Commit cc11dc2a by Brian Wilson

switch to using timelimit module for Pearson test

parent 33a5a5fd
......@@ -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
......
......@@ -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",
......
......@@ -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
......
......@@ -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)))
......
# -*- 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
......@@ -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])
......@@ -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):
'''
......
......@@ -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"),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment