diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 61313376d1df8d26de1924091a65d0ddf8cdc323..235f4b414f1027abb096ba72734a2c75ea0e4ce4 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -39,12 +39,15 @@ from certificates.models import CertificateStatuses, certificate_status_for_stud from xmodule.course_module import CourseDescriptor from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.django import modulestore +from xmodule.modulestore import Location from collections import namedtuple from courseware.courses import get_courses, sort_by_announcement from courseware.access import has_access -from courseware.models import TimedModule +from courseware.models import StudentModuleCache +from courseware.views import get_module_for_descriptor +from courseware.module_render import get_instance_module from statsd import statsd @@ -1082,13 +1085,14 @@ def test_center_login(request): # errors are returned by navigating to the error_url, adding a query parameter named "code" # which contains the error code describing the exceptional condition. def makeErrorURL(error_url, error_code): - return "{}&code={}".format(error_url, error_code); + log.error("generating error URL with error code {}".format(error_code)) + return "{}?code={}".format(error_url, error_code); # get provided error URL, which will be used as a known prefix for returning error messages to the - # Pearson shell. It does not have a trailing slash, so we need to add one when creating output URLs. + # Pearson shell. error_url = request.POST.get("errorURL") - # check that the parameters have not been tampered with, by comparing the code provided by Pearson + # TODO: check that the parameters have not been tampered with, by comparing the code provided by Pearson # with the code we calculate for the same parameters. if 'code' not in request.POST: return HttpResponseRedirect(makeErrorURL(error_url, "missingSecurityCode")); @@ -1112,65 +1116,81 @@ def test_center_login(request): try: testcenteruser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id) except TestCenterUser.DoesNotExist: + log.error("not able to find demographics for cand ID {}".format(client_candidate_id)) return HttpResponseRedirect(makeErrorURL(error_url, "invalidClientCandidateID")); # find testcenter_registration that matches the provided exam code: - # Note that we could rely on either the registrationId or the exam code, - # or possibly both. + # Note that we could rely in future on either the registrationId or the exam code, + # or possibly both. But for now we know what to do with an ExamSeriesCode, + # while we currently have no record of RegistrationID values at all. if 'vueExamSeriesCode' not in request.POST: # TODO: confirm this error code (made up, not in documentation) + log.error("missing exam series code for cand ID {}".format(client_candidate_id)) return HttpResponseRedirect(makeErrorURL(error_url, "missingExamSeriesCode")); exam_series_code = request.POST.get('vueExamSeriesCode') registrations = TestCenterRegistration.objects.filter(testcenter_user=testcenteruser, exam_series_code=exam_series_code) - if not registrations: + log.error("not able to find exam registration for exam {} and cand ID {}".format(exam_series_code, client_candidate_id)) return HttpResponseRedirect(makeErrorURL(error_url, "noTestsAssigned")); # TODO: figure out what to do if there are more than one registrations.... # for now, just take the first... registration = registrations[0] - course_id = registration.course_id - # if we want to look up whether the test has already been taken, or to - # communicate that a time accommodation needs to be applied, we need to - # know the module_id to use that corresponds to the particular exam_series_code. - # For now, we can hardcode that... - if exam_series_code == '6002x001': - # This should not be hardcoded here, but should be added to the exam definition. - # TODO: look the location up in the course, by finding the exam_info with the matching code, - # and get the location from that. - location = 'i4x://MITx/6.002x/sequential/Final_Exam_Fall_2012' - redirect_url = reverse('jump_to', kwargs={'course_id': course_id, 'location': location}) - else: - # TODO: clarify if this is the right error code for this condition. + course_id = registration.course_id + course = course_from_id(course_id) # assume it will be found.... + if not course: + log.error("not able to find course from ID {} for cand ID {}".format(course_id, client_candidate_id)) return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")); + exam = course.get_test_center_exam(exam_series_code) + if not exam: + log.error("not able to find exam {} for course ID {} and cand ID {}".format(exam_series_code, course_id, client_candidate_id)) + return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")); + location = exam.exam_url + redirect_url = reverse('jump_to', kwargs={'course_id': course_id, 'location': location}) + + log.info("proceeding with test of cand {} on exam {} for course {}: URL = {}".format(client_candidate_id, exam_series_code, course_id, location)) + + # check if the test has already been taken + timelimit_descriptor = modulestore().get_instance(course_id, Location(location)) + if not timelimit_descriptor: + log.error("cand {} on exam {} for course {}: descriptor not found for location {}".format(client_candidate_id, exam_series_code, course_id, location)) + return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")); + + timelimit_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course_id, testcenteruser.user, + timelimit_descriptor, depth=None) + timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor, + timelimit_module_cache, course_id, position=None) + if not timelimit_module.category == 'timelimit': + log.error("cand {} on exam {} for course {}: non-timelimit module at location {}".format(client_candidate_id, exam_series_code, course_id, location)) + return HttpResponseRedirect(makeErrorURL(error_url, "missingClientProgram")); + + if timelimit_module and timelimit_module.has_ended: + log.warning("cand {} on exam {} for course {}: test already over at {}".format(client_candidate_id, exam_series_code, course_id, timelimit_module.ending_at)) + return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken")); - + # check if we need to provide an accommodation: time_accommodation_mapping = {'ET12ET' : 'ADDHALFTIME', 'ET30MN' : 'ADD30MIN', 'ETDBTM' : 'ADDDOUBLE', } - - # check if the test has already been taken - timed_modules = TimedModule.objects.filter(student=testcenteruser.user, course_id=course_id, location=location) - if timed_modules: - timed_module = timed_modules[0] - if timed_module.has_ended: - return HttpResponseRedirect(makeErrorURL(error_url, "allTestsTaken")); - elif registration.get_accommodation_codes(): - # we don't have a timed module created yet, so if we have time accommodations - # to implement, create an entry now: - time_accommodation_code = None + + time_accommodation_code = None + if registration.get_accommodation_codes(): for code in registration.get_accommodation_codes(): if code in time_accommodation_mapping: time_accommodation_code = time_accommodation_mapping[code] - if client_candidate_id == "edX003671291147": - time_accommodation_code = 'TESTING' - if time_accommodation_code: - timed_module = TimedModule(student=request.user, course_id=course_id, location=location) - timed_module.accommodation_code = time_accommodation_code - timed_module.save() + # special, hard-coded client ID used by Pearson shell for testing: + if client_candidate_id == "edX003671291147": + time_accommodation_code = 'TESTING' + + if time_accommodation_code: + timelimit_module.accommodation_code = time_accommodation_code + instance_module = get_instance_module(course_id, testcenteruser.user, timelimit_module, timelimit_module_cache) + instance_module.state = timelimit_module.get_instance_state() + instance_module.save() + log.info("cand {} on exam {} for course {}: receiving accommodation {}".format(client_candidate_id, exam_series_code, course_id, time_accommodation_code)) # UGLY HACK!!! # Login assumes that authentication has occurred, and that there is a diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index f61e6f6f36546976e2fd4996b2ddf459e3e35262..d3a0562b414e72f902e7eedd24b998fa91dcef88 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -23,7 +23,6 @@ setup( "course = xmodule.course_module:CourseDescriptor", "customtag = xmodule.template_module:CustomTagDescriptor", "discuss = xmodule.backcompat_module:TranslateCustomTagDescriptor", - "fixedtime = xmodule.fixed_time_module:FixedTimeDescriptor", "html = xmodule.html_module:HtmlDescriptor", "image = xmodule.backcompat_module:TranslateCustomTagDescriptor", "error = xmodule.error_module:ErrorDescriptor", @@ -32,6 +31,7 @@ setup( "section = xmodule.backcompat_module:SemanticSectionDescriptor", "sequential = xmodule.seq_module:SequenceDescriptor", "slides = xmodule.backcompat_module:TranslateCustomTagDescriptor", + "timelimit = xmodule.timelimit_module:TimeLimitDescriptor", "vertical = xmodule.vertical_module:VerticalDescriptor", "video = xmodule.video_module:VideoDescriptor", "videodev = xmodule.backcompat_module:TranslateCustomTagDescriptor", diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 6e3e2cfa39ab8118e413ad4bbe7e0cf100bc36b4..4c5c3a0a90e5fdfa60fb4bc42327b70e5bb494c2 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -648,7 +648,7 @@ class CourseDescriptor(SequenceDescriptor): raise ValueError("First appointment date must be before last appointment date") if self.registration_end_date > self.last_eligible_appointment_date: raise ValueError("Registration end date must be before last appointment date") - + self.exam_url = exam_info.get('Exam_URL') def _try_parse_time(self, key): """ @@ -704,6 +704,10 @@ class CourseDescriptor(SequenceDescriptor): else: return None + def get_test_center_exam(self, exam_series_code): + exams = [exam for exam in self.test_center_exams if exam.exam_series_code == exam_series_code] + return exams[0] if len(exams) == 1 else None + @property def title(self): return self.display_name diff --git a/common/lib/xmodule/xmodule/fixed_time_module.py b/common/lib/xmodule/xmodule/timelimit_module.py similarity index 77% rename from common/lib/xmodule/xmodule/fixed_time_module.py rename to common/lib/xmodule/xmodule/timelimit_module.py index f1fec26dc3af11502284a19867a2aaf425adf0a2..23ed06eb5964d4a1d9857ed0ad705f29fa9dba45 100644 --- a/common/lib/xmodule/xmodule/fixed_time_module.py +++ b/common/lib/xmodule/xmodule/timelimit_module.py @@ -4,22 +4,16 @@ import logging from lxml import etree from time import time -from xmodule.mako_module import MakoModuleDescriptor +from xmodule.editing_module import XMLEditingDescriptor from xmodule.xml_module import XmlDescriptor from xmodule.x_module import XModule from xmodule.progress import Progress from xmodule.exceptions import NotFoundError -from pkg_resources import resource_string log = logging.getLogger(__name__) -# HACK: This shouldn't be hard-coded to two types -# OBSOLETE: This obsoletes 'type' -# class_priority = ['video', 'problem'] - - -class FixedTimeModule(XModule): +class TimeLimitModule(XModule): ''' Wrapper module which imposes a time constraint for the completion of its child. ''' @@ -29,9 +23,7 @@ class FixedTimeModule(XModule): XModule.__init__(self, system, location, definition, descriptor, instance_state, shared_state, **kwargs) - # NOTE: Position is 1-indexed. This is silly, but there are now student - # positions saved on prod, so it's not easy to fix. -# self.position = 1 + self.rendered = False self.beginning_at = None self.ending_at = None self.accommodation_code = None @@ -46,13 +38,6 @@ class FixedTimeModule(XModule): if 'accommodation_code' in state: self.accommodation_code = state['accommodation_code'] - - # if position is specified in system, then use that instead -# if system.get('position'): -# self.position = int(system.get('position')) - - self.rendered = False - # For a timed activity, we are only interested here # in time-related accommodations, and these should be disjoint. # (For proctored exams, it is possible to have multiple accommodations @@ -81,8 +66,6 @@ class FixedTimeModule(XModule): elif self.accommodation_code == 'TESTING': # when testing, set timer to run for a week at a time. return 3600 * 24 * 7 - - # store state: @property def has_begun(self): @@ -101,8 +84,6 @@ class FixedTimeModule(XModule): ''' self.beginning_at = time() modified_duration = self._get_accommodated_duration(duration) - # datetime_duration = timedelta(seconds=modified_duration) - # self.ending_at = self.beginning_at + datetime_duration self.ending_at = self.beginning_at + modified_duration def get_end_time_in_ms(self): @@ -132,31 +113,32 @@ class FixedTimeModule(XModule): progress = reduce(Progress.add_counts, progresses) return progress - def handle_ajax(self, dispatch, get): # TODO: bounds checking -# ''' get = request.POST instance ''' -# if dispatch == 'goto_position': -# self.position = int(get['position']) -# return json.dumps({'success': True}) + def handle_ajax(self, dispatch, get): raise NotFoundError('Unexpected dispatch type') def render(self): if self.rendered: return # assumes there is one and only one child, so it only renders the first child - child = self.get_display_items()[0] - self.content = child.get_html() + children = self.get_display_items() + if children: + child = children[0] + self.content = child.get_html() self.rendered = True def get_icon_class(self): - return self.get_children()[0].get_icon_class() + children = self.get_children() + if children: + return children[0].get_icon_class() + else: + return "other" +class TimeLimitDescriptor(XMLEditingDescriptor, XmlDescriptor): -class FixedTimeDescriptor(MakoModuleDescriptor, XmlDescriptor): - # TODO: fix this template?! - mako_template = 'widgets/sequence-edit.html' - module_class = FixedTimeModule + module_class = TimeLimitModule - stores_state = True # For remembering when a student started, and when they should end + # For remembering when a student started, and when they should end + stores_state = True @classmethod def definition_from_xml(cls, xml_object, system): @@ -165,14 +147,14 @@ class FixedTimeDescriptor(MakoModuleDescriptor, XmlDescriptor): try: children.append(system.process_xml(etree.tostring(child, encoding='unicode')).location.url()) except Exception as e: - log.exception("Unable to load child when parsing FixedTime wrapper. Continuing...") + log.exception("Unable to load child when parsing TimeLimit wrapper. Continuing...") if system.error_tracker is not None: system.error_tracker("ERROR: " + str(e)) continue return {'children': children} def definition_to_xml(self, resource_fs): - xml_object = etree.Element('fixedtime') + xml_object = etree.Element('timelimit') for child in self.get_children(): xml_object.append( etree.fromstring(child.export_to_xml(resource_fs))) diff --git a/lms/djangoapps/courseware/migrations/0006_add_timed_module.py b/lms/djangoapps/courseware/migrations/0006_add_timed_module.py deleted file mode 100644 index 6e8791a97520e8baf04c19aa3b15bfd923e64fa1..0000000000000000000000000000000000000000 --- a/lms/djangoapps/courseware/migrations/0006_add_timed_module.py +++ /dev/null @@ -1,119 +0,0 @@ -# -*- coding: utf-8 -*- -import datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - - -class Migration(SchemaMigration): - - def forwards(self, orm): - # Adding model 'TimedModule' - db.create_table('courseware_timedmodule', ( - ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('location', self.gf('django.db.models.fields.CharField')(max_length=255, db_column='location', db_index=True)), - ('student', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), - ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), - ('accommodation_code', self.gf('django.db.models.fields.CharField')(default='NONE', max_length=12, db_index=True)), - ('beginning_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), - ('ending_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), - ('created_at', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, db_index=True, blank=True)), - ('modified_at', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, db_index=True, blank=True)), - )) - db.send_create_signal('courseware', ['TimedModule']) - - # Adding unique constraint on 'TimedModule', fields ['student', 'location', 'course_id'] - db.create_unique('courseware_timedmodule', ['student_id', 'location', 'course_id']) - - - def backwards(self, orm): - # Removing unique constraint on 'TimedModule', fields ['student', 'location', 'course_id'] - db.delete_unique('courseware_timedmodule', ['student_id', 'location', 'course_id']) - - # Deleting model 'TimedModule' - db.delete_table('courseware_timedmodule') - - - models = { - 'auth.group': { - 'Meta': {'object_name': 'Group'}, - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - 'auth.permission': { - 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - 'auth.user': { - 'Meta': {'object_name': 'User'}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) - }, - 'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - 'courseware.offlinecomputedgrade': { - 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'}, - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), - 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), - 'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'courseware.offlinecomputedgradelog': { - 'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'}, - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), - 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}), - 'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'}) - }, - 'courseware.studentmodule': { - 'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'}, - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), - 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), - 'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}), - 'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), - 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), - 'module_state_key': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}), - 'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}), - 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), - 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'courseware.timedmodule': { - 'Meta': {'unique_together': "(('student', 'location', 'course_id'),)", 'object_name': 'TimedModule'}, - 'accommodation_code': ('django.db.models.fields.CharField', [], {'default': "'NONE'", 'max_length': '12', 'db_index': 'True'}), - 'beginning_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), - 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), - 'ending_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'location': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_column': "'location'", 'db_index': 'True'}), - 'modified_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), - 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - } - } - - complete_apps = ['courseware'] \ No newline at end of file diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index d9cc560215c84c62a7a92fc88f2e0bff454f3808..87b9edaac2330335abe679362f595a773c89637b 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -212,87 +212,3 @@ class OfflineComputedGradeLog(models.Model): def __unicode__(self): return "[OCGLog] %s: %s" % (self.course_id, self.created) -class TimedModule(models.Model): - """ - Keeps student state for a timed activity in a particular course. - Includes information about time accommodations granted, - time started, and ending time. - """ - ## These three are the key for the object - - # Key used to share state. By default, this is the module_id, - # but for abtests and the like, this can be set to a shared value - # for many instances of the module. - # Filename for homeworks, etc. - # module_state_key = models.CharField(max_length=255, db_index=True, db_column='module_id') - location = models.CharField(max_length=255, db_index=True, db_column='location') - student = models.ForeignKey(User, db_index=True) - course_id = models.CharField(max_length=255, db_index=True) - - class Meta: -# unique_together = (('student', 'module_state_key', 'course_id'),) - unique_together = (('student', 'location', 'course_id'),) - - # For a timed activity, we are only interested here - # in time-related accommodations, and these should be disjoint. - # (For proctored exams, it is possible to have multiple accommodations - # apply to an exam, so they require accommodating a multi-choice.) - TIME_ACCOMMODATION_CODES = (('NONE', 'No Time Accommodation'), - ('ADDHALFTIME', 'Extra Time - 1 1/2 Time'), - ('ADD30MIN', 'Extra Time - 30 Minutes'), - ('DOUBLE', 'Extra Time - Double Time'), - ('TESTING', 'Extra Time -- Large amount for testing purposes') - ) - accommodation_code = models.CharField(max_length=12, choices=TIME_ACCOMMODATION_CODES, default='NONE', db_index=True) - - def _get_accommodated_duration(self, duration): - ''' - Get duration for activity, as adjusted for accommodations. - Input and output are expressed in seconds. - ''' - if self.accommodation_code == 'NONE': - return duration - elif self.accommodation_code == 'ADDHALFTIME': - # TODO: determine what type to return - return int(duration * 1.5) - elif self.accommodation_code == 'ADD30MIN': - return (duration + (30 * 60)) - elif self.accommodation_code == 'DOUBLE': - return (duration * 2) - elif self.accommodation_code == 'TESTING': - # when testing, set timer to run for a week at a time. - return 3600 * 24 * 7 - - # store state: - - beginning_at = models.DateTimeField(null=True, db_index=True) - ending_at = models.DateTimeField(null=True, db_index=True) - created_at = models.DateTimeField(auto_now_add=True, db_index=True) - modified_at = models.DateTimeField(auto_now=True, db_index=True) - - @property - def has_begun(self): - return self.beginning_at is not None - - @property - def has_ended(self): - if not self.ending_at: - return False - return self.ending_at < datetime.utcnow() - - def begin(self, duration): - ''' - Sets the starting time and ending time for the activity, - based on the duration provided (in seconds). - ''' - self.beginning_at = datetime.utcnow() - modified_duration = self._get_accommodated_duration(duration) - datetime_duration = timedelta(seconds=modified_duration) - self.ending_at = self.beginning_at + datetime_duration - - def get_end_time_in_ms(self): - return (timegm(self.ending_at.timetuple()) * 1000) - - def __unicode__(self): - return '/'.join([self.course_id, self.student.username, self.module_state_key]) - diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 0acf435f0bc31339aea2a7d71fb748a3f95e7e89..07b177979a9cbe59b16fd53a83728a8aea8ed4ca 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -20,7 +20,7 @@ from courseware.access import has_access from courseware.courses import (get_courses, get_course_with_access, get_courses_by_university, sort_by_announcement) import courseware.tabs as tabs -from courseware.models import StudentModuleCache, TimedModule +from courseware.models import StudentModule, StudentModuleCache from module_render import toc_for_course, get_module, get_instance_module, get_module_for_descriptor from django_comment_client.utils import get_discussion_title @@ -31,6 +31,7 @@ from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, NoPathToItem from xmodule.modulestore.search import path_to_location +#from xmodule.fixed_time_module import FixedTimeModule import comment_client @@ -152,6 +153,80 @@ def save_child_position(seq_module, child_name, instance_module): instance_module.state = seq_module.get_instance_state() instance_module.save() +def check_for_active_timelimit_module(request, course_id, course): + ''' + Looks for a timing module for the given user and course that is currently active. + If found, returns a context dict with timer-related values to enable display of time remaining. + ''' + context = {} + timelimit_student_modules = StudentModule.objects.filter(student=request.user, course_id=course_id, module_type='timelimit') + if timelimit_student_modules: + for timelimit_student_module in timelimit_student_modules: + # get the corresponding section_descriptor for the given StudentModel entry: + module_state_key = timelimit_student_module.module_state_key + timelimit_descriptor = modulestore().get_instance(course_id, Location(module_state_key)) + timelimit_module_cache = StudentModuleCache.cache_for_descriptor_descendents(course.id, request.user, + timelimit_descriptor, depth=None) + timelimit_module = get_module_for_descriptor(request.user, request, timelimit_descriptor, + timelimit_module_cache, course.id, position=None) + if timelimit_module is not None and timelimit_module.category == 'timelimit' and \ + timelimit_module.has_begun and not timelimit_module.has_ended: + location = timelimit_module.location + # determine where to go when the timer expires: + if 'time_expired_redirect_url' not in timelimit_descriptor.metadata: + # TODO: provide a better error + raise Http404 + time_expired_redirect_url = timelimit_descriptor.metadata.get('time_expired_redirect_url') + context['time_expired_redirect_url'] = time_expired_redirect_url + # Fetch the end time (in GMT) as stored in the module when it was started. + # This value should be UTC time as number of milliseconds since epoch. + end_date = timelimit_module.get_end_time_in_ms() + context['timer_expiration_datetime'] = end_date + if 'suppress_toplevel_navigation' in timelimit_descriptor.metadata: + context['suppress_toplevel_navigation'] = timelimit_descriptor.metadata['suppress_toplevel_navigation'] + return_url = reverse('jump_to', kwargs={'course_id':course_id, 'location':location}) + context['timer_navigation_return_url'] = return_url + return context + +def update_timelimit_module(user, course_id, student_module_cache, timelimit_descriptor, timelimit_module): + ''' + Updates the state of the provided timing module, starting it if it hasn't begun. + Returns dict with timer-related values to enable display of time remaining. + Returns 'timer_expiration_datetime' in dict if timer is still active, and not if timer has expired. + ''' + context = {} + # determine where to go when the exam ends: + if 'time_expired_redirect_url' not in timelimit_descriptor.metadata: + # TODO: provide a better error + raise Http404 + time_expired_redirect_url = timelimit_descriptor.metadata.get('time_expired_redirect_url') + context['time_expired_redirect_url'] = time_expired_redirect_url + + if not timelimit_module.has_ended: + if not timelimit_module.has_begun: + # user has not started the exam, so start it now. + if 'duration' not in timelimit_descriptor.metadata: + # TODO: provide a better error + raise Http404 + # The user may have an accommodation that has been granted to them. + # This accommodation information should already be stored in the module's state. + duration = int(timelimit_descriptor.metadata.get('duration')) + timelimit_module.begin(duration) + # we have changed state, so we need to persist the change: + instance_module = get_instance_module(course_id, user, timelimit_module, student_module_cache) + instance_module.state = timelimit_module.get_instance_state() + instance_module.save() + + # the exam has been started, either because the student is returning to the + # exam page, or because they have just visited it. Fetch the end time (in GMT) as stored + # in the module when it was started. + # This value should be UTC time as number of milliseconds since epoch. + context['timer_expiration_datetime'] = timelimit_module.get_end_time_in_ms() + # also use the timed module to determine whether top-level navigation is visible: + if 'suppress_toplevel_navigation' in timelimit_descriptor.metadata: + context['suppress_toplevel_navigation'] = timelimit_descriptor.metadata['suppress_toplevel_navigation'] + return context + @login_required @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @@ -215,43 +290,6 @@ def index(request, course_id, chapter=None, section=None, 'xqa_server': settings.MITX_FEATURES.get('USE_XQA_SERVER','http://xqa:server@content-qa.mitx.mit.edu/xqa') } - # check here if this page is within a course that has an active timed module running. If so, then - # display the appropriate timer information: - timed_modules = TimedModule.objects.filter(student=request.user, course_id=course_id) - if timed_modules: - for timed_module in timed_modules: - if timed_module.has_begun and not timed_module.has_ended: - # a timed module has been found that is active, so display - # the relevant time: - # module_state_key = timed_module.module_state_key - location = timed_module.location - - # when we actually make the state be stored in the StudentModule, then - # we can fetch what we need from that. - # student_module = student_module_cache.lookup(course_id, 'sequential', module_state_key) - # But the module doesn't give us anything helpful to find the corresponding descriptor - - # get the corresponding section_descriptor for this timed_module entry: - section_descriptor = modulestore().get_instance(course_id, Location(location)) - - # determine where to go when the timer expires: - # Note that if we could get this from the timed_module, we wouldn't have to - # fetch the section_descriptor in the first place. - if 'time_expired_redirect_url' not in section_descriptor.metadata: - raise Http404 - time_expired_redirect_url = section_descriptor.metadata.get('time_expired_redirect_url') - context['time_expired_redirect_url'] = time_expired_redirect_url - - # Fetch the end time (in GMT) as stored in the module when it was started. - # This value should be UTC time as number of milliseconds since epoch. - end_date = timed_module.get_end_time_in_ms() - context['timer_expiration_datetime'] = end_date - if 'suppress_toplevel_navigation' in section_descriptor.metadata: - context['suppress_toplevel_navigation'] = section_descriptor.metadata['suppress_toplevel_navigation'] - return_url = reverse('jump_to', kwargs={'course_id': course_id, 'location': location}) - context['timer_navigation_return_url'] = return_url - - chapter_descriptor = course.get_child_by(lambda m: m.url_name == chapter) if chapter_descriptor is not None: instance_module = get_instance_module(course_id, request.user, course_module, student_module_cache) @@ -286,7 +324,20 @@ def index(request, course_id, chapter=None, section=None, instance_module = get_instance_module(course_id, request.user, chapter_module, student_module_cache) save_child_position(chapter_module, section, instance_module) - + # check here if this section *is* a timed module. + if section_module.category == 'timelimit': + timer_context = update_timelimit_module(request.user, course_id, student_module_cache, + section_descriptor, section_module) + if 'timer_expiration_datetime' in timer_context: + context.update(timer_context) + else: + # if there is no expiration defined, then we know the timer has expired: + return HttpResponseRedirect(timer_context['time_expired_redirect_url']) + else: + # check here if this page is within a course that has an active timed module running. If so, then + # add in the appropriate timer information to the rendering context: + context.update(check_for_active_timelimit_module(request, course_id, course)) + context['content'] = section_module.get_html() else: # section is none, so display a message @@ -334,201 +385,6 @@ def index(request, course_id, chapter=None, section=None, return result -@login_required -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -def timed_exam(request, course_id, chapter, section): - """ - Displays only associated content. If course, chapter, - and section are all specified, renders the page, or returns an error if they - are invalid. - - Returns an error if these are not all specified and correct. - - Arguments: - - - request : HTTP request - - course_id : course id (str: ORG/course/URL_NAME) - - chapter : chapter url_name (str) - - section : section url_name (str) - - Returns: - - - HTTPresponse - """ - course = get_course_with_access(request.user, course_id, 'load', depth=2) - staff_access = has_access(request.user, course, 'staff') - registered = registered_for_course(course, request.user) - if not registered: - log.debug('User %s tried to view course %s but is not enrolled' % (request.user,course.location.url())) - raise # error - try: - student_module_cache = StudentModuleCache.cache_for_descriptor_descendents( - course.id, request.user, course, depth=2) - - # Has this student been in this course before? - # first_time = student_module_cache.lookup(course_id, 'course', course.location.url()) is None - - # Load the module for the course - course_module = get_module_for_descriptor(request.user, request, course, student_module_cache, course.id) - if course_module is None: - log.warning('If you see this, something went wrong: if we got this' - ' far, should have gotten a course module for this user') - # return redirect(reverse('about_course', args=[course.id])) - raise # error - - if chapter is None: - # return redirect_to_course_position(course_module, first_time) - raise # error - - # BW: add this test earlier, and remove later clause - if section is None: - # return redirect_to_course_position(course_module, first_time) - raise # error - - context = { - 'csrf': csrf(request)['csrf_token'], - 'COURSE_TITLE': course.title, - 'course': course, - 'init': '', - 'content': '', - 'staff_access': staff_access, - 'xqa_server': settings.MITX_FEATURES.get('USE_XQA_SERVER','http://xqa:server@content-qa.mitx.mit.edu/xqa') - } - - # in general, we may want to disable accordion display on timed exams. - provide_accordion = True - if provide_accordion: - context['accordion'] = render_accordion(request, course, chapter, section) - - chapter_descriptor = course.get_child_by(lambda m: m.url_name == chapter) - if chapter_descriptor is not None: - instance_module = get_instance_module(course_id, request.user, course_module, student_module_cache) - save_child_position(course_module, chapter, instance_module) - else: - raise Http404 - - chapter_module = course_module.get_child_by(lambda m: m.url_name == chapter) - if chapter_module is None: - # User may be trying to access a chapter that isn't live yet - raise Http404 - - section_descriptor = chapter_descriptor.get_child_by(lambda m: m.url_name == section) - if section_descriptor is None: - # Specifically asked-for section doesn't exist - raise Http404 - - # Load all descendents of the section, because we're going to display its - # html, which in general will need all of its children - section_module = get_module(request.user, request, section_descriptor.location, - student_module_cache, course.id, position=None, depth=None) - if section_module is None: - # User may be trying to be clever and access something - # they don't have access to. - raise Http404 - - # Save where we are in the chapter: - instance_module = get_instance_module(course_id, request.user, chapter_module, student_module_cache) - save_child_position(chapter_module, section, instance_module) - - - context['content'] = section_module.get_html() - - # determine where to go when the exam ends: - if 'time_expired_redirect_url' not in section_descriptor.metadata: - raise Http404 - time_expired_redirect_url = section_descriptor.metadata.get('time_expired_redirect_url') - context['time_expired_redirect_url'] = time_expired_redirect_url - - # figure out when the timed exam should end. Going forward, this is determined by getting a "normal" - # duration from the test, then doing some math to modify the duration based on accommodations, - # and then use that value as the end. Once we have calculated this, it should be sticky -- we - # use the same value for future requests, unless it's a tester. - - # get value for duration from the section's metadata: - # for now, assume that the duration is set as an integer value, indicating the number of seconds: - if 'duration' not in section_descriptor.metadata: - raise Http404 - duration = int(section_descriptor.metadata.get('duration')) - - # get corresponding time module, if one is present: - try: - timed_module = TimedModule.objects.get(student=request.user, course_id=course_id, location=section_module.location) - - # if a module exists, check to see if it has already been started, - # and if it has already ended. - if timed_module.has_ended: - # the exam has already ended, and the student has tried to - # revisit the exam. - # TODO: determine what do we do here. - # For a Pearson exam, we want to go to the exit page. - # (Not so sure what to do in general.) - # Proposal: store URL in the section descriptor, - # along with the duration. If no such URL is set, - # just put up the error page, - if time_expired_redirect_url is None: - raise Exception("Time expired on {}".format(timed_module)) - else: - return HttpResponseRedirect(time_expired_redirect_url) - - elif not timed_module.has_begun: - # user has not started the exam, but may have an accommodation - # that has been granted to them. - # modified_duration = timed_module.get_accommodated_duration(duration) - # timed_module.started_at = datetime.utcnow() # time() * 1000 - # timed_module.end_date = timed_module. - timed_module.begin(duration) - timed_module.save() - - except TimedModule.DoesNotExist: - # no entry found. So we're starting this test - # without any accommodations being preset. - timed_module = TimedModule(student=request.user, course_id=course_id, location=section_module.location) - timed_module.begin(duration) - timed_module.save() - - - # the exam has already been started, and the student is returning to the - # exam page. Fetch the end time (in GMT) as stored - # in the module when it was started. - end_date = timed_module.get_end_time_in_ms() - - # This value should be UTC time as number of milliseconds since epoch. - # context['end_date'] = end_date - context['timer_expiration_datetime'] = end_date - if 'suppress_toplevel_navigation' in section_descriptor.metadata: - context['suppress_toplevel_navigation'] = section_descriptor.metadata['suppress_toplevel_navigation'] - - result = render_to_response('courseware/courseware.html', context) - except Exception as e: - if isinstance(e, Http404): - # let it propagate - raise - - # In production, don't want to let a 500 out for any reason - if settings.DEBUG: - raise - else: - log.exception("Error in exam view: user={user}, course={course}," - " chapter={chapter} section={section}" - "position={position}".format( - user=request.user, - course=course, - chapter=chapter, - section=section - )) - try: - result = render_to_response('courseware/courseware-error.html', - {'staff_access': staff_access, - 'course' : course}) - except: - # Let the exception propagate, relying on global config to at - # at least return a nice error message - log.exception("Error while rendering courseware-error page") - raise - - return result - @ensure_csrf_cookie def jump_to(request, course_id, location): ''' diff --git a/lms/urls.py b/lms/urls.py index f6819d05a2da8b3216577998f607e5873327ed85..f92b63aac235ae88d205b60f40994891226f447a 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -217,16 +217,6 @@ if settings.COURSEWARE_ENABLED: url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/about$', 'courseware.views.course_about', name="about_course"), - # timed exam: - url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/timed_exam/(?P<chapter>[^/]*)/(?P<section>[^/]*)/$', - 'courseware.views.timed_exam', name="timed_exam"), - # (handle hard-coded 6.002x exam explicitly as a timed exam, but without changing the URL. - # not only because Pearson doesn't want us to change its location, but because we also include it - # in the navigation accordion we display with this exam (so students can see what work they have already - # done). Those are generated automatically using reverse(courseware_section). - url(r'^courses/(?P<course_id>MITx/6.002x/2012_Fall)/courseware/(?P<chapter>Final_Exam)/(?P<section>Final_Exam_Fall_2012)/$', - 'courseware.views.timed_exam'), - #Inside the course url(r'^courses/(?P<course_id>[^/]+/[^/]+/[^/]+)/$', 'courseware.views.course_info', name="course_root"),