diff --git a/lms/djangoapps/licenses/__init__.py b/lms/djangoapps/licenses/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/licenses/management/__init__.py b/lms/djangoapps/licenses/management/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/licenses/management/commands/__init__.py b/lms/djangoapps/licenses/management/commands/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/licenses/management/commands/generate_serial_numbers.py b/lms/djangoapps/licenses/management/commands/generate_serial_numbers.py new file mode 100644 index 0000000000000000000000000000000000000000..7c6b0d310e7e473ee92fd72cb58782418babf4cd --- /dev/null +++ b/lms/djangoapps/licenses/management/commands/generate_serial_numbers.py @@ -0,0 +1,65 @@ +import os.path +from uuid import uuid4 +from optparse import make_option + +from django.utils.html import escape +from django.core.management.base import BaseCommand, CommandError + +from xmodule.modulestore.django import modulestore + +from licenses.models import CourseSoftware, UserLicense + + +class Command(BaseCommand): + help = """Generate random serial numbers for software used in a course. + + Usage: generate_serial_numbers <course_id> <software_name> <count> + + <count> is the number of numbers to generate. + + Example: + + import_serial_numbers MITx/6.002x/2012_Fall matlab 100 + + """ + args = "course_id software_id count" + + def handle(self, *args, **options): + """ + """ + course_id, software_name, count = self._parse_arguments(args) + + software, _ = CourseSoftware.objects.get_or_create(course_id=course_id, + name=software_name) + self._generate_serials(software, count) + + def _parse_arguments(self, args): + if len(args) != 3: + raise CommandError("Incorrect number of arguments") + + course_id = args[0] + courses = modulestore().get_courses() + known_course_ids = set(c.id for c in courses) + + if course_id not in known_course_ids: + raise CommandError("Unknown course_id") + + software_name = escape(args[1].lower()) + + try: + count = int(args[2]) + except ValueError: + raise CommandError("Invalid <count> argument.") + + return course_id, software_name, count + + def _generate_serials(self, software, count): + print "Generating {0} serials".format(count) + + # add serial numbers them to the database + for _ in xrange(count): + serial = str(uuid4()) + license = UserLicense(software=software, serial=serial) + license.save() + + print "{0} new serial numbers generated.".format(count) diff --git a/lms/djangoapps/licenses/management/commands/import_serial_numbers.py b/lms/djangoapps/licenses/management/commands/import_serial_numbers.py new file mode 100644 index 0000000000000000000000000000000000000000..a3a8c0bad1017415f1e05c02391470153d8e3fec --- /dev/null +++ b/lms/djangoapps/licenses/management/commands/import_serial_numbers.py @@ -0,0 +1,70 @@ +import os.path +from optparse import make_option + +from django.utils.html import escape +from django.core.management.base import BaseCommand, CommandError + +from xmodule.modulestore.django import modulestore + +from licenses.models import CourseSoftware, UserLicense + + +class Command(BaseCommand): + help = """Imports serial numbers for software used in a course. + + Usage: import_serial_numbers <course_id> <software_name> <file> + + <file> is a text file that list one available serial number per line. + + Example: + + import_serial_numbers MITx/6.002x/2012_Fall matlab serials.txt + + """ + args = "course_id software_id serial_file" + + def handle(self, *args, **options): + """ + """ + course_id, software_name, filename = self._parse_arguments(args) + + software, _ = CourseSoftware.objects.get_or_create(course_id=course_id, + name=software_name) + self._import_serials(software, filename) + + def _parse_arguments(self, args): + if len(args) != 3: + raise CommandError("Incorrect number of arguments") + + course_id = args[0] + courses = modulestore().get_courses() + known_course_ids = set(c.id for c in courses) + + if course_id not in known_course_ids: + raise CommandError("Unknown course_id") + + software_name = escape(args[1].lower()) + + filename = os.path.abspath(args[2]) + if not os.path.exists(filename): + raise CommandError("Cannot find filename {0}".format(filename)) + + return course_id, software_name, filename + + def _import_serials(self, software, filename): + print "Importing serial numbers for {0}.".format(software) + + serials = set(unicode(l.strip()) for l in open(filename)) + + # remove serial numbers we already have + licenses = UserLicense.objects.filter(software=software) + known_serials = set(l.serial for l in licenses) + if known_serials: + serials = serials.difference(known_serials) + + # add serial numbers them to the database + for serial in serials: + license = UserLicense(software=software, serial=serial) + license.save() + + print "{0} new serial numbers imported.".format(len(serials)) diff --git a/lms/djangoapps/licenses/models.py b/lms/djangoapps/licenses/models.py new file mode 100644 index 0000000000000000000000000000000000000000..d259892f5d61cfccccb51dbc6555d6ad2f5cd689 --- /dev/null +++ b/lms/djangoapps/licenses/models.py @@ -0,0 +1,78 @@ +import logging + +from django.db import models, transaction + +from student.models import User + +log = logging.getLogger("mitx.licenses") + + +class CourseSoftware(models.Model): + name = models.CharField(max_length=255) + full_name = models.CharField(max_length=255) + url = models.CharField(max_length=255) + course_id = models.CharField(max_length=255) + + def __unicode__(self): + return u'{0} for {1}'.format(self.name, self.course_id) + + +class UserLicense(models.Model): + software = models.ForeignKey(CourseSoftware, db_index=True) + user = models.ForeignKey(User, null=True) + serial = models.CharField(max_length=255) + + +def get_courses_licenses(user, courses): + course_ids = set(course.id for course in courses) + all_software = CourseSoftware.objects.filter(course_id__in=course_ids) + + assigned_licenses = UserLicense.objects.filter(software__in=all_software, + user=user) + + licenses = dict.fromkeys(all_software, None) + for license in assigned_licenses: + licenses[license.software] = license + + log.info(assigned_licenses) + log.info(licenses) + + return licenses + + +def get_license(user, software): + try: + license = UserLicense.objects.get(user=user, software=software) + except UserLicense.DoesNotExist: + license = None + + return license + + +def get_or_create_license(user, software): + license = get_license(user, software) + if license is None: + license = _create_license(user, software) + + return license + + +def _create_license(user, software): + license = None + + try: + # find one license that has not been assigned, locking the + # table/rows with select_for_update to prevent race conditions + with transaction.commit_on_success(): + selected = UserLicense.objects.select_for_update() + license = selected.filter(user__isnull=True, software=software)[0] + license.user = user + license.save() + except IndexError: + # there are no free licenses + log.error('No serial numbers available for {0}', software) + license = None + # TODO [rocha]look if someone has unenrolled from the class + # and already has a serial number + + return license diff --git a/lms/djangoapps/licenses/tests.py b/lms/djangoapps/licenses/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..f06899d2decfc99b7ef7331546bb27654195983a --- /dev/null +++ b/lms/djangoapps/licenses/tests.py @@ -0,0 +1,85 @@ +import logging +from uuid import uuid4 +from random import shuffle +from tempfile import NamedTemporaryFile + +from django.test import TestCase +from django.core.management import call_command + +from models import CourseSoftware, UserLicense + +COURSE_1 = 'MITx/6.002x/2012_Fall' + +SOFTWARE_1 = 'matlab' +SOFTWARE_2 = 'stata' + +log = logging.getLogger(__name__) + + +class CommandTest(TestCase): + def test_import_serial_numbers(self): + size = 20 + + log.debug('Adding one set of serials for {0}'.format(SOFTWARE_1)) + with generate_serials_file(size) as temp_file: + args = [COURSE_1, SOFTWARE_1, temp_file.name] + call_command('import_serial_numbers', *args) + + log.debug('Adding one set of serials for {0}'.format(SOFTWARE_2)) + with generate_serials_file(size) as temp_file: + args = [COURSE_1, SOFTWARE_2, temp_file.name] + call_command('import_serial_numbers', *args) + + log.debug('There should be only 2 course-software entries') + software_count = CourseSoftware.objects.all().count() + self.assertEqual(2, software_count) + + log.debug('We added two sets of {0} serials'.format(size)) + licenses_count = UserLicense.objects.all().count() + self.assertEqual(2 * size, licenses_count) + + log.debug('Adding more serial numbers to {0}'.format(SOFTWARE_1)) + with generate_serials_file(size) as temp_file: + args = [COURSE_1, SOFTWARE_1, temp_file.name] + call_command('import_serial_numbers', *args) + + log.debug('There should be still only 2 course-software entries') + software_count = CourseSoftware.objects.all().count() + self.assertEqual(2, software_count) + + log.debug('Now we should have 3 sets of 20 serials'.format(size)) + licenses_count = UserLicense.objects.all().count() + self.assertEqual(3 * size, licenses_count) + + cs = CourseSoftware.objects.get(pk=1) + + lics = UserLicense.objects.filter(software=cs)[:size] + known_serials = list(l.serial for l in lics) + known_serials.extend(generate_serials(10)) + + shuffle(known_serials) + + log.debug('Adding some new and old serials to {0}'.format(SOFTWARE_1)) + with NamedTemporaryFile() as f: + f.write('\n'.join(known_serials)) + f.flush() + args = [COURSE_1, SOFTWARE_1, f.name] + call_command('import_serial_numbers', *args) + + log.debug('Check if we added only the new ones') + licenses_count = UserLicense.objects.filter(software=cs).count() + self.assertEqual((2 * size) + 10, licenses_count) + + +def generate_serials(size=20): + return [str(uuid4()) for _ in range(size)] + + +def generate_serials_file(size=20): + serials = generate_serials(size) + + temp_file = NamedTemporaryFile() + temp_file.write('\n'.join(serials)) + temp_file.flush() + + return temp_file diff --git a/lms/djangoapps/licenses/views.py b/lms/djangoapps/licenses/views.py new file mode 100644 index 0000000000000000000000000000000000000000..7d804fbd3d5c32b6bb6376047648a62402e09b12 --- /dev/null +++ b/lms/djangoapps/licenses/views.py @@ -0,0 +1,84 @@ +import logging +import json +import re +from urlparse import urlparse +from collections import namedtuple, defaultdict + + +from mitxmako.shortcuts import render_to_string + +from django.contrib.auth.models import User +from django.http import HttpResponse, Http404 +from django.views.decorators.csrf import requires_csrf_token, csrf_protect + +from models import CourseSoftware +from models import get_courses_licenses, get_or_create_license, get_license + + +log = logging.getLogger("mitx.licenses") + + +License = namedtuple('License', 'software serial') + + +def get_licenses_by_course(user, courses): + licenses = get_courses_licenses(user, courses) + licenses_by_course = defaultdict(list) + + # create missing licenses and group by course_id + for software, license in licenses.iteritems(): + if license is None: + licenses[software] = get_or_create_license(user, software) + + course_id = software.course_id + serial = license.serial if license else None + licenses_by_course[course_id].append(License(software, serial)) + + # render elements + data_by_course = {} + for course_id, licenses in licenses_by_course.iteritems(): + context = {'licenses': licenses} + template = 'licenses/serial_numbers.html' + data_by_course[course_id] = render_to_string(template, context) + + return data_by_course + + +@requires_csrf_token +def user_software_license(request): + if request.method != 'POST' or not request.is_ajax(): + raise Http404 + + # get the course id from the referer + url_path = urlparse(request.META.get('HTTP_REFERER', '')).path + pattern = re.compile('^/courses/(?P<id>[^/]+/[^/]+/[^/]+)/.*/?$') + match = re.match(pattern, url_path) + + if not match: + raise Http404 + course_id = match.groupdict().get('id', '') + + user_id = request.session.get('_auth_user_id') + software_name = request.POST.get('software') + generate = request.POST.get('generate', False) == 'true' + + try: + software = CourseSoftware.objects.get(name=software_name, + course_id=course_id) + print software + except CourseSoftware.DoesNotExist: + raise Http404 + + user = User.objects.get(id=user_id) + + if generate: + license = get_or_create_license(user, software) + else: + license = get_license(user, software) + + if license: + response = {'serial': license.serial} + else: + response = {'error': 'No serial number found'} + + return HttpResponse(json.dumps(response), mimetype='application/json') diff --git a/lms/envs/common.py b/lms/envs/common.py index a927da8e98251c17e8d7005710dbc82cd8618ab2..9b98e4ecfda3f50dae69609a6e182fea79668dbd 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -626,6 +626,7 @@ INSTALLED_APPS = ( 'certificates', 'instructor', 'psychometrics', + 'licenses', #For the wiki 'wiki', # The new django-wiki from benjaoming diff --git a/lms/templates/licenses/serial_numbers.html b/lms/templates/licenses/serial_numbers.html new file mode 100644 index 0000000000000000000000000000000000000000..18f0ff8a9bfd425ca83eace673495057976c66aa --- /dev/null +++ b/lms/templates/licenses/serial_numbers.html @@ -0,0 +1,10 @@ +<dl> +% for license in licenses: + <dt> ${license.software.name}: </dt> + % if license.serial: + <dd> ${license.serial} </dd> + % else: + <dd> None Available </dd> + % endif +% endfor +</dl> diff --git a/lms/urls.py b/lms/urls.py index 89a541ab0696dbafc69c17e69ae08c84fe308d2f..e02547838777a418099cdfc62043d3a683cd5a06 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -154,6 +154,14 @@ if settings.COURSEWARE_ENABLED: url(r'^preview/chemcalc', 'courseware.module_render.preview_chemcalc', name='preview_chemcalc'), + # Software Licenses + + # TODO: for now, this is the endpoint of an ajax replay + # service that retrieve and assigns license numbers for + # software assigned to a course. The numbers have to be loaded + # into the database. + url(r'^software-licenses$', 'licenses.views.user_software_license', name="user_software_license"), + url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/xqueue/(?P<userid>[^/]*)/(?P<id>.*?)/(?P<dispatch>[^/]*)$', 'courseware.module_render.xqueue_callback', name='xqueue_callback'),