From 1685f302ab7721be80634ae2f68154d74d452cd2 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Mon, 4 Feb 2013 02:22:24 -0500 Subject: [PATCH] add TimerModule to courseware --- .../migrations/0006_add_timed_module.py | 119 ++++++++++++++++++ lms/djangoapps/courseware/models.py | 88 ++++++++++++- lms/djangoapps/courseware/views.py | 50 +++++++- 3 files changed, 245 insertions(+), 12 deletions(-) create mode 100644 lms/djangoapps/courseware/migrations/0006_add_timed_module.py diff --git a/lms/djangoapps/courseware/migrations/0006_add_timed_module.py b/lms/djangoapps/courseware/migrations/0006_add_timed_module.py new file mode 100644 index 0000000000..89b63cf659 --- /dev/null +++ b/lms/djangoapps/courseware/migrations/0006_add_timed_module.py @@ -0,0 +1,119 @@ +# -*- 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)), + ('module_state_key', self.gf('django.db.models.fields.CharField')(max_length=255, db_column='module_id', 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', 'module_state_key', 'course_id'] + db.create_unique('courseware_timedmodule', ['student_id', 'module_id', 'course_id']) + + + def backwards(self, orm): + # Removing unique constraint on 'TimedModule', fields ['student', 'module_state_key', 'course_id'] + db.delete_unique('courseware_timedmodule', ['student_id', 'module_id', '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', 'module_state_key', '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'}), + 'modified_at': ('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'}), + '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 21ef8b3d66..bd2da02027 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -12,15 +12,12 @@ file and check it in at the same time as your model changes. To do that, ASSUMPTIONS: modules have unique IDs, even across different module_types """ +from datetime import datetime, timedelta +from calendar import timegm + from django.db import models -#from django.core.cache import cache from django.contrib.auth.models import User -#from cache_toolbox import cache_model, cache_relation - -#CACHE_TIMEOUT = 60 * 60 * 4 # Set the cache timeout to be four hours - - class StudentModule(models.Model): """ Keeps student state for a particular module in a particular course. @@ -214,3 +211,82 @@ 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') + 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'),) + + # 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'), + ) + 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) + + # 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 47942f3a63..b44887dbfd 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -2,7 +2,6 @@ import logging import urllib from functools import partial -from time import time from django.conf import settings from django.core.context_processors import csrf @@ -21,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 +from courseware.models import StudentModuleCache, TimedModule 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 @@ -402,16 +401,55 @@ def timed_exam(request, course_id, chapter, section): # 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 - - # for now, assume that the duration is set as an integer value, indicating the number of seconds: duration = int(section_descriptor.metadata.get('duration')) + # get corresponding time module, if one is present: + # TODO: determine what to use for module_key... + try: + timed_module = TimedModule.objects.get(student=request.user, course_id=course_id) + + # 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, + raise Exception("Time expired on {}".format(timed_module)) + 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. + # TODO: determine what to use for module_key... + timed_module = TimedModule(student=request.user, course_id=course_id) + 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'] = (time() + duration) * 1000 + context['end_date'] = end_date result = render_to_response('courseware/testcenter_exam.html', context) except Exception as e: