From 9b3d7efb3f60d3b0913c49e01847f9be38892b6b Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Wed, 30 Jan 2013 18:14:53 -0500 Subject: [PATCH 01/29] add first pass at testcenter exam --- lms/djangoapps/courseware/views.py | 135 ++++++++++++++++ lms/templates/courseware/testcenter_exam.html | 152 ++++++++++++++++++ lms/templates/main_nonav.html | 46 ++++++ lms/urls.py | 4 + 4 files changed, 337 insertions(+) create mode 100644 lms/templates/courseware/testcenter_exam.html create mode 100644 lms/templates/main_nonav.html diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 5d65d7c632..c7838156cb 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -2,6 +2,7 @@ import logging import urllib from functools import partial +from time import time from django.conf import settings from django.core.context_processors import csrf @@ -297,6 +298,140 @@ 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 testcenter_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'], + # 'accordion': render_accordion(request, course, chapter, section), + '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') + } + + 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() + + # figure out when the 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. + + # Let's try 600s for now... + context['end_date'] = (time() + 600) * 1000 + + result = render_to_response('courseware/testcenter_exam.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/templates/courseware/testcenter_exam.html b/lms/templates/courseware/testcenter_exam.html new file mode 100644 index 0000000000..66adfefcff --- /dev/null +++ b/lms/templates/courseware/testcenter_exam.html @@ -0,0 +1,152 @@ +<%inherit file="/main_nonav.html" /> +<%namespace name='static' file='/static_content.html'/> +<%block name="bodyclass">courseware ${course.css_class} +<%block name="title">${course.number} Exam + +<%block name="headextra"> + <%static:css group='course'/> + <%include file="../discussion/_js_head_dependencies.html" /> + + +<%block name="js_extra"> + + + + ## codemirror + + + ## alternate codemirror + ## + ## + ## + + + <%static:js group='courseware'/> + <%static:js group='discussion'/> + + <%include file="../discussion/_js_body_dependencies.html" /> + % if staff_access: + <%include file="xqa_interface.html"/> + % endif + + + + + + + + + + + + +  + +
+
+
+ ${content} +
+
+
+ + +% if course.show_calculator: +
+ Calculator + +
+
+
+ + +
+ Hints +
+
Suffixes:
+
%kMGTcmunp
+
Operations:
+
^ * / + - ()
+
Functions:
+
sin, cos, tan, sqrt, log10, log2, ln, arccos, arcsin, arctan, abs
+
Constants
+
e, pi
+ + +
+
+
+ + +
+ +
+
+% endif diff --git a/lms/templates/main_nonav.html b/lms/templates/main_nonav.html new file mode 100644 index 0000000000..f2b87ef348 --- /dev/null +++ b/lms/templates/main_nonav.html @@ -0,0 +1,46 @@ +<%namespace name='static' file='static_content.html'/> + + + + <%block name="title">edX + + + + + <%static:css group='application'/> + + <%static:js group='main_vendor'/> + <%block name="headextra"/> + + + + + + + + % if not course: + <%include file="google_analytics.html" /> + % endif + + + + + + +
+ ${self.body()} + <%block name="bodyextra"/> +
+ + + + <%static:js group='application'/> + <%static:js group='module-js'/> + + <%block name="js_extra"/> + + diff --git a/lms/urls.py b/lms/urls.py index f92b63aac2..2c5db07d00 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -217,6 +217,10 @@ if settings.COURSEWARE_ENABLED: url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/about$', 'courseware.views.course_about', name="about_course"), + # testcenter exam: + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/testcenter_exam/(?P[^/]*)/(?P
[^/]*)/$', + 'courseware.views.testcenter_exam', name="testcenter_exam"), + #Inside the course url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/$', 'courseware.views.course_info', name="course_root"), From 07638440ac93d63893ea2d0b3a67f799bfeb1596 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Thu, 31 Jan 2013 18:33:22 -0500 Subject: [PATCH 02/29] rename testcenter_exam to timed_exam, and read duration from metadata (policy.json) --- lms/djangoapps/courseware/views.py | 17 ++++++++++++----- lms/templates/courseware/testcenter_exam.html | 3 ++- lms/urls.py | 6 +++--- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index c7838156cb..1ac7cebd4b 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -301,7 +301,7 @@ def index(request, course_id, chapter=None, section=None, @login_required @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) -def testcenter_exam(request, course_id, chapter, section): +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 @@ -387,20 +387,27 @@ def testcenter_exam(request, course_id, chapter, section): # they don't have access to. raise Http404 - # Save where we are in the chapter + # Save where we are in the chapter NOT! # 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() - # figure out when the exam should end. Going forward, this is determined by getting a "normal" + # 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: + 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')) - # Let's try 600s for now... - context['end_date'] = (time() + 600) * 1000 + # This value should be UTC time as number of milliseconds since epoch. + context['end_date'] = (time() + duration) * 1000 result = render_to_response('courseware/testcenter_exam.html', context) except Exception as e: diff --git a/lms/templates/courseware/testcenter_exam.html b/lms/templates/courseware/testcenter_exam.html index 66adfefcff..638778f7cd 100644 --- a/lms/templates/courseware/testcenter_exam.html +++ b/lms/templates/courseware/testcenter_exam.html @@ -67,7 +67,8 @@ return ( num < 10 ? "0" : "" ) + num; } - // set the end time when the template is rendered + // set the end time when the template is rendered. + // This value should be UTC time as number of milliseconds since epoch. var endTime = new Date(${end_date}); var currentTime = new Date(); var remaining_secs = Math.floor((endTime - currentTime)/1000); diff --git a/lms/urls.py b/lms/urls.py index 2c5db07d00..021079333a 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -217,9 +217,9 @@ if settings.COURSEWARE_ENABLED: url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/about$', 'courseware.views.course_about', name="about_course"), - # testcenter exam: - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/testcenter_exam/(?P[^/]*)/(?P
[^/]*)/$', - 'courseware.views.testcenter_exam', name="testcenter_exam"), + # timed exam: + url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/timed_exam/(?P[^/]*)/(?P
[^/]*)/$', + 'courseware.views.timed_exam', name="timed_exam"), #Inside the course url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/$', From 9d98b7055de26a4f70f0e987c8f682786564441d Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Fri, 1 Feb 2013 17:52:14 -0500 Subject: [PATCH 03/29] add course navigation back into timed exam. Add initial timer styling. --- lms/djangoapps/courseware/views.py | 12 +++++--- lms/static/sass/course.scss | 2 +- lms/static/sass/course/layout/_timer.scss | 11 +++++++ lms/templates/courseware/testcenter_exam.html | 29 +++++++++++++++++-- lms/urls.py | 6 ++++ 5 files changed, 52 insertions(+), 8 deletions(-) create mode 100644 lms/static/sass/course/layout/_timer.scss diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 1ac7cebd4b..47942f3a63 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -352,7 +352,6 @@ def timed_exam(request, course_id, chapter, section): context = { 'csrf': csrf(request)['csrf_token'], - # 'accordion': render_accordion(request, course, chapter, section), 'COURSE_TITLE': course.title, 'course': course, 'init': '', @@ -361,6 +360,11 @@ def timed_exam(request, course_id, chapter, section): '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) @@ -387,9 +391,9 @@ def timed_exam(request, course_id, chapter, section): # they don't have access to. raise Http404 - # Save where we are in the chapter NOT! -# instance_module = get_instance_module(course_id, request.user, chapter_module, student_module_cache) -# save_child_position(chapter_module, section, instance_module) + # 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() diff --git a/lms/static/sass/course.scss b/lms/static/sass/course.scss index e900e589b2..7c2522a194 100644 --- a/lms/static/sass/course.scss +++ b/lms/static/sass/course.scss @@ -22,7 +22,7 @@ @import 'course/courseware/sidebar'; @import 'course/courseware/amplifier'; @import 'course/layout/calculator'; - +@import 'course/layout/timer'; // course-specific courseware (all styles in these files should be gated by a // course-specific class). This should be replaced with a better way of diff --git a/lms/static/sass/course/layout/_timer.scss b/lms/static/sass/course/layout/_timer.scss new file mode 100644 index 0000000000..01d62d87c7 --- /dev/null +++ b/lms/static/sass/course/layout/_timer.scss @@ -0,0 +1,11 @@ +div.timer-main { + position: fixed; + z-index: 99; + width: 100%; + + div#timer_wrapper { + position: relative; + float: right; + margin-right: 10px; + } +} diff --git a/lms/templates/courseware/testcenter_exam.html b/lms/templates/courseware/testcenter_exam.html index 638778f7cd..d2f74ab296 100644 --- a/lms/templates/courseware/testcenter_exam.html +++ b/lms/templates/courseware/testcenter_exam.html @@ -78,6 +78,7 @@ // TBD... if (remaining_secs <= 0) { return "00:00:00"; + // do we just set window.location = value? } // count down in terms of hours, minutes, and seconds: @@ -104,19 +105,41 @@ - +
+
+
Time Remaining:
 
+
+
+ +% if accordion: + <%include file="/courseware/course_navigation.html" args="active_page='courseware'" /> +% endif - 
+
+ +% if accordion: +
+
+ close +
+ + +
+% endif +
${content}
- % if course.show_calculator:
Calculator diff --git a/lms/urls.py b/lms/urls.py index 021079333a..f6819d05a2 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -220,6 +220,12 @@ if settings.COURSEWARE_ENABLED: # timed exam: url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/timed_exam/(?P[^/]*)/(?P
[^/]*)/$', '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/(?PMITx/6.002x/2012_Fall)/courseware/(?PFinal_Exam)/(?P
Final_Exam_Fall_2012)/$', + 'courseware.views.timed_exam'), #Inside the course url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/$', From 1685f302ab7721be80634ae2f68154d74d452cd2 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Mon, 4 Feb 2013 02:22:24 -0500 Subject: [PATCH 04/29] 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: From f8b7d5fad6205fe4eead190538b82df8585ceb98 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Mon, 4 Feb 2013 15:13:14 -0500 Subject: [PATCH 05/29] have timer redirect when it expires --- lms/djangoapps/courseware/views.py | 16 ++++-- lms/templates/courseware/testcenter_exam.html | 53 ++++++++++--------- 2 files changed, 41 insertions(+), 28 deletions(-) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index b44887dbfd..2484aa5c6b 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -8,7 +8,7 @@ from django.core.context_processors import csrf from django.core.urlresolvers import reverse from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required -from django.http import Http404 +from django.http import Http404, HttpResponseRedirect from django.shortcuts import redirect from mitxmako.shortcuts import render_to_response, render_to_string #from django.views.decorators.csrf import ensure_csrf_cookie @@ -397,6 +397,12 @@ def timed_exam(request, course_id, chapter, section): 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 @@ -407,7 +413,7 @@ def timed_exam(request, course_id, chapter, section): if 'duration' not in section_descriptor.metadata: raise Http404 duration = int(section_descriptor.metadata.get('duration')) - + # get corresponding time module, if one is present: # TODO: determine what to use for module_key... try: @@ -424,7 +430,11 @@ def timed_exam(request, course_id, chapter, section): # 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)) + 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. diff --git a/lms/templates/courseware/testcenter_exam.html b/lms/templates/courseware/testcenter_exam.html index d2f74ab296..8082200146 100644 --- a/lms/templates/courseware/testcenter_exam.html +++ b/lms/templates/courseware/testcenter_exam.html @@ -61,26 +61,21 @@ From 1923ae0d6b08aa094ba1e464a4ead26b1b782293 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Mon, 4 Feb 2013 15:40:01 -0500 Subject: [PATCH 06/29] pearson - added in timer styling for IE7 --- lms/static/sass/course/layout/_timer.scss | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lms/static/sass/course/layout/_timer.scss b/lms/static/sass/course/layout/_timer.scss index 01d62d87c7..eef21b8c27 100644 --- a/lms/static/sass/course/layout/_timer.scss +++ b/lms/static/sass/course/layout/_timer.scss @@ -2,10 +2,26 @@ div.timer-main { position: fixed; z-index: 99; width: 100%; + border-top: 2px solid #000; div#timer_wrapper { position: relative; + top: -3px; float: right; margin-right: 10px; + background: #000; + color: #fff; + padding: 10px 20px; + border-radius: 3px; + } + + .timer_label { + color: #ccc; + font-size: 13px; + } + + #exam_timer { + font-weight: bold; + font-size: 15px; } } From 1b465d1beb0fe744199dc8c7d222563e3199a2e3 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Tue, 5 Feb 2013 18:01:55 -0500 Subject: [PATCH 07/29] implement testcenter_login --- common/djangoapps/student/views.py | 130 ++++++++++++++++++++++++++-- lms/djangoapps/courseware/models.py | 4 + lms/djangoapps/courseware/views.py | 6 +- 3 files changed, 131 insertions(+), 9 deletions(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index b583599e97..9312f7b76a 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1,12 +1,10 @@ import datetime import feedparser -#import itertools import json import logging import random import string import sys -#import time import urllib import uuid @@ -18,10 +16,13 @@ from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required from django.core.context_processors import csrf from django.core.mail import send_mail +from django.core.urlresolvers import reverse from django.core.validators import validate_email, validate_slug, ValidationError from django.db import IntegrityError -from django.http import HttpResponse, HttpResponseForbidden, Http404 +from django.http import HttpResponse, HttpResponseForbidden, Http404,\ + HttpResponseRedirect from django.shortcuts import redirect + from mitxmako.shortcuts import render_to_response, render_to_string from bs4 import BeautifulSoup from django.core.cache import cache @@ -39,11 +40,11 @@ from xmodule.course_module import CourseDescriptor from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.django import modulestore -#from datetime import date 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 statsd import statsd @@ -1058,7 +1059,7 @@ def accept_name_change(request): # TODO: This is a giant kludge to give Pearson something to test against ASAP. # Will need to get replaced by something that actually ties into TestCenterUser @csrf_exempt -def test_center_login(request): +def atest_center_login(request): if not settings.MITX_FEATURES.get('ENABLE_PEARSON_HACK_TEST'): raise Http404 @@ -1076,6 +1077,125 @@ def test_center_login(request): return HttpResponseForbidden() +@csrf_exempt +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); + + # 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. + error_url = request.POST.get("errorURL") + + # 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")); + code = request.POST.get("code") + + # calculate SHA for query string + # TODO: figure out how to get the original query string, so we can hash it and compare. + + + if 'clientCandidateID' not in request.POST: + return HttpResponseRedirect(makeErrorURL(error_url, "missingClientCandidateID")); + client_candidate_id = request.POST.get("clientCandidateID") + + # TODO: check remaining parameters, and maybe at least log if they're not matching + # expected values.... + # registration_id = request.POST.get("registrationID") + # exit_url = request.POST.get("exitURL") + + + # find testcenter_user that matches the provided ID: + try: + testcenteruser = TestCenterUser.objects.get(client_candidate_id=client_candidate_id) + except TestCenterUser.DoesNotExist: + 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. + if 'vueExamSeriesCode' not in request.POST: + # TODO: confirm this error code (made up, not in documentation) + 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: + 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': + chapter_url_name = 'Final_Exam' + section_url_name = 'Final_Exam_Fall_2012' + redirect_url = reverse('courseware_section', args=[course_id, chapter_url_name, section_url_name]) + location = 'i4x://MITx/6.002x/2012_Fall/sequence/Final_Exam_Fall_2012' + else: + # TODO: clarify if this is the right error code for this condition. + return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")); + + + 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, module_state_key=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 + 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, module_state_key=location) + timed_module.accommodation_code = time_accommodation_code + timed_module.save() + + # Now log the user in: +# user = authenticate(username=testcenteruser.user.username, +# password=testcenteruser.user.password) +# +# if user is None: +# # argh. We couldn't login! +# return HttpResponseRedirect(makeErrorURL(error_url, "ARGH! User cannot log in")); + + # UGLY HACK!!! + # Login assumes that authentication has occurred, and that there is a + # backend annotation on the user object, indicating which backend + # against which the user was authenticated. We're authenticating here + # against the registration entry, and assuming that the request given + # this information is correct, we allow the user to be logged in + # without a password. This could all be formalized in a backend object + # that does the above checking. + # TODO: create a backend class to do this. + # testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__) + testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass") + login(request, testcenteruser.user) + + # And start the test: + return redirect(redirect_url) + + def _get_news(top=None): "Return the n top news items on settings.RSS_URL" diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index bd2da02027..00079d30f2 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -239,6 +239,7 @@ class TimedModule(models.Model): ('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) @@ -256,6 +257,9 @@ class TimedModule(models.Model): 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: diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 2484aa5c6b..5afda7b181 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -415,9 +415,8 @@ def timed_exam(request, course_id, chapter, section): 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) + timed_module = TimedModule.objects.get(student=request.user, course_id=course_id, module_state_key=section_module.id) # if a module exists, check to see if it has already been started, # and if it has already ended. @@ -447,8 +446,7 @@ def timed_exam(request, course_id, chapter, section): 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 = TimedModule(student=request.user, course_id=course_id, module_state_key=section_module.id) timed_module.begin(duration) timed_module.save() From bfc452759013a443915396c3df1f34e5e9e423f1 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Wed, 6 Feb 2013 15:23:11 -0500 Subject: [PATCH 08/29] Switch timed_module to store location, and use to navigate from timer when timer displays on non-exam course pages. --- common/djangoapps/student/views.py | 21 ++---- .../migrations/0006_add_timed_module.py | 14 ++-- lms/djangoapps/courseware/models.py | 6 +- lms/djangoapps/courseware/views.py | 50 ++++++++++++-- lms/templates/courseware/courseware.html | 66 ++++++++++++++++++- lms/templates/main.html | 5 ++ 6 files changed, 133 insertions(+), 29 deletions(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 9312f7b76a..61313376d1 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1138,10 +1138,11 @@ def test_center_login(request): # 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': - chapter_url_name = 'Final_Exam' - section_url_name = 'Final_Exam_Fall_2012' - redirect_url = reverse('courseware_section', args=[course_id, chapter_url_name, section_url_name]) - location = 'i4x://MITx/6.002x/2012_Fall/sequence/Final_Exam_Fall_2012' + # 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. return HttpResponseRedirect(makeErrorURL(error_url, "incorrectCandidateTests")); @@ -1152,7 +1153,7 @@ def test_center_login(request): 'ETDBTM' : 'ADDDOUBLE', } # check if the test has already been taken - timed_modules = TimedModule.objects.filter(student=testcenteruser.user, course_id=course_id, module_state_key=location) + 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: @@ -1167,17 +1168,9 @@ def test_center_login(request): if client_candidate_id == "edX003671291147": time_accommodation_code = 'TESTING' if time_accommodation_code: - timed_module = TimedModule(student=request.user, course_id=course_id, module_state_key=location) + timed_module = TimedModule(student=request.user, course_id=course_id, location=location) timed_module.accommodation_code = time_accommodation_code timed_module.save() - - # Now log the user in: -# user = authenticate(username=testcenteruser.user.username, -# password=testcenteruser.user.password) -# -# if user is None: -# # argh. We couldn't login! -# return HttpResponseRedirect(makeErrorURL(error_url, "ARGH! User cannot log in")); # UGLY HACK!!! # Login assumes that authentication has occurred, and that there is a diff --git a/lms/djangoapps/courseware/migrations/0006_add_timed_module.py b/lms/djangoapps/courseware/migrations/0006_add_timed_module.py index 89b63cf659..6e8791a975 100644 --- a/lms/djangoapps/courseware/migrations/0006_add_timed_module.py +++ b/lms/djangoapps/courseware/migrations/0006_add_timed_module.py @@ -11,7 +11,7 @@ class Migration(SchemaMigration): # 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)), + ('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)), @@ -22,13 +22,13 @@ class Migration(SchemaMigration): )) 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']) + # 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', 'module_state_key', 'course_id'] - db.delete_unique('courseware_timedmodule', ['student_id', 'module_id', 'course_id']) + # 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') @@ -103,15 +103,15 @@ class Migration(SchemaMigration): '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'}, + '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'}), - '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']"}) } } diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 00079d30f2..d9cc560215 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -224,12 +224,14 @@ class TimedModule(models.Model): # 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') + # 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', '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. diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index 5afda7b181..0acf435f0b 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -215,6 +215,43 @@ 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) @@ -416,7 +453,7 @@ def timed_exam(request, course_id, chapter, section): # get corresponding time module, if one is present: try: - timed_module = TimedModule.objects.get(student=request.user, course_id=course_id, module_state_key=section_module.id) + 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. @@ -446,7 +483,7 @@ def timed_exam(request, course_id, chapter, section): 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, module_state_key=section_module.id) + timed_module = TimedModule(student=request.user, course_id=course_id, location=section_module.location) timed_module.begin(duration) timed_module.save() @@ -457,9 +494,12 @@ def timed_exam(request, course_id, chapter, section): 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 - - result = render_to_response('courseware/testcenter_exam.html', context) + # 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 diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index 1ea3df1b5a..da429c5275 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -59,12 +59,75 @@ }); }); + +% if timer_expiration_datetime: + +% endif + -<%include file="/courseware/course_navigation.html" args="active_page='courseware'" /> +% if timer_expiration_datetime: +
+
+
Time Remaining:
 
+ % if timer_navigation_return_url: + Return... + % endif +
+
+% endif + +% if accordion: + <%include file="/courseware/course_navigation.html" args="active_page='courseware'" /> +% endif
+ +% if accordion:
close @@ -76,6 +139,7 @@
+% endif
${content} diff --git a/lms/templates/main.html b/lms/templates/main.html index 5d3fd29104..42d5a71228 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -29,13 +29,18 @@ +% if not suppress_toplevel_navigation: <%include file="navigation.html" /> +% endif +
${self.body()} <%block name="bodyextra"/>
+% if not suppress_toplevel_navigation: <%include file="footer.html" /> +% endif <%static:js group='application'/> <%static:js group='module-js'/> From aae5c7ca23378e47584e91c65f078892e733cac3 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Wed, 6 Feb 2013 15:25:25 -0500 Subject: [PATCH 09/29] remove unneeded files --- lms/templates/courseware/testcenter_exam.html | 179 ------------------ lms/templates/main_nonav.html | 46 ----- 2 files changed, 225 deletions(-) delete mode 100644 lms/templates/courseware/testcenter_exam.html delete mode 100644 lms/templates/main_nonav.html diff --git a/lms/templates/courseware/testcenter_exam.html b/lms/templates/courseware/testcenter_exam.html deleted file mode 100644 index 8082200146..0000000000 --- a/lms/templates/courseware/testcenter_exam.html +++ /dev/null @@ -1,179 +0,0 @@ -<%inherit file="/main_nonav.html" /> -<%namespace name='static' file='/static_content.html'/> -<%block name="bodyclass">courseware ${course.css_class} -<%block name="title">${course.number} Exam - -<%block name="headextra"> - <%static:css group='course'/> - <%include file="../discussion/_js_head_dependencies.html" /> - - -<%block name="js_extra"> - - - - ## codemirror - - - ## alternate codemirror - ## - ## - ## - - - <%static:js group='courseware'/> - <%static:js group='discussion'/> - - <%include file="../discussion/_js_body_dependencies.html" /> - % if staff_access: - <%include file="xqa_interface.html"/> - % endif - - - - - - - - - - -
-
-
Time Remaining:
 
-
-
- -% if accordion: - <%include file="/courseware/course_navigation.html" args="active_page='courseware'" /> -% endif - - -
- -
- -% if accordion: -
-
- close -
- - -
-% endif - -
- ${content} -
-
-
- -% if course.show_calculator: -
- Calculator - -
-
-
- - -
- Hints -
-
Suffixes:
-
%kMGTcmunp
-
Operations:
-
^ * / + - ()
-
Functions:
-
sin, cos, tan, sqrt, log10, log2, ln, arccos, arcsin, arctan, abs
-
Constants
-
e, pi
- - -
-
-
- - -
- -
-
-% endif diff --git a/lms/templates/main_nonav.html b/lms/templates/main_nonav.html deleted file mode 100644 index f2b87ef348..0000000000 --- a/lms/templates/main_nonav.html +++ /dev/null @@ -1,46 +0,0 @@ -<%namespace name='static' file='static_content.html'/> - - - - <%block name="title">edX - - - - - <%static:css group='application'/> - - <%static:js group='main_vendor'/> - <%block name="headextra"/> - - - - - - - - % if not course: - <%include file="google_analytics.html" /> - % endif - - - - - - -
- ${self.body()} - <%block name="bodyextra"/> -
- - - - <%static:js group='application'/> - <%static:js group='module-js'/> - - <%block name="js_extra"/> - - From 33a5a5fd9fa3a84773316ced8ea8fc1a3d522f01 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Thu, 7 Feb 2013 05:17:56 -0500 Subject: [PATCH 10/29] add implementation for fixed_time_module --- common/lib/xmodule/setup.py | 1 + .../lib/xmodule/xmodule/fixed_time_module.py | 180 ++++++++++++++++++ common/lib/xmodule/xmodule/modulestore/xml.py | 2 +- 3 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 common/lib/xmodule/xmodule/fixed_time_module.py diff --git a/common/lib/xmodule/setup.py b/common/lib/xmodule/setup.py index 29227c3188..f61e6f6f36 100644 --- a/common/lib/xmodule/setup.py +++ b/common/lib/xmodule/setup.py @@ -23,6 +23,7 @@ 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", diff --git a/common/lib/xmodule/xmodule/fixed_time_module.py b/common/lib/xmodule/xmodule/fixed_time_module.py new file mode 100644 index 0000000000..f1fec26dc3 --- /dev/null +++ b/common/lib/xmodule/xmodule/fixed_time_module.py @@ -0,0 +1,180 @@ +import json +import logging + +from lxml import etree +from time import time + +from xmodule.mako_module import MakoModuleDescriptor +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): + ''' + Wrapper module which imposes a time constraint for the completion of its child. + ''' + + def __init__(self, system, location, definition, descriptor, instance_state=None, + shared_state=None, **kwargs): + 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.beginning_at = None + self.ending_at = None + self.accommodation_code = None + + if instance_state is not None: + state = json.loads(instance_state) + + if 'beginning_at' in state: + self.beginning_at = state['beginning_at'] + if 'ending_at' in state: + self.ending_at = state['ending_at'] + 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 + # 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') + ) + + 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 is None or 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: + + @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 < time() + + def begin(self, duration): + ''' + Sets the starting time and ending time for the activity, + based on the duration provided (in seconds). + ''' + 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): + return int(self.ending_at * 1000) + + def get_instance_state(self): + state = {} + if self.beginning_at: + state['beginning_at'] = self.beginning_at + if self.ending_at: + state['ending_at'] = self.ending_at + if self.accommodation_code: + state['accommodation_code'] = self.accommodation_code + return json.dumps(state) + + def get_html(self): + self.render() + return self.content + + def get_progress(self): + ''' Return the total progress, adding total done and total available. + (assumes that each submodule uses the same "units" for progress.) + ''' + # TODO: Cache progress or children array? + children = self.get_children() + progresses = [child.get_progress() for child in children] + 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}) + 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() + self.rendered = True + + def get_icon_class(self): + return self.get_children()[0].get_icon_class() + + +class FixedTimeDescriptor(MakoModuleDescriptor, XmlDescriptor): + # TODO: fix this template?! + mako_template = 'widgets/sequence-edit.html' + module_class = FixedTimeModule + + stores_state = True # For remembering when a student started, and when they should end + + @classmethod + def definition_from_xml(cls, xml_object, system): + children = [] + for child in xml_object: + 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...") + 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') + for child in self.get_children(): + xml_object.append( + etree.fromstring(child.export_to_xml(resource_fs))) + return xml_object + diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 17d6f04932..332b1b1898 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -73,7 +73,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): # VS[compat]. Take this out once course conversion is done (perhaps leave the uniqueness check) # tags that really need unique names--they store (or should store) state. - need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter', 'videosequence') + need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter', 'videosequence', 'fixedtime') attr = xml_data.attrib tag = xml_data.tag From 6d4cb558d3c99622d3c59b48f21f2f78a10cca1f Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Thu, 7 Feb 2013 12:35:52 -0500 Subject: [PATCH 11/29] lms - pearson: styled return to exam link on timer and revised timer positioning to not interfere with main course nav --- .../test_files/js/test_problem_display.js | 2 +- .../test_files/js/test_problem_generator.js | 2 +- .../test_files/js/test_problem_grader.js | 2 +- .../capa/capa/tests/test_files/js/xproblem.js | 2 +- lms/static/sass/course/layout/_timer.scss | 21 ++++++++++++++----- lms/templates/courseware/courseware.html | 4 ++-- 6 files changed, 22 insertions(+), 11 deletions(-) diff --git a/common/lib/capa/capa/tests/test_files/js/test_problem_display.js b/common/lib/capa/capa/tests/test_files/js/test_problem_display.js index 35b619c6ec..b61569acea 100644 --- a/common/lib/capa/capa/tests/test_files/js/test_problem_display.js +++ b/common/lib/capa/capa/tests/test_files/js/test_problem_display.js @@ -1,4 +1,4 @@ -// Generated by CoffeeScript 1.3.3 +// Generated by CoffeeScript 1.4.0 (function() { var MinimaxProblemDisplay, root, __hasProp = {}.hasOwnProperty, diff --git a/common/lib/capa/capa/tests/test_files/js/test_problem_generator.js b/common/lib/capa/capa/tests/test_files/js/test_problem_generator.js index b2f01ed252..4b1d133723 100644 --- a/common/lib/capa/capa/tests/test_files/js/test_problem_generator.js +++ b/common/lib/capa/capa/tests/test_files/js/test_problem_generator.js @@ -1,4 +1,4 @@ -// Generated by CoffeeScript 1.3.3 +// Generated by CoffeeScript 1.4.0 (function() { var TestProblemGenerator, root, __hasProp = {}.hasOwnProperty, diff --git a/common/lib/capa/capa/tests/test_files/js/test_problem_grader.js b/common/lib/capa/capa/tests/test_files/js/test_problem_grader.js index 34dfff35cc..80d7ad1690 100644 --- a/common/lib/capa/capa/tests/test_files/js/test_problem_grader.js +++ b/common/lib/capa/capa/tests/test_files/js/test_problem_grader.js @@ -1,4 +1,4 @@ -// Generated by CoffeeScript 1.3.3 +// Generated by CoffeeScript 1.4.0 (function() { var TestProblemGrader, root, __hasProp = {}.hasOwnProperty, diff --git a/common/lib/capa/capa/tests/test_files/js/xproblem.js b/common/lib/capa/capa/tests/test_files/js/xproblem.js index 512cf22739..55a469f7c1 100644 --- a/common/lib/capa/capa/tests/test_files/js/xproblem.js +++ b/common/lib/capa/capa/tests/test_files/js/xproblem.js @@ -1,4 +1,4 @@ -// Generated by CoffeeScript 1.3.3 +// Generated by CoffeeScript 1.4.0 (function() { var XProblemDisplay, XProblemGenerator, XProblemGrader, root; diff --git a/lms/static/sass/course/layout/_timer.scss b/lms/static/sass/course/layout/_timer.scss index eef21b8c27..6bfa1e6c8c 100644 --- a/lms/static/sass/course/layout/_timer.scss +++ b/lms/static/sass/course/layout/_timer.scss @@ -1,27 +1,38 @@ div.timer-main { position: fixed; z-index: 99; + top: 0; + right: 0; width: 100%; border-top: 2px solid #000; div#timer_wrapper { - position: relative; + position: absolute; top: -3px; - float: right; - margin-right: 10px; + right: 10px; background: #000; color: #fff; padding: 10px 20px; border-radius: 3px; } - .timer_label { - color: #ccc; + .timer_return_url { + display: block; + margin-bottom: 5px; + border-bottom: 1px solid tint(#000, 20%); + padding-bottom: 5px; font-size: 13px; } + .timer_label { + color: #b0b0b0; + font-size: 13px; + margin-bottom: 3px; + } + #exam_timer { font-weight: bold; font-size: 15px; + letter-spacing: 1px; } } diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index da429c5275..72a4b2cae1 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -112,10 +112,10 @@ % if timer_expiration_datetime:
-
Time Remaining:
 
% if timer_navigation_return_url: - Return... + Return to Exam % endif +
Time Remaining:
 
% endif From cc11dc2aa33fefb056c42632882705e300199d1e Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Thu, 7 Feb 2013 15:57:43 -0500 Subject: [PATCH 12/29] switch to using timelimit module for Pearson test --- common/djangoapps/student/views.py | 96 ++++-- common/lib/xmodule/setup.py | 2 +- common/lib/xmodule/xmodule/course_module.py | 6 +- ...xed_time_module.py => timelimit_module.py} | 56 +-- .../migrations/0006_add_timed_module.py | 119 ------- lms/djangoapps/courseware/models.py | 84 ----- lms/djangoapps/courseware/views.py | 324 +++++------------- lms/urls.py | 10 - 8 files changed, 173 insertions(+), 524 deletions(-) rename common/lib/xmodule/xmodule/{fixed_time_module.py => timelimit_module.py} (77%) delete mode 100644 lms/djangoapps/courseware/migrations/0006_add_timed_module.py diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 61313376d1..235f4b414f 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 = 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 f61e6f6f36..d3a0562b41 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 6e3e2cfa39..4c5c3a0a90 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 f1fec26dc3..23ed06eb59 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 6e8791a975..0000000000 --- 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 d9cc560215..87b9edaac2 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 0acf435f0b..07b177979a 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 f6819d05a2..f92b63aac2 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -217,16 +217,6 @@ if settings.COURSEWARE_ENABLED: url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/about$', 'courseware.views.course_about', name="about_course"), - # timed exam: - url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/timed_exam/(?P[^/]*)/(?P
[^/]*)/$', - '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/(?PMITx/6.002x/2012_Fall)/courseware/(?PFinal_Exam)/(?P
Final_Exam_Fall_2012)/$', - 'courseware.views.timed_exam'), - #Inside the course url(r'^courses/(?P[^/]+/[^/]+/[^/]+)/$', 'courseware.views.course_info', name="course_root"), From 80e4944314ae8de27adf0d4ffd2e3fe1131f72e8 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Thu, 7 Feb 2013 16:12:02 -0500 Subject: [PATCH 13/29] minor cleanup --- common/djangoapps/student/views.py | 28 ++----------------- common/lib/xmodule/xmodule/modulestore/xml.py | 2 +- lms/djangoapps/courseware/models.py | 4 --- 3 files changed, 4 insertions(+), 30 deletions(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 235f4b414f..61cb1299b4 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -14,20 +14,19 @@ from django.contrib.auth import logout, authenticate, login from django.contrib.auth.forms import PasswordResetForm from django.contrib.auth.models import User from django.contrib.auth.decorators import login_required +from django.core.cache import cache from django.core.context_processors import csrf from django.core.mail import send_mail from django.core.urlresolvers import reverse from django.core.validators import validate_email, validate_slug, ValidationError from django.db import IntegrityError -from django.http import HttpResponse, HttpResponseForbidden, Http404,\ - HttpResponseRedirect +from django.http import HttpResponse, HttpResponseRedirect, Http404 from django.shortcuts import redirect +from django_future.csrf import ensure_csrf_cookie, csrf_exempt from mitxmako.shortcuts import render_to_response, render_to_string from bs4 import BeautifulSoup -from django.core.cache import cache -from django_future.csrf import ensure_csrf_cookie, csrf_exempt from student.models import (Registration, UserProfile, TestCenterUser, TestCenterUserForm, TestCenterRegistration, TestCenterRegistrationForm, PendingNameChange, PendingEmailChange, @@ -1059,27 +1058,6 @@ def accept_name_change(request): return accept_name_change_by_id(int(request.POST['id'])) -# TODO: This is a giant kludge to give Pearson something to test against ASAP. -# Will need to get replaced by something that actually ties into TestCenterUser -@csrf_exempt -def atest_center_login(request): - if not settings.MITX_FEATURES.get('ENABLE_PEARSON_HACK_TEST'): - raise Http404 - - client_candidate_id = request.POST.get("clientCandidateID") - # registration_id = request.POST.get("registrationID") - exit_url = request.POST.get("exitURL") - error_url = request.POST.get("errorURL") - - if client_candidate_id == "edX003671291147": - user = authenticate(username=settings.PEARSON_TEST_USER, - password=settings.PEARSON_TEST_PASSWORD) - login(request, user) - return redirect('/courses/MITx/6.002x/2012_Fall/courseware/Final_Exam/Final_Exam_Fall_2012/') - else: - return HttpResponseForbidden() - - @csrf_exempt def test_center_login(request): # errors are returned by navigating to the error_url, adding a query parameter named "code" diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 332b1b1898..d225eef980 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -73,7 +73,7 @@ class ImportSystem(XMLParsingSystem, MakoDescriptorSystem): # VS[compat]. Take this out once course conversion is done (perhaps leave the uniqueness check) # tags that really need unique names--they store (or should store) state. - need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter', 'videosequence', 'fixedtime') + need_uniq_names = ('problem', 'sequential', 'video', 'course', 'chapter', 'videosequence', 'timelimit') attr = xml_data.attrib tag = xml_data.tag diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 87b9edaac2..78c6e738b0 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -12,9 +12,6 @@ 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.contrib.auth.models import User @@ -211,4 +208,3 @@ class OfflineComputedGradeLog(models.Model): def __unicode__(self): return "[OCGLog] %s: %s" % (self.course_id, self.created) - From 48e582647c723221b81b153d3742b1fb87c578b5 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Thu, 7 Feb 2013 16:24:43 -0500 Subject: [PATCH 14/29] bug 171 --- cms/templates/manage_users.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/templates/manage_users.html b/cms/templates/manage_users.html index 36930f5386..99ac279bfb 100644 --- a/cms/templates/manage_users.html +++ b/cms/templates/manage_users.html @@ -97,7 +97,7 @@ $cancelButton.bind('click', hideNewUserForm); $('.new-user-button').bind('click', showNewUserForm); - $body.bind('keyup', { $cancelButton: $cancelButton }, checkForCancel); + $('body').bind('keyup', { $cancelButton: $cancelButton }, checkForCancel); $('.remove-user').click(function() { $.ajax({ From fa5537ab719f542a6d4da49bc1efad05885ceb28 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Thu, 7 Feb 2013 17:09:57 -0500 Subject: [PATCH 15/29] On first request for handouts, create the db record. (bug 160) --- .../contentstore/module_info_model.py | 148 +++++++++--------- 1 file changed, 72 insertions(+), 76 deletions(-) diff --git a/cms/djangoapps/contentstore/module_info_model.py b/cms/djangoapps/contentstore/module_info_model.py index 796184baa0..7ed4505c94 100644 --- a/cms/djangoapps/contentstore/module_info_model.py +++ b/cms/djangoapps/contentstore/module_info_model.py @@ -1,37 +1,35 @@ -import logging from static_replace import replace_static_urls from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore import Location -from xmodule.modulestore.django import modulestore -from lxml import etree -import re -from django.http import HttpResponseBadRequest, Http404 +from django.http import Http404 def get_module_info(store, location, parent_location=None, rewrite_static_links=False): - try: - if location.revision is None: - module = store.get_item(location) - else: - module = store.get_item(location) - except ItemNotFoundError: - raise Http404 + try: + if location.revision is None: + module = store.get_item(location) + else: + module = store.get_item(location) + except ItemNotFoundError: + # create a new one + template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) + module = store.clone_item(template_location, location) - data = module.definition['data'] - if rewrite_static_links: - data = replace_static_urls( - module.definition['data'], - None, - course_namespace=Location([ - module.location.tag, - module.location.org, - module.location.course, + data = module.definition['data'] + if rewrite_static_links: + data = replace_static_urls( + module.definition['data'], None, - None - ]) - ) + course_namespace=Location([ + module.location.tag, + module.location.org, + module.location.course, + None, + None + ]) + ) - return { + return { 'id': module.location.url(), 'data': data, 'metadata': module.metadata @@ -39,58 +37,56 @@ def get_module_info(store, location, parent_location=None, rewrite_static_links= def set_module_info(store, location, post_data): - module = None - isNew = False - try: - if location.revision is None: - module = store.get_item(location) - else: - module = store.get_item(location) - except: - pass + module = None + try: + if location.revision is None: + module = store.get_item(location) + else: + module = store.get_item(location) + except: + pass - if module is None: - # new module at this location - # presume that we have an 'Empty' template - template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) - module = store.clone_item(template_location, location) - isNew = True + if module is None: + # new module at this location + # presume that we have an 'Empty' template + template_location = Location(['i4x', 'edx', 'templates', location.category, 'Empty']) + module = store.clone_item(template_location, location) - if post_data.get('data') is not None: - data = post_data['data'] - store.update_item(location, data) + if post_data.get('data') is not None: + data = post_data['data'] + store.update_item(location, data) - # cdodge: note calling request.POST.get('children') will return None if children is an empty array - # so it lead to a bug whereby the last component to be deleted in the UI was not actually - # deleting the children object from the children collection - if 'children' in post_data and post_data['children'] is not None: - children = post_data['children'] - store.update_children(location, children) + # cdodge: note calling request.POST.get('children') will return None if children is an empty array + # so it lead to a bug whereby the last component to be deleted in the UI was not actually + # deleting the children object from the children collection + if 'children' in post_data and post_data['children'] is not None: + children = post_data['children'] + store.update_children(location, children) - # cdodge: also commit any metadata which might have been passed along in the - # POST from the client, if it is there - # NOTE, that the postback is not the complete metadata, as there's system metadata which is - # not presented to the end-user for editing. So let's fetch the original and - # 'apply' the submitted metadata, so we don't end up deleting system metadata - if post_data.get('metadata') is not None: - posted_metadata = post_data['metadata'] - - # update existing metadata with submitted metadata (which can be partial) - # IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it' - for metadata_key in posted_metadata.keys(): - - # let's strip out any metadata fields from the postback which have been identified as system metadata - # and therefore should not be user-editable, so we should accept them back from the client - if metadata_key in module.system_metadata_fields: - del posted_metadata[metadata_key] - elif posted_metadata[metadata_key] is None: - # remove both from passed in collection as well as the collection read in from the modulestore - if metadata_key in module.metadata: - del module.metadata[metadata_key] - del posted_metadata[metadata_key] - - # overlay the new metadata over the modulestore sourced collection to support partial updates - module.metadata.update(posted_metadata) - - # commit to datastore - store.update_metadata(location, module.metadata) + # cdodge: also commit any metadata which might have been passed along in the + # POST from the client, if it is there + # NOTE, that the postback is not the complete metadata, as there's system metadata which is + # not presented to the end-user for editing. So let's fetch the original and + # 'apply' the submitted metadata, so we don't end up deleting system metadata + if post_data.get('metadata') is not None: + posted_metadata = post_data['metadata'] + + # update existing metadata with submitted metadata (which can be partial) + # IMPORTANT NOTE: if the client passed pack 'null' (None) for a piece of metadata that means 'remove it' + for metadata_key in posted_metadata.keys(): + + # let's strip out any metadata fields from the postback which have been identified as system metadata + # and therefore should not be user-editable, so we should accept them back from the client + if metadata_key in module.system_metadata_fields: + del posted_metadata[metadata_key] + elif posted_metadata[metadata_key] is None: + # remove both from passed in collection as well as the collection read in from the modulestore + if metadata_key in module.metadata: + del module.metadata[metadata_key] + del posted_metadata[metadata_key] + + # overlay the new metadata over the modulestore sourced collection to support partial updates + module.metadata.update(posted_metadata) + + # commit to datastore + store.update_metadata(location, module.metadata) From 7b7b0690b68d9c1aaa4350793fcb5133b4c0b814 Mon Sep 17 00:00:00 2001 From: ichuang Date: Thu, 7 Feb 2013 18:18:45 -0500 Subject: [PATCH 16/29] fix test_conditional --- common/lib/xmodule/xmodule/tests/test_conditional.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_conditional.py b/common/lib/xmodule/xmodule/tests/test_conditional.py index 1b463eccaf..361a6ea785 100644 --- a/common/lib/xmodule/xmodule/tests/test_conditional.py +++ b/common/lib/xmodule/xmodule/tests/test_conditional.py @@ -84,18 +84,21 @@ class ConditionalModuleTest(unittest.TestCase): descriptor = self.modulestore.get_instance(course.id, location, depth=None) location = descriptor.location instance_state = instance_states.get(location.category, None) - print "inner_get_module, location.category=%s, inst_state=%s" % (location.category, instance_state) + print "inner_get_module, location=%s, inst_state=%s" % (location, instance_state) return descriptor.xmodule_constructor(test_system)(instance_state, shared_state) location = Location(["i4x", "edX", "cond_test", "conditional", "condone"]) - module = inner_get_module(location) def replace_urls(text, staticfiles_prefix=None, replace_prefix='/static/', course_namespace=None): return text test_system.replace_urls = replace_urls test_system.get_module = inner_get_module + module = inner_get_module(location) print "module: ", module + print "module definition: ", module.definition + print "module children: ", module.get_children() + print "module display items (children): ", module.get_display_items() html = module.get_html() print "html type: ", type(html) From f1198b5f5f050024141eaf15231664b371c04b7e Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Fri, 8 Feb 2013 02:15:45 -0500 Subject: [PATCH 17/29] for starting pearson exam, just call jump_to() instead of reverse(jump_to...) --- common/djangoapps/student/views.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 60aa03857b..c9ca330479 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -45,7 +45,7 @@ from collections import namedtuple from courseware.courses import get_courses, sort_by_announcement from courseware.access import has_access from courseware.models import StudentModuleCache -from courseware.views import get_module_for_descriptor +from courseware.views import get_module_for_descriptor, jump_to from courseware.module_render import get_instance_module from statsd import statsd @@ -1138,8 +1138,6 @@ def test_center_login(request): 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 @@ -1195,7 +1193,7 @@ def test_center_login(request): login(request, testcenteruser.user) # And start the test: - return redirect(redirect_url) + return jump_to(request, course_id, location) def _get_news(top=None): From ab6f383be1f8f796d61ed32b928380867b5ee8b6 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Fri, 8 Feb 2013 03:05:28 -0500 Subject: [PATCH 18/29] support for Pearson test user --- common/djangoapps/student/views.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index c9ca330479..9ef6a0d73a 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1099,7 +1099,6 @@ def test_center_login(request): # expected values.... # registration_id = request.POST.get("registrationID") # exit_url = request.POST.get("exitURL") - # find testcenter_user that matches the provided ID: try: @@ -1108,7 +1107,6 @@ def test_center_login(request): 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 in future on either the registrationId or the exam code, # or possibly both. But for now we know what to do with an ExamSeriesCode, @@ -1118,7 +1116,11 @@ def test_center_login(request): 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') - + # special case for supporting test user: + if client_candidate_id == "edX003671291147" and exam_series_code != '6002x001': + log.warning("test user {} using unexpected exam code {}, coercing to 6002x001".format(client_candidate_id, exam_series_code)) + exam_series_code = '6002x001' + 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)) From dd4db0601ff3cb67b7128a0818dbb863fecdf527 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 8 Feb 2013 10:45:59 -0500 Subject: [PATCH 19/29] Remove generated files --- .../capa/capa/tests/test_files/js/.gitignore | 4 + .../test_files/js/test_problem_display.js | 49 ------------ .../test_files/js/test_problem_generator.js | 29 ------- .../test_files/js/test_problem_grader.js | 50 ------------ .../capa/capa/tests/test_files/js/xproblem.js | 78 ------------------- 5 files changed, 4 insertions(+), 206 deletions(-) create mode 100644 common/lib/capa/capa/tests/test_files/js/.gitignore delete mode 100644 common/lib/capa/capa/tests/test_files/js/test_problem_display.js delete mode 100644 common/lib/capa/capa/tests/test_files/js/test_problem_generator.js delete mode 100644 common/lib/capa/capa/tests/test_files/js/test_problem_grader.js delete mode 100644 common/lib/capa/capa/tests/test_files/js/xproblem.js diff --git a/common/lib/capa/capa/tests/test_files/js/.gitignore b/common/lib/capa/capa/tests/test_files/js/.gitignore new file mode 100644 index 0000000000..d2910668f2 --- /dev/null +++ b/common/lib/capa/capa/tests/test_files/js/.gitignore @@ -0,0 +1,4 @@ +test_problem_display.js +test_problem_generator.js +test_problem_grader.js +xproblem.js \ No newline at end of file diff --git a/common/lib/capa/capa/tests/test_files/js/test_problem_display.js b/common/lib/capa/capa/tests/test_files/js/test_problem_display.js deleted file mode 100644 index b61569acea..0000000000 --- a/common/lib/capa/capa/tests/test_files/js/test_problem_display.js +++ /dev/null @@ -1,49 +0,0 @@ -// Generated by CoffeeScript 1.4.0 -(function() { - var MinimaxProblemDisplay, root, - __hasProp = {}.hasOwnProperty, - __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; - - MinimaxProblemDisplay = (function(_super) { - - __extends(MinimaxProblemDisplay, _super); - - function MinimaxProblemDisplay(state, submission, evaluation, container, submissionField, parameters) { - this.state = state; - this.submission = submission; - this.evaluation = evaluation; - this.container = container; - this.submissionField = submissionField; - this.parameters = parameters != null ? parameters : {}; - MinimaxProblemDisplay.__super__.constructor.call(this, this.state, this.submission, this.evaluation, this.container, this.submissionField, this.parameters); - } - - MinimaxProblemDisplay.prototype.render = function() {}; - - MinimaxProblemDisplay.prototype.createSubmission = function() { - var id, value, _ref, _results; - this.newSubmission = {}; - if (this.submission != null) { - _ref = this.submission; - _results = []; - for (id in _ref) { - value = _ref[id]; - _results.push(this.newSubmission[id] = value); - } - return _results; - } - }; - - MinimaxProblemDisplay.prototype.getCurrentSubmission = function() { - return this.newSubmission; - }; - - return MinimaxProblemDisplay; - - })(XProblemDisplay); - - root = typeof exports !== "undefined" && exports !== null ? exports : this; - - root.TestProblemDisplay = TestProblemDisplay; - -}).call(this); diff --git a/common/lib/capa/capa/tests/test_files/js/test_problem_generator.js b/common/lib/capa/capa/tests/test_files/js/test_problem_generator.js deleted file mode 100644 index 4b1d133723..0000000000 --- a/common/lib/capa/capa/tests/test_files/js/test_problem_generator.js +++ /dev/null @@ -1,29 +0,0 @@ -// Generated by CoffeeScript 1.4.0 -(function() { - var TestProblemGenerator, root, - __hasProp = {}.hasOwnProperty, - __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; - - TestProblemGenerator = (function(_super) { - - __extends(TestProblemGenerator, _super); - - function TestProblemGenerator(seed, parameters) { - this.parameters = parameters != null ? parameters : {}; - TestProblemGenerator.__super__.constructor.call(this, seed, this.parameters); - } - - TestProblemGenerator.prototype.generate = function() { - this.problemState.value = this.parameters.value; - return this.problemState; - }; - - return TestProblemGenerator; - - })(XProblemGenerator); - - root = typeof exports !== "undefined" && exports !== null ? exports : this; - - root.generatorClass = TestProblemGenerator; - -}).call(this); diff --git a/common/lib/capa/capa/tests/test_files/js/test_problem_grader.js b/common/lib/capa/capa/tests/test_files/js/test_problem_grader.js deleted file mode 100644 index 80d7ad1690..0000000000 --- a/common/lib/capa/capa/tests/test_files/js/test_problem_grader.js +++ /dev/null @@ -1,50 +0,0 @@ -// Generated by CoffeeScript 1.4.0 -(function() { - var TestProblemGrader, root, - __hasProp = {}.hasOwnProperty, - __extends = function(child, parent) { for (var key in parent) { if (__hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; }; - - TestProblemGrader = (function(_super) { - - __extends(TestProblemGrader, _super); - - function TestProblemGrader(submission, problemState, parameters) { - this.submission = submission; - this.problemState = problemState; - this.parameters = parameters != null ? parameters : {}; - TestProblemGrader.__super__.constructor.call(this, this.submission, this.problemState, this.parameters); - } - - TestProblemGrader.prototype.solve = function() { - return this.solution = { - 0: this.problemState.value - }; - }; - - TestProblemGrader.prototype.grade = function() { - var allCorrect, id, value, valueCorrect, _ref; - if (!(this.solution != null)) { - this.solve(); - } - allCorrect = true; - _ref = this.solution; - for (id in _ref) { - value = _ref[id]; - valueCorrect = this.submission != null ? value === this.submission[id] : false; - this.evaluation[id] = valueCorrect; - if (!valueCorrect) { - allCorrect = false; - } - } - return allCorrect; - }; - - return TestProblemGrader; - - })(XProblemGrader); - - root = typeof exports !== "undefined" && exports !== null ? exports : this; - - root.graderClass = TestProblemGrader; - -}).call(this); diff --git a/common/lib/capa/capa/tests/test_files/js/xproblem.js b/common/lib/capa/capa/tests/test_files/js/xproblem.js deleted file mode 100644 index 55a469f7c1..0000000000 --- a/common/lib/capa/capa/tests/test_files/js/xproblem.js +++ /dev/null @@ -1,78 +0,0 @@ -// Generated by CoffeeScript 1.4.0 -(function() { - var XProblemDisplay, XProblemGenerator, XProblemGrader, root; - - XProblemGenerator = (function() { - - function XProblemGenerator(seed, parameters) { - this.parameters = parameters != null ? parameters : {}; - this.random = new MersenneTwister(seed); - this.problemState = {}; - } - - XProblemGenerator.prototype.generate = function() { - return console.error("Abstract method called: XProblemGenerator.generate"); - }; - - return XProblemGenerator; - - })(); - - XProblemDisplay = (function() { - - function XProblemDisplay(state, submission, evaluation, container, submissionField, parameters) { - this.state = state; - this.submission = submission; - this.evaluation = evaluation; - this.container = container; - this.submissionField = submissionField; - this.parameters = parameters != null ? parameters : {}; - } - - XProblemDisplay.prototype.render = function() { - return console.error("Abstract method called: XProblemDisplay.render"); - }; - - XProblemDisplay.prototype.updateSubmission = function() { - return this.submissionField.val(JSON.stringify(this.getCurrentSubmission())); - }; - - XProblemDisplay.prototype.getCurrentSubmission = function() { - return console.error("Abstract method called: XProblemDisplay.getCurrentSubmission"); - }; - - return XProblemDisplay; - - })(); - - XProblemGrader = (function() { - - function XProblemGrader(submission, problemState, parameters) { - this.submission = submission; - this.problemState = problemState; - this.parameters = parameters != null ? parameters : {}; - this.solution = null; - this.evaluation = {}; - } - - XProblemGrader.prototype.solve = function() { - return console.error("Abstract method called: XProblemGrader.solve"); - }; - - XProblemGrader.prototype.grade = function() { - return console.error("Abstract method called: XProblemGrader.grade"); - }; - - return XProblemGrader; - - })(); - - root = typeof exports !== "undefined" && exports !== null ? exports : this; - - root.XProblemGenerator = XProblemGenerator; - - root.XProblemDisplay = XProblemDisplay; - - root.XProblemGrader = XProblemGrader; - -}).call(this); From 33a3d5dbb7c30fc44c305a3b4370bfcb5da706cb Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Fri, 8 Feb 2013 10:49:43 -0500 Subject: [PATCH 20/29] Make it so that cms doesn't try to run collectstatic on course content --- cms/envs/common.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index ef7a4f43fa..30aac6ea01 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -165,13 +165,6 @@ STATICFILES_DIRS = [ # This is how you would use the textbook images locally # ("book", ENV_ROOT / "book_images") ] -if os.path.isdir(GITHUB_REPO_ROOT): - STATICFILES_DIRS += [ - # TODO (cpennington): When courses aren't loaded from github, remove this - (course_dir, GITHUB_REPO_ROOT / course_dir) - for course_dir in os.listdir(GITHUB_REPO_ROOT) - if os.path.isdir(GITHUB_REPO_ROOT / course_dir) - ] # Locale/Internationalization TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name From 96d119965c1d6219fe1e1407b2ffdf0cc070b477 Mon Sep 17 00:00:00 2001 From: ichuang Date: Thu, 7 Feb 2013 23:18:58 -0500 Subject: [PATCH 21/29] limit staff debug verbosity to locations of specific categories --- lms/templates/staff_problem_info.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lms/templates/staff_problem_info.html b/lms/templates/staff_problem_info.html index 61cda0c52b..9324445dd1 100644 --- a/lms/templates/staff_problem_info.html +++ b/lms/templates/staff_problem_info.html @@ -1,5 +1,6 @@ ${module_content} -%if edit_link: +%if location.category in ['problem','video','html']: +% if edit_link:
Edit / QA
-% endif +% endif
-

Our mission is to transform learning.

+

Our mission is to transform learning.

-
-

“EdX represents a unique opportunity to improve education on our campuses through online learning, while simultaneously creating a bold new educational path for millions of learners worldwide.”

- —Rafael Reif, MIT President -
+
+

“EdX represents a unique opportunity to improve education on our campuses through online learning, while simultaneously creating a bold new educational path for millions of learners worldwide.”

+ —Rafael Reif, MIT President +
-
-

“EdX gives Harvard and MIT an unprecedented opportunity to dramatically extend our collective reach by conducting groundbreaking research into effective education and by extending online access to quality higher education.”

- —Drew Faust, Harvard President -
+
+

“EdX gives Harvard and MIT an unprecedented opportunity to dramatically extend our collective reach by conducting groundbreaking research into effective education and by extending online access to quality higher education.”

+ —Drew Faust, Harvard President +
@@ -34,25 +34,45 @@
-
+

EdX is looking to add new talent to our team!

Our mission is to give a world-class education to everyone, everywhere, regardless of gender, income or social status

Today, EdX.org, a not-for-profit provides hundreds of thousands of people from around the globe with access to free education.  We offer amazing quality classes by the best professors from the best schools. We enable our members to uncover a new passion that will transform their lives and their communities.

-

Around the world-from coast to coast, in over 192 countries, people are making the decision to take one or several of our courses. As we continue to grow our operations, we are looking for talented, passionate people with great ideas to join the edX team. We aim to create an environment that is supportive, diverse, and as fun as our brand. If you're results-oriented, dedicated, and ready to contribute to an unparalleled member experience for our community, we really want you to apply.

+

Around the world-from coast to coast, in over 192 countries, people are making the decision to take one or several of our courses. As we continue to grow our operations, we are looking for talented, passionate people with great ideas to join the edX team. We aim to create an environment that is supportive, diverse, and as fun as our brand. If you’re results-oriented, dedicated, and ready to contribute to an unparalleled member experience for our community, we really want you to apply.

As part of the edX team, you’ll receive:

  • Competitive compensation
  • Generous benefits package
  • Free lunch every day
  • -
  • A great working experience where everyone cares
  • +
  • A great working experience where everyone cares and wants to change the world (no, we’re not kidding)
-

While we appreciate every applicant's interest, only those under consideration will be contacted. We regret that phone calls will not be accepted.

+

While we appreciate every applicant’s interest, only those under consideration will be contacted. We regret that phone calls will not be accepted. Equal opportunity employer.

-

Positions

How to Apply

E-mail your resume, coverletter and any other materials to jobs@edx.org

Our Location

11 Cambridge Center
- Cambridge, MA 02142

+ Cambridge, MA 02142

From 83a4f40ac61ba1baf3b6cb92a16a4c94242d3132 Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Fri, 8 Feb 2013 12:24:07 -0500 Subject: [PATCH 23/29] fix typo; fix phrasing for software engineer job --- lms/templates/static_templates/jobs.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lms/templates/static_templates/jobs.html b/lms/templates/static_templates/jobs.html index 134f8a7a15..a50484d722 100644 --- a/lms/templates/static_templates/jobs.html +++ b/lms/templates/static_templates/jobs.html @@ -419,8 +419,8 @@

Requirements:

    -
  • Expert Python Developer or familiar with dynamic development languages
  • -
  • Able to code front to back, including HTML, CSS, javascript, Django, Python
  • +
  • Real-world experience with Python or other dynamic development languages.
  • +
  • Able to code front to back, including HTML, CSS, Javascript, Django, Python
  • You must be committed to an agile development practices, in Scrum or Kanban
  • Demonstrated skills in building Service based architecture
  • Test Driven Development
  • @@ -452,7 +452,7 @@ Software Engineer

    How to Apply

    -

    E-mail your resume, coverletter and any other materials to jobs@edx.org

    +

    E-mail your resume, cover letter and any other materials to jobs@edx.org

    Our Location

    11 Cambridge Center
    Cambridge, MA 02142

    From a68094515ca839980e7e893259feacdd6cb43260 Mon Sep 17 00:00:00 2001 From: Victor Shnayder Date: Fri, 8 Feb 2013 12:33:19 -0500 Subject: [PATCH 24/29] fix capitalization and spacing --- lms/templates/static_templates/jobs.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lms/templates/static_templates/jobs.html b/lms/templates/static_templates/jobs.html index a50484d722..da31e07e42 100644 --- a/lms/templates/static_templates/jobs.html +++ b/lms/templates/static_templates/jobs.html @@ -96,7 +96,7 @@
  • Knowledge of copyright, trademark and patent law
  • Experience with open source content and open source software preferred
  • Outstanding communications skills (written and oral)
  • -
  • Experience with drafting and legal review of internet privacy policies and terms of use.
  • +
  • Experience with drafting and legal review of Internet privacy policies and terms of use.
  • Understanding of how to balance legal risks with business objectives
  • Ability to develop an innovative approach to legal issues in support of strategic business initiatives
  • An internal business and customer focused proactive attitude with ability to prioritize effectively
  • @@ -307,7 +307,7 @@

    DIRECTOR, PRODUCT MANAGEMENT

    When the power of edX is at its fullest, individuals become the students they had always hoped to be, Professors teach the courses they had always imagined and Universities offer educational opportunities never before seen. None of that happens by accident, so edX is seeking a Product Manager who can keep their eyes on the future and their heart and hands with a team of ferociously intelligent and dedicated technologists.

    -

    The responsibility of a Product Manager is first and foremost to provide evidence to the development team that what they build will succeed in the marketplace. It is the responsibility of the Product Manager to define the product backlog and the team to build the backlog. The Product Manager is one of the most highly leveraged individuals in the Engineering organization. They work to bring a deep knowledge of the Customer – Students, Professors and Course Staff to the Product Roadmap. The Product Manager is well-versed in the data and sets the KPI’s that drives the team, the Product Scorecard and the Company Scorecard. They are expected to become experts in the business of online learning, familiar with blended models, MOOC’s and University and Industry needs and the competition. The Product Manager must be able to understand the edX stakeholders. +

    The responsibility of a Product Manager is first and foremost to provide evidence to the development team that what they build will succeed in the marketplace. It is the responsibility of the Product Manager to define the product backlog and the team to build the backlog. The Product Manager is one of the most highly leveraged individuals in the Engineering organization. They work to bring a deep knowledge of the Customer – Students, Professors and Course Staff to the product roadmap. The Product Manager is well-versed in the data and sets the KPI’s that drives the team, the Product Scorecard and the Company Scorecard. They are expected to become experts in the business of online learning, familiar with blended models, MOOC’s and University and Industry needs and the competition. The Product Manager must be able to understand the edX stakeholders.

    Responsibilities:

      @@ -413,7 +413,7 @@

      Forums: We are building our own Forums software because we believe that education requires a forums platform capable of supporting learning communities. We are analytics driven. The ideal Forums candidates are focused on metrics and key performance indicators, understand how to build on top of a service based architecture and are wedded to quick iterations and user feedback. -

      Analytics: We are looking for a platform engineer who has deep MongoDB or no SQL database experience. Our data infrastructure needs to scale to multiple tera bytes. Researchers from Harvard, MIT, Berkeley and edX Universities will use our analytics platform to research and examine the fundamentals of learning. The analytics engineer will be responsible for both building out an analytics platform and a pub-sub and real-time pipeline processing architecture. Together they will allow researchers, students and Professors access to never before seen analytics. +

      Analytics: We are looking for a platform engineer who has deep MongoDB or no SQL database experience. Our data infrastructure needs to scale to multiple terabytes. Researchers from Harvard, MIT, Berkeley and edX Universities will use our analytics platform to research and examine the fundamentals of learning. The analytics engineer will be responsible for both building out an analytics platform and a pub-sub and real-time pipeline processing architecture. Together they will allow researchers, students and Professors access to never before seen analytics.

      Course Development Authoring Tools: We are committed to making it easy for Professors to develop and publish their courses online. So we are building the tools that allow them to readily convert their vision to an online course ready for thousands of students.

      From 98026e4325b5f8554a2e1db2c40dd4a7fcc700bd Mon Sep 17 00:00:00 2001 From: cahrens Date: Fri, 8 Feb 2013 12:46:32 -0500 Subject: [PATCH 25/29] A couple minor bug fixes. https://edx.lighthouseapp.com/projects/102637/tickets/173 https://edx.lighthouseapp.com/projects/102637/tickets/177 --- cms/templates/settings.html | 2 +- common/lib/xmodule/xmodule/js/src/html/edit.coffee | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 8cd4246da9..c96d5686fd 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -206,7 +206,7 @@ from contentstore import utils

      Introducing Your Course

      - Information for perspective students + Information for prospective students
      diff --git a/common/lib/xmodule/xmodule/js/src/html/edit.coffee b/common/lib/xmodule/xmodule/js/src/html/edit.coffee index 238182f3d9..eae9df0f20 100644 --- a/common/lib/xmodule/xmodule/js/src/html/edit.coffee +++ b/common/lib/xmodule/xmodule/js/src/html/edit.coffee @@ -107,12 +107,13 @@ class @HTMLEditingDescriptor # In order for isDirty() to return true ONLY if edits have been made after setting the text, # both the startContent must be sync'ed up and the dirty flag set to false. visualEditor.startContent = visualEditor.getContent({format: "raw", no_events: 1}); - visualEditor.isNotDirty = true @focusVisualEditor(visualEditor) @showingVisualEditor = true focusVisualEditor: (visualEditor) => visualEditor.focus() + # Need to mark editor as not dirty both when it is initially created and when we switch back to it. + visualEditor.isNotDirty = true if not @$mceToolbar? @$mceToolbar = $(@element).find('table.mceToolbar') From f51876da6a7d17a6e6197587e540fff23b2bfd7c Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Fri, 8 Feb 2013 13:42:54 -0500 Subject: [PATCH 26/29] cosmetic changes --- common/djangoapps/student/views.py | 7 +++---- lms/djangoapps/courseware/models.py | 1 + lms/djangoapps/courseware/views.py | 11 ++++------- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 9ef6a0d73a..d6ad9ba4eb 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1166,10 +1166,9 @@ def test_center_login(request): 'ETDBTM' : 'ADDDOUBLE', } 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] + for code in registration.get_accommodation_codes(): + if code in time_accommodation_mapping: + time_accommodation_code = time_accommodation_mapping[code] # special, hard-coded client ID used by Pearson shell for testing: if client_candidate_id == "edX003671291147": time_accommodation_code = 'TESTING' diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index 875c3fcf3a..ac9bde77cd 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -24,6 +24,7 @@ class StudentModule(models.Model): MODULE_TYPES = (('problem', 'problem'), ('video', 'video'), ('html', 'html'), + ('timelimit', 'timelimit'), ) ## These three are the key for the object module_type = models.CharField(max_length=32, choices=MODULE_TYPES, default='problem', db_index=True) diff --git a/lms/djangoapps/courseware/views.py b/lms/djangoapps/courseware/views.py index ffe3972310..e8b36ecd2a 100644 --- a/lms/djangoapps/courseware/views.py +++ b/lms/djangoapps/courseware/views.py @@ -174,8 +174,7 @@ def check_for_active_timelimit_module(request, course_id, course): 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 + raise Http404("No {0} metadata at this location: {1} ".format('time_expired_redirect_url', location)) 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. @@ -197,8 +196,7 @@ def update_timelimit_module(user, course_id, student_module_cache, timelimit_des 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 + raise Http404("No {0} metadata at this location: {1} ".format('time_expired_redirect_url', timelimit_module.location)) time_expired_redirect_url = timelimit_descriptor.metadata.get('time_expired_redirect_url') context['time_expired_redirect_url'] = time_expired_redirect_url @@ -206,8 +204,7 @@ def update_timelimit_module(user, course_id, student_module_cache, timelimit_des 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 + raise Http404("No {0} metadata at this location: {1} ".format('duration', timelimit_module.location)) # 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')) @@ -295,7 +292,7 @@ def index(request, course_id, chapter=None, section=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 + raise Http404('No chapter descriptor found with name {}'.format(chapter)) chapter_module = course_module.get_child_by(lambda m: m.url_name == chapter) if chapter_module is None: From 2a1c89bec5c9296c96d4d3c04efd81a4a7ea7072 Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Sat, 9 Feb 2013 02:24:51 -0500 Subject: [PATCH 27/29] change missingExamSeriesCode to missingPartnerID --- common/djangoapps/student/views.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index d6ad9ba4eb..4413ebfc0f 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1112,9 +1112,11 @@ def test_center_login(request): # 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) + # we are not allowed to make up a new error code, according to Pearson, + # so instead of "missingExamSeriesCode", we use a valid one that is + # inaccurate but at least distinct. (Sigh.) log.error("missing exam series code for cand ID {}".format(client_candidate_id)) - return HttpResponseRedirect(makeErrorURL(error_url, "missingExamSeriesCode")); + return HttpResponseRedirect(makeErrorURL(error_url, "missingPartnerID")); exam_series_code = request.POST.get('vueExamSeriesCode') # special case for supporting test user: if client_candidate_id == "edX003671291147" and exam_series_code != '6002x001': @@ -1188,7 +1190,7 @@ def test_center_login(request): # this information is correct, we allow the user to be logged in # without a password. This could all be formalized in a backend object # that does the above checking. - # TODO: create a backend class to do this. + # TODO: (brian) create a backend class to do this. # testcenteruser.user.backend = "%s.%s" % (backend.__module__, backend.__class__.__name__) testcenteruser.user.backend = "%s.%s" % ("TestcenterAuthenticationModule", "TestcenterAuthenticationClass") login(request, testcenteruser.user) From 4f67c6c0522959c60658bab920d782bb08672fc1 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Sun, 10 Feb 2013 16:17:08 -0500 Subject: [PATCH 28/29] quick hack to give some protection from unauthorized users from making new courses. Make it so only is_staff people see the 'Create New Course' button. --- cms/djangoapps/contentstore/views.py | 7 ++++++- cms/templates/index.html | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/views.py b/cms/djangoapps/contentstore/views.py index 137e71b24a..87a2943773 100644 --- a/cms/djangoapps/contentstore/views.py +++ b/cms/djangoapps/contentstore/views.py @@ -122,7 +122,8 @@ def index(request): course.location.course, course.location.name])) for course in courses], - 'user': request.user + 'user': request.user, + 'disable_course_creation': settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff }) @@ -1259,6 +1260,10 @@ def edge(request): @login_required @expect_json def create_new_course(request): + + if settings.MITX_FEATURES.get('DISABLE_COURSE_CREATION', False) and not request.user.is_staff: + raise PermissionDenied() + # This logic is repeated in xmodule/modulestore/tests/factories.py # so if you change anything here, you need to also change it there. # TODO: write a test that creates two courses, one with the factory and diff --git a/cms/templates/index.html b/cms/templates/index.html index 92987babda..45c4edc176 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -37,7 +37,9 @@

      My Courses

      % if user.is_active: - New Course + % if not disable_course_creation: + New Course + %endif
        %for course, url in courses:
      • From fb1e7f3dc1f4a2d38035c18045ccf7c1ceb00917 Mon Sep 17 00:00:00 2001 From: David Ormsbee Date: Sun, 10 Feb 2013 19:03:25 -0500 Subject: [PATCH 29/29] Make type check for self.code a little more robust, give default val for mmlans so it's never undefined --- common/lib/capa/capa/responsetypes.py | 2 +- lms/lib/symmath/symmath_check.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 78c986a963..ad084bdaf7 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -999,7 +999,7 @@ def sympy_check2(): self.context['debug'] = self.system.DEBUG # exec the check function - if type(self.code) == str: + if isinstance(self.code, basestring): try: exec self.code in self.context['global_context'], self.context correct = self.context['correct'] diff --git a/lms/lib/symmath/symmath_check.py b/lms/lib/symmath/symmath_check.py index a3dec4aae5..d386ea6b06 100644 --- a/lms/lib/symmath/symmath_check.py +++ b/lms/lib/symmath/symmath_check.py @@ -238,8 +238,7 @@ def symmath_check(expect, ans, dynamath=None, options=None, debug=None, xml=None ###### PMathML input ###### # convert mathml answer to formula try: - if dynamath: - mmlans = dynamath[0] + mmlans = dynamath[0] if dynamath else None except Exception, err: mmlans = None if not mmlans: