From 91075eb367e46b4a6059d33609361cc587cc397e Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 3 Oct 2013 12:17:38 -0400 Subject: [PATCH 01/32] nfs and testing artifacts --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4fd90cfe036f..a4a9c6958682 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,12 @@ *.orig *.DS_Store *.mo +*.nfs* :2e_* :2e# .AppleDouble +.noseids +.testids database.sqlite requirements/private.txt lms/envs/private.py From ebd2759d79f1639f8d60afbca55eb842b6d228c8 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 10 Oct 2013 09:52:38 -0400 Subject: [PATCH 02/32] An individual due date extension may be set for a student for a particular problem in the capa module. --- common/lib/xmodule/xmodule/capa_module.py | 10 +++- .../xmodule/combined_open_ended_module.py | 7 +++ common/lib/xmodule/xmodule/foldit_module.py | 7 +++ .../xmodule/xmodule/peer_grading_module.py | 7 +++ common/lib/xmodule/xmodule/utils.py | 7 +++ lms/djangoapps/instructor/views/extensions.py | 28 +++++++++++ lms/djangoapps/instructor/views/legacy.py | 46 +++++++++++++++++++ lms/envs/common.py | 3 ++ .../courseware/instructor_dashboard.html | 38 +++++++++++++++ lms/xmodule_namespace.py | 7 +++ 10 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 common/lib/xmodule/xmodule/utils.py create mode 100644 lms/djangoapps/instructor/views/extensions.py diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index dbd535a471be..c16235641535 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -21,6 +21,7 @@ from xblock.core import Scope, String, Boolean, Dict, Integer, Float from .fields import Timedelta, Date from django.utils.timezone import UTC +from .utils import get_extended_due_date log = logging.getLogger("mitx.courseware") @@ -94,6 +95,13 @@ class CapaFields(object): values={"min": 0}, scope=Scope.settings ) due = Date(help="Date that this problem is due by", scope=Scope.settings) + extended_due = Date( + help="Date that this problem is due by for a particular student. This " + "may differ from the global due date if an instructor has granted " + "an extension to the student.", + default=None, + scope=Scope.user_state, + ) graceperiod = Timedelta( help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings @@ -179,7 +187,7 @@ def __init__(self, *args, **kwargs): """ XModule.__init__(self, *args, **kwargs) - due_date = self.due + due_date = get_extended_due_date(self) if self.graceperiod is not None and due_date: self.close_date = due_date + self.graceperiod diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index e01ae49149dd..3ce74fb3e221 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -229,6 +229,13 @@ class CombinedOpenEndedFields(object): default=None, scope=Scope.settings ) + extended_due = Date( + help="Date that this problem is due by for a particular student. This " + "may differ from the global due date if an instructor has granted " + "an extension to the student.", + default=None, + scope=Scope.user_state, + ) graceperiod = String( help="Amount of time after the due date that submissions will be accepted", default=None, diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index cadf6cef0bcd..6629444a9e7b 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -20,6 +20,13 @@ class FolditFields(object): required_level = Integer(default=4, scope=Scope.settings) required_sublevel = Integer(default=5, scope=Scope.settings) due = Date(help="Date that this problem is due by", scope=Scope.settings) + extended_due = Date( + help="Date that this problem is due by for a particular student. This " + "may differ from the global due date if an instructor has granted " + "an extension to the student.", + default=None, + scope=Scope.user_state, + ) show_basic_score = String(scope=Scope.settings, default='false') show_leaderboard = String(scope=Scope.settings, default='false') diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 1ef3883d8274..0216171c6f6a 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -48,6 +48,13 @@ class PeerGradingFields(object): help="Due date that should be displayed.", default=None, scope=Scope.settings) + extended_due = Date( + help="Date that this problem is due by for a particular student. This " + "may differ from the global due date if an instructor has granted " + "an extension to the student.", + default=None, + scope=Scope.user_state, + ) graceperiod = Timedelta( help="Amount of grace to give on the due date.", scope=Scope.settings diff --git a/common/lib/xmodule/xmodule/utils.py b/common/lib/xmodule/xmodule/utils.py new file mode 100644 index 000000000000..92e4a0d14d3c --- /dev/null +++ b/common/lib/xmodule/xmodule/utils.py @@ -0,0 +1,7 @@ + + +def get_extended_due_date(node): + due_date = getattr(node, 'extended_due', None) + if not due_date: + due_date = getattr(node, 'due', None) + return due_date diff --git a/lms/djangoapps/instructor/views/extensions.py b/lms/djangoapps/instructor/views/extensions.py new file mode 100644 index 000000000000..f10fa86477b5 --- /dev/null +++ b/lms/djangoapps/instructor/views/extensions.py @@ -0,0 +1,28 @@ +import json +from courseware.models import StudentModule +from xmodule.fields import Date + +datetime_to_json = Date().to_json + + +def set_due_date_extension(request, course_id, section, student, due_date): + """ + Sets a due date extension. Factored to be usable in both legacy and beta + instructor dashboards. + """ + try: + student_module = StudentModule.objects.get( + student_id=student.id, + course_id=course_id, + module_state_key=section + ) + state = json.loads(student_module.state) + state['extended_due'] = datetime_to_json(due_date) + student_module.state = json.dumps(state) + student_module.save() + return None # no error + + except StudentModule.DoesNotExist: + return "Couldn't find module with that urlname: {0} {1}. ".format( + section, student + ) diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 6a02b5be7d4c..23adc6041410 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -3,6 +3,7 @@ """ from collections import defaultdict import csv +import datetime import json import logging from markupsafe import escape @@ -50,6 +51,8 @@ import track.views from mitxmako.shortcuts import render_to_string +from .extensions import set_due_date_extension + log = logging.getLogger(__name__) @@ -187,6 +190,22 @@ def get_student_from_identifier(unique_student_identifier): msg += "Couldn't find student with that email or username. " return msg, student + def parse_datetime(s): + """ + Constructs a datetime object in UTC from user input. + """ + msg = "" + dt = None + try: + d, t = s.split() + mm, dd, yy = map(int, d.split('/')) + h, m = map(int, t.split(':')) + dt = datetime.datetime(yy, mm, dd, h, m, tzinfo=timezone.utc) + return msg, dt + except: + msg = "Unable to parse date: {0} ".format(s) + return msg, dt + # process actions from form POST action = request.POST.get('action', '') use_offline = request.POST.get('use_offline_grades', False) @@ -469,6 +488,33 @@ def domatch(x): msg2, _ = _do_remote_gradebook(request.user, course, 'post-grades', files=files) msg += msg2 + #---------------------------------------- + # Extensions + + elif "Change due date for student" in action: + # get the form data + unique_student_identifier = request.POST.get( + 'unique_student_identifier', '' + ) + section = get_module_url(request.POST.get('section')) + + # try to uniquely id student by email address or username + message, student = get_student_from_identifier(unique_student_identifier) + msg += message + + # parse datetime + message, due_date = parse_datetime(request.POST.get('due_datetime')) + msg += message + + if section and student and due_date: + error = set_due_date_extension( + request, course_id, section, student, due_date) + if error: + msg += '{0}'.format(error) + log.debug(error) + else: + msg += 'Successfully changed due date.' + #---------------------------------------- # Admin diff --git a/lms/envs/common.py b/lms/envs/common.py index 0cbcbb774ab3..faf6a054ce76 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -144,6 +144,9 @@ # Enable instructor dash to submit background tasks 'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True, + # Enable instructor to assign individual due dates + 'INDIVIDUAL_DUE_DATES': False, + # Enable instructor dash beta version link 'ENABLE_INSTRUCTOR_BETA_DASHBOARD': False, diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 5b254fc86e58..2ff43f2c0468 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -110,6 +110,9 @@

${_("Instructor Dashboard")}

${_("Export grades to remote gradebook")}


%endif + %if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):

${_("Course-specific grade adjustment")}

@@ -271,6 +275,40 @@

${_("Student-specific grade inspection and adjustment")}

%endif + +##----------------------------------------------------------------------------- +%if modeflag.get('Extensions'): + %if settings.MITX_FEATURES.get('INDIVIDUAL_DUE_DATES'): +

${_("Individual due date extensions")}

+ +

+ ${_("Specify the {platform_name} email address or username of a student here:").format(platform_name=settings.PLATFORM_NAME)} + +

+

+ ${_("Specify a particular unit or problem in the course here by its url:")} + +

+

+ ${_('You may use just the "urlname" if a problem, or "modulename/urlname" if not. ' + '(For example, if the location is i4x://university/course/problem/problemname, ' + 'then just provide the problemname. ' + 'If the location is i4x://university/course/notaproblem/someothername, then ' + 'provide notaproblem/someothername.)')} +

+

+ ${_("Specify the individual due date and time in UTC:")} + +

+

+ +

+
+ %endif +%endif + + ##----------------------------------------------------------------------------- %if modeflag.get('Psychometrics'): diff --git a/lms/xmodule_namespace.py b/lms/xmodule_namespace.py index d57ad9ce5238..235724a3bc57 100644 --- a/lms/xmodule_namespace.py +++ b/lms/xmodule_namespace.py @@ -33,6 +33,13 @@ class LmsNamespace(Namespace): scope=Scope.settings ) due = Date(help="Date that this problem is due by", scope=Scope.settings) + extended_due = Date( + help="Date that this problem is due by for a particular student. This " + "may differ from the global due date if an instructor has granted " + "an extension to the student.", + default=None, + scope=Scope.user_state, + ) source_file = String(help="source file name (eg for latex)", scope=Scope.settings) giturl = String(help="url root for course data git repository", scope=Scope.settings) xqa_key = String(help="DO NOT USE", scope=Scope.settings) From c8794b84faef494b480b5b11368731ef291a3e83 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 10 Oct 2013 15:53:33 -0400 Subject: [PATCH 03/32] Ability to set due date extension for entire homework or exam. --- lms/djangoapps/instructor/views/extensions.py | 55 +++++++++++++------ lms/djangoapps/instructor/views/legacy.py | 6 +- 2 files changed, 41 insertions(+), 20 deletions(-) diff --git a/lms/djangoapps/instructor/views/extensions.py b/lms/djangoapps/instructor/views/extensions.py index f10fa86477b5..7b615bb8b400 100644 --- a/lms/djangoapps/instructor/views/extensions.py +++ b/lms/djangoapps/instructor/views/extensions.py @@ -5,24 +5,45 @@ datetime_to_json = Date().to_json -def set_due_date_extension(request, course_id, section, student, due_date): +def set_due_date_extension(course, url, student, due_date): """ Sets a due date extension. Factored to be usable in both legacy and beta instructor dashboards. """ - try: - student_module = StudentModule.objects.get( - student_id=student.id, - course_id=course_id, - module_state_key=section - ) - state = json.loads(student_module.state) - state['extended_due'] = datetime_to_json(due_date) - student_module.state = json.dumps(state) - student_module.save() - return None # no error - - except StudentModule.DoesNotExist: - return "Couldn't find module with that urlname: {0} {1}. ".format( - section, student - ) + def find_node(node): + """ + Find node in course tree for url. + """ + if node.location.url() == url: + return node + for child in node.get_children(): + found = find_node(child) + if found: + return found + return None + + node = find_node(course) + if not node: + return "Couldn't find module for url: {0}" % url + + def set_due_date(node): + try: + student_module = StudentModule.objects.get( + student_id=student.id, + course_id=course.id, + module_state_key=node.location.url() + ) + + state = json.loads(student_module.state) + state['extended_due'] = datetime_to_json(due_date) + student_module.state = json.dumps(state) + student_module.save() + except StudentModule.DoesNotExist: + pass + + for child in node.get_children(): + set_due_date(child) + + set_due_date(node) + + return None # no error diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 23adc6041410..cd72e90c1eaa 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -496,7 +496,7 @@ def domatch(x): unique_student_identifier = request.POST.get( 'unique_student_identifier', '' ) - section = get_module_url(request.POST.get('section')) + url = get_module_url(request.POST.get('section')) # try to uniquely id student by email address or username message, student = get_student_from_identifier(unique_student_identifier) @@ -506,9 +506,9 @@ def domatch(x): message, due_date = parse_datetime(request.POST.get('due_datetime')) msg += message - if section and student and due_date: + if url and student and due_date: error = set_due_date_extension( - request, course_id, section, student, due_date) + course, url, student, due_date) if error: msg += '{0}'.format(error) log.debug(error) From 8f57c59c60cdde0a08f35702cf589d1077abaa7a Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 10 Oct 2013 16:23:48 -0400 Subject: [PATCH 04/32] Display correct dates in UI. --- lms/djangoapps/courseware/grades.py | 3 ++- lms/djangoapps/courseware/module_render.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index e3c40079c3fd..b47feb11063d 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -14,6 +14,7 @@ from xmodule import graders from xmodule.capa_module import CapaModule from xmodule.graders import Score +from xmodule.utils import get_extended_due_date from .models import StudentModule log = logging.getLogger("mitx.courseware") @@ -325,7 +326,7 @@ def progress_summary(student, request, course, model_data_cache): 'scores': scores, 'section_total': section_total, 'format': module_format, - 'due': section_module.lms.due, + 'due': get_extended_due_date(section_module.lms), 'graded': graded, }) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 0a48c56f874d..7bdc3295ae32 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -24,6 +24,7 @@ from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.utils import get_extended_due_date from xmodule.x_module import ModuleSystem from xmodule_modifiers import replace_course_urls, replace_jump_to_id_urls, replace_static_urls, add_histogram, wrap_xmodule, save_module # pylint: disable=F0401 @@ -110,7 +111,7 @@ def toc_for_course(user, request, course, active_chapter, active_section, model_ sections.append({'display_name': section.display_name_with_default, 'url_name': section.url_name, 'format': section.lms.format if section.lms.format is not None else '', - 'due': section.lms.due, + 'due': get_extended_due_date(section.lms), 'active': active, 'graded': section.lms.graded, }) From eb76cf117e1da3311c5aa6be3b308fcbb88021e9 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 11 Oct 2013 10:43:58 -0400 Subject: [PATCH 05/32] Fix InvalidWriteError when viewing a combined open ended problem in the LMS as an instructor. --- lms/djangoapps/courseware/model_data.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index 44be16e44182..bb9a490d8ef0 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -353,7 +353,13 @@ def set_many(self, kv_dict): for field in kv_dict: # Check field for validity if field.field_name in self._descriptor_model_data: - raise InvalidWriteError("Not allowed to overwrite descriptor model data", field.field_name) + # xblock model data will set any mutable field as dirty whether + # it's been mutated or not. In light of that it's better to + # silently skip these fields rather than raise an error. + #raise InvalidWriteError( + # "Not allowed to overwrite descriptor model data", + # field.field_name) + continue if field.scope not in self._allowed_scopes: raise InvalidScopeError(field.scope) From 7f400717de79ae219ad177ab24bad07de649db32 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 11 Oct 2013 11:26:28 -0400 Subject: [PATCH 06/32] Get extended due date to work with combined open ended module. --- .../xmodule/xmodule/combined_open_ended_module.py | 10 ++++++---- .../combined_open_ended_modulev1.py | 4 +++- common/lib/xmodule/xmodule/peer_grading_module.py | 14 ++++++++------ 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 3ce74fb3e221..dab818dac90a 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -13,11 +13,13 @@ log = logging.getLogger("mitx.courseware") -V1_SETTINGS_ATTRIBUTES = ["display_name", "max_attempts", "graded", "accept_file_upload", - "skip_spelling_checks", "due", "graceperiod", "weight"] +V1_SETTINGS_ATTRIBUTES = [ + "display_name", "max_attempts", "graded", "accept_file_upload", + "skip_spelling_checks", "due", "graceperiod", "weight", "extended_due"] -V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state", - "student_attempts", "ready_to_reset"] +V1_STUDENT_ATTRIBUTES = [ + "current_task_number", "task_states", "state", + "student_attempts", "ready_to_reset"] V1_ATTRIBUTES = V1_SETTINGS_ATTRIBUTES + V1_STUDENT_ATTRIBUTES diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 933eb0b5bb1f..c829aeb8b5c8 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -106,7 +106,9 @@ def __init__(self, system, location, definition, descriptor, self.accept_file_upload = instance_state.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT self.skip_basic_checks = instance_state.get('skip_spelling_checks', SKIP_BASIC_CHECKS) in TRUE_DICT - due_date = instance_state.get('due', None) + due_date = instance_state.get('extended_due', None) + if due_date is None: + due_date = instance_state.get('due', None) grace_period_string = instance_state.get('graceperiod', None) try: diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 0216171c6f6a..fd722a1aa917 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -7,10 +7,11 @@ from pkg_resources import resource_string from .capa_module import ComplexEncoder from .x_module import XModule -from xmodule.raw_module import RawDescriptor -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.exceptions import ItemNotFoundError +from .raw_module import RawDescriptor +from .modulestore.django import modulestore +from .modulestore.exceptions import ItemNotFoundError from .timeinfo import TimeInfo +from .utils import get_extended_due_date from xblock.core import Dict, String, Scope, Boolean, Integer, Float from xmodule.fields import Date, Timedelta @@ -117,12 +118,13 @@ def __init__(self, *args, **kwargs): log.error("Linked location {0} for peer grading module {1} does not exist".format( self.link_to_location, self.location)) raise - due_date = self.linked_problem.lms.due + due_date = get_extended_due_date(self.linked_problem.lms) if due_date: self.lms.due = due_date try: - self.timeinfo = TimeInfo(self.due, self.graceperiod) + self.timeinfo = TimeInfo( + get_extended_due_date(self), self.graceperiod) except Exception: log.error("Error parsing due date information in location {0}".format(self.location)) raise @@ -529,7 +531,7 @@ def _find_corresponding_module_for_location(location): problem_location = problem['location'] descriptor = _find_corresponding_module_for_location(problem_location) if descriptor: - problem['due'] = descriptor.lms.due + problem['due'] = get_extended_due_date(descriptor.lms) grace_period = descriptor.lms.graceperiod try: problem_timeinfo = TimeInfo(problem['due'], grace_period) From b1716f458d52c0872895dd8adca3524278379f3f Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 11 Oct 2013 13:58:50 -0400 Subject: [PATCH 07/32] Use extended due date (unable to test FoldIt module). --- common/lib/xmodule/xmodule/foldit_module.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index 6629444a9e7b..8fad45aa4a49 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -8,6 +8,7 @@ from xmodule.xml_module import XmlDescriptor from xblock.core import Scope, Integer, String from .fields import Date +from .utils import get_extended_due_date log = logging.getLogger(__name__) @@ -47,7 +48,7 @@ def __init__(self, *args, **kwargs): show_leaderboard="false"/> """ XModule.__init__(self, *args, **kwargs) - self.due_time = self.due + self.due_time = get_extended_due_date(self) def is_complete(self): """ From 083ab0d2687db781eaff743ed0fa50ad50038081 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 11 Oct 2013 14:00:41 -0400 Subject: [PATCH 08/32] More user friendly unit selection. --- common/lib/xmodule/xmodule/seq_module.py | 20 ++++++++++++---- lms/djangoapps/instructor/views/legacy.py | 23 +++++++++++++++++-- .../courseware/instructor_dashboard.html | 15 +++++------- 3 files changed, 42 insertions(+), 16 deletions(-) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 580475e1ae7d..9ef866dafe27 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -3,14 +3,16 @@ from lxml import etree -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 xblock.core import Integer, Scope from pkg_resources import resource_string +from .exceptions import NotFoundError +from .fields import Date +from .mako_module import MakoModuleDescriptor +from .progress import Progress +from .x_module import XModule +from .xml_module import XmlDescriptor + log = logging.getLogger(__name__) # HACK: This shouldn't be hard-coded to two types @@ -24,6 +26,14 @@ class SequenceFields(object): # NOTE: Position is 1-indexed. This is silly, but there are now student # positions saved on prod, so it's not easy to fix. position = Integer(help="Last tab viewed in this sequence", scope=Scope.user_state) + due = Date(help="Date that this problem is due by", scope=Scope.settings) + extended_due = Date( + help="Date that this problem is due by for a particular student. This " + "may differ from the global due date if an instructor has granted " + "an extension to the student.", + default=None, + scope=Scope.user_state, + ) class SequenceModule(SequenceFields, XModule): diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index cd72e90c1eaa..99a3e5471fea 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -496,7 +496,7 @@ def domatch(x): unique_student_identifier = request.POST.get( 'unique_student_identifier', '' ) - url = get_module_url(request.POST.get('section')) + url = request.POST.get('url') # try to uniquely id student by email address or username message, student = get_student_from_identifier(unique_student_identifier) @@ -817,7 +817,7 @@ def get_analytics_result(analytics_name): 'instructor_tasks': instructor_tasks, 'offline_grade_log': offline_grades_available(course_id), 'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}), - + 'units_with_due_dates': get_graded_units_with_due_dates(course), 'analytics_results': analytics_results, } @@ -827,6 +827,25 @@ def get_analytics_result(analytics_name): return render_to_response('courseware/instructor_dashboard.html', context) +def get_graded_units_with_due_dates(course): + units = [] + def visit(node, level=0): + if getattr(node, 'due', None): + url = node.location.url() + title = getattr(node, 'display_name', None) + if not title: + title = url + else: + title += " (%s)" % url + units.append((title, url)) + else: + for child in node.get_children(): + visit(child, level+1) + visit(course) + units.sort(key=lambda x: x[0].lower()) + return units + + def _do_remote_gradebook(user, course, action, args=None, files=None): ''' Perform remote gradebook action. Returns msg, datatable. diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 2ff43f2c0468..fbfc022ec14a 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -286,15 +286,12 @@

${_("Individual due date extensions")}

- ${_("Specify a particular unit or problem in the course here by its url:")} - -

-

- ${_('You may use just the "urlname" if a problem, or "modulename/urlname" if not. ' - '(For example, if the location is i4x://university/course/problem/problemname, ' - 'then just provide the problemname. ' - 'If the location is i4x://university/course/notaproblem/someothername, then ' - 'provide notaproblem/someothername.)')} + ${_("Choose the graded unit:")} +

${_("Specify the individual due date and time in UTC:")} From 0eaafba061c983eadfd8415cb4ce48e8efd7ca12 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 11 Oct 2013 17:06:15 -0400 Subject: [PATCH 09/32] Dump due date extension data and reset due date extensions. --- lms/djangoapps/instructor/views/extensions.py | 108 +++++++++++++++--- lms/djangoapps/instructor/views/legacy.py | 68 +++++++---- .../courseware/instructor_dashboard.html | 5 + 3 files changed, 143 insertions(+), 38 deletions(-) diff --git a/lms/djangoapps/instructor/views/extensions.py b/lms/djangoapps/instructor/views/extensions.py index 7b615bb8b400..b58d2ae4a97a 100644 --- a/lms/djangoapps/instructor/views/extensions.py +++ b/lms/djangoapps/instructor/views/extensions.py @@ -2,7 +2,7 @@ from courseware.models import StudentModule from xmodule.fields import Date -datetime_to_json = Date().to_json +date_field = Date() def set_due_date_extension(course, url, student, due_date): @@ -10,19 +10,7 @@ def set_due_date_extension(course, url, student, due_date): Sets a due date extension. Factored to be usable in both legacy and beta instructor dashboards. """ - def find_node(node): - """ - Find node in course tree for url. - """ - if node.location.url() == url: - return node - for child in node.get_children(): - found = find_node(child) - if found: - return found - return None - - node = find_node(course) + node = find_unit(course, url) if not node: return "Couldn't find module for url: {0}" % url @@ -35,7 +23,7 @@ def set_due_date(node): ) state = json.loads(student_module.state) - state['extended_due'] = datetime_to_json(due_date) + state['extended_due'] = date_field.to_json(due_date) student_module.state = json.dumps(state) student_module.save() except StudentModule.DoesNotExist: @@ -47,3 +35,93 @@ def set_due_date(node): set_due_date(node) return None # no error + + +def find_unit(node, url): + """ + Find node in course tree for url. + """ + if node.location.url() == url: + return node + for child in node.get_children(): + found = find_unit(child, url) + if found: + return found + return None + + +def get_units_with_due_date(course): + units = [] + def visit(node, level=0): + if getattr(node, 'due', None): + units.append(node) + else: + for child in node.get_children(): + visit(child, level+1) + visit(course) + units.sort(key=title_or_url) + return units + + +def get_units_with_due_date_options(course): + def make_option(node): + return title_or_url(node), node.location.url() + return map(make_option, get_units_with_due_date(course)) + + +def title_or_url(node): + title = getattr(node, 'display_name', None) + if not title: + title = node.location.url() + return title + + +def dump_students_with_due_date_extensions(course, url): + unit = find_unit(course, url) + if not unit: + return "Couldn't find module for url: {0}" % url, {} + + data = [] + query = StudentModule.objects.filter( + course_id=course.id, + module_state_key=url) + for sm in query: + state = json.loads(sm.state) + extended_due = state.get("extended_due") + if not extended_due: + continue + extended_due = date_field.from_json(extended_due) + extended_due = extended_due.strftime("%Y-%m-%d %H:%M") + fullname = '%s %s' % (sm.student.first_name, sm.student.last_name) + data.append((sm.student.username, fullname, extended_due)) + return None, { + "header": ["Username", "Full Name", "Extended Due Date"], + "title": "Users with due date extensions for {0}".format( + title_or_url(unit)), + "data": data + } + + +def dump_due_date_extensions_for_student(course, student): + data = [] + units = get_units_with_due_date(course) + units = dict([(u.location.url(), u) for u in units]) + query = StudentModule.objects.filter( + course_id=course.id, + student_id=student.id) + for sm in query: + state = json.loads(sm.state) + if sm.module_state_key not in units: + continue + extended_due = state.get("extended_due") + if not extended_due: + continue + extended_due = date_field.from_json(extended_due) + extended_due = extended_due.strftime("%Y-%m-%d %H:%M") + title = title_or_url(units[sm.module_state_key]) + data.append((title, extended_due)) + return None, { + "header": ["Unit", "Extended Due Date"], + "title": "Due date extensions for {0} {1} ({2})".format( + student.first_name, student.last_name, student.username), + "data": data} diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 99a3e5471fea..d80eb730e564 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -51,7 +51,11 @@ import track.views from mitxmako.shortcuts import render_to_string -from .extensions import set_due_date_extension +from .extensions import ( + dump_students_with_due_date_extensions, + dump_due_date_extensions_for_student, + get_units_with_due_date_options, + set_due_date_extension) log = logging.getLogger(__name__) @@ -494,8 +498,7 @@ def domatch(x): elif "Change due date for student" in action: # get the form data unique_student_identifier = request.POST.get( - 'unique_student_identifier', '' - ) + 'unique_student_identifier', '') url = request.POST.get('url') # try to uniquely id student by email address or username @@ -515,6 +518,44 @@ def domatch(x): else: msg += 'Successfully changed due date.' + elif "Reset due date for student" in action: + # get the form data + unique_student_identifier = request.POST.get( + 'unique_student_identifier', '') + url = request.POST.get('url') + + # try to uniquely id student by email address or username + message, student = get_student_from_identifier(unique_student_identifier) + msg += message + + if url and student: + error = set_due_date_extension( + course, url, student, None) + if error: + msg += '{0}'.format(error) + log.debug(error) + else: + msg += 'Successfully reset due date.' + + + elif "Dump list of students with due date extensions" in action: + url = request.POST.get('url') + error, datatable = dump_students_with_due_date_extensions(course, url) + if error: + msg += '{0}'.format(error) + + elif "Dump due date extensions for student" in action: + unique_student_identifier = request.POST.get( + 'unique_student_identifier', '') + message, student = get_student_from_identifier(unique_student_identifier) + msg += message + + if student: + error, datatable = dump_due_date_extensions_for_student( + course, student) + if error: + msg += '{0}'.format(error) + #---------------------------------------- # Admin @@ -817,7 +858,7 @@ def get_analytics_result(analytics_name): 'instructor_tasks': instructor_tasks, 'offline_grade_log': offline_grades_available(course_id), 'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}), - 'units_with_due_dates': get_graded_units_with_due_dates(course), + 'units_with_due_dates': get_units_with_due_date_options(course), 'analytics_results': analytics_results, } @@ -827,25 +868,6 @@ def get_analytics_result(analytics_name): return render_to_response('courseware/instructor_dashboard.html', context) -def get_graded_units_with_due_dates(course): - units = [] - def visit(node, level=0): - if getattr(node, 'due', None): - url = node.location.url() - title = getattr(node, 'display_name', None) - if not title: - title = url - else: - title += " (%s)" % url - units.append((title, url)) - else: - for child in node.get_children(): - visit(child, level+1) - visit(course) - units.sort(key=lambda x: x[0].lower()) - return units - - def _do_remote_gradebook(user, course, action, args=None, files=None): ''' Perform remote gradebook action. Returns msg, datatable. diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index fbfc022ec14a..ba0b54d34f73 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -300,6 +300,11 @@

${_("Individual due date extensions")}

+ +

+

+ +


%endif From 12579d152cb17bf04b430dfcb9912cb00f860c6d Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Sat, 12 Oct 2013 10:02:17 -0400 Subject: [PATCH 10/32] Test closing date respects due date extension. --- .../xmodule/xmodule/tests/test_capa_module.py | 33 ++++++------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 80c4e41e8f1a..102ef4cb582d 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -66,16 +66,10 @@ def answer_key(): "_2_1") @staticmethod - def create(graceperiod=None, - due=None, - max_attempts=None, - showanswer=None, - rerandomize=None, - force_save_button=None, - attempts=None, + def create(attempts=None, problem_state=None, correct=False, - done=None + **kwargs ): """ All parameters are optional, and are added to the created problem if specified. @@ -97,21 +91,7 @@ def create(graceperiod=None, "SampleProblem{0}".format(CapaFactory.next_num())]) model_data = {'data': CapaFactory.sample_problem_xml, 'location': location} - if graceperiod is not None: - model_data['graceperiod'] = graceperiod - if due is not None: - model_data['due'] = due - if max_attempts is not None: - model_data['max_attempts'] = max_attempts - if showanswer is not None: - model_data['showanswer'] = showanswer - if force_save_button is not None: - model_data['force_save_button'] = force_save_button - if rerandomize is not None: - model_data['rerandomize'] = rerandomize - if done is not None: - model_data['done'] = done - + model_data.update(kwargs) descriptor = Mock(weight="1") if problem_state is not None: model_data.update(problem_state) @@ -318,6 +298,13 @@ def test_closed(self): due=self.yesterday_str) self.assertTrue(module.closed()) + def test_due_date_extension(self): + + module = CapaFactory.create( + max_attempts="1", attempts="0", due=self.yesterday_str, + extended_due=self.tomorrow_str) + self.assertFalse(module.closed()) + def test_parse_get_params(self): # We have to set up Django settings in order to use QueryDict From 7e9cbac85207e3ceceeeaf011b781b995f896f89 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Sat, 12 Oct 2013 10:08:00 -0400 Subject: [PATCH 11/32] Fix test broken by eb76cf117e1da3311c5aa6be3b308fcbb88021e9 --- lms/djangoapps/courseware/tests/test_model_data.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/courseware/tests/test_model_data.py b/lms/djangoapps/courseware/tests/test_model_data.py index 0368bb040bee..8d88fb99b370 100644 --- a/lms/djangoapps/courseware/tests/test_model_data.py +++ b/lms/djangoapps/courseware/tests/test_model_data.py @@ -66,12 +66,12 @@ def test_get_from_descriptor(self): self.assertEquals('settings', self.kvs.get(settings_key('field_b'))) def test_write_to_descriptor(self): - self.assertRaises(InvalidWriteError, self.kvs.set, content_key('field_a'), 'foo') + self.kvs.set(content_key('field_a'), 'foo') self.assertEquals('content', self.desc_md['field_a']) - self.assertRaises(InvalidWriteError, self.kvs.set, settings_key('field_b'), 'foo') + self.kvs.set(settings_key('field_b'), 'foo') self.assertEquals('settings', self.desc_md['field_b']) - self.assertRaises(InvalidWriteError, self.kvs.set_many, {content_key('field_a'): 'foo'}) + self.kvs.set_many({content_key('field_a'): 'foo'}) self.assertEquals('content', self.desc_md['field_a']) self.assertRaises(InvalidWriteError, self.kvs.delete, content_key('field_a')) From 087b5396d80bf09939e207fa3a5a9e15fdf884c5 Mon Sep 17 00:00:00 2001 From: vagrant Date: Sat, 12 Oct 2013 15:34:22 +0000 Subject: [PATCH 12/32] Unit tests for due date extensions. --- lms/djangoapps/instructor/views/extensions.py | 5 +- .../instructor/views/tests/__init__.py | 0 .../instructor/views/tests/test_extensions.py | 125 ++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 lms/djangoapps/instructor/views/tests/__init__.py create mode 100644 lms/djangoapps/instructor/views/tests/test_extensions.py diff --git a/lms/djangoapps/instructor/views/extensions.py b/lms/djangoapps/instructor/views/extensions.py index b58d2ae4a97a..e8e346bc597a 100644 --- a/lms/djangoapps/instructor/views/extensions.py +++ b/lms/djangoapps/instructor/views/extensions.py @@ -12,7 +12,7 @@ def set_due_date_extension(course, url, student, due_date): """ node = find_unit(course, url) if not node: - return "Couldn't find module for url: {0}" % url + return "Couldn't find module for url: {0}".format(url) def set_due_date(node): try: @@ -79,7 +79,7 @@ def title_or_url(node): def dump_students_with_due_date_extensions(course, url): unit = find_unit(course, url) if not unit: - return "Couldn't find module for url: {0}" % url, {} + return "Couldn't find module for url: {0}".format(url), {} data = [] query = StudentModule.objects.filter( @@ -94,6 +94,7 @@ def dump_students_with_due_date_extensions(course, url): extended_due = extended_due.strftime("%Y-%m-%d %H:%M") fullname = '%s %s' % (sm.student.first_name, sm.student.last_name) data.append((sm.student.username, fullname, extended_due)) + data.sort(key=lambda x: x[0]) return None, { "header": ["Username", "Full Name", "Extended Due Date"], "title": "Users with due date extensions for {0}".format( diff --git a/lms/djangoapps/instructor/views/tests/__init__.py b/lms/djangoapps/instructor/views/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/instructor/views/tests/test_extensions.py b/lms/djangoapps/instructor/views/tests/test_extensions.py new file mode 100644 index 000000000000..6e0731bb3f3a --- /dev/null +++ b/lms/djangoapps/instructor/views/tests/test_extensions.py @@ -0,0 +1,125 @@ +import datetime +import json +import mock +import unittest + + +due_date = datetime.datetime(2010, 5, 12, 2, 42) +extended_due_date = datetime.datetime(2013, 10, 12, 10, 30) + + +class ExtensionsTests(unittest.TestCase): + + def setUp(self): + self.course = DummyCourseNode( + 'Dummy Course', 'i4x://dummy', + children = [ + DummyCourseNode( + 'Homework 1', 'i4x://dummy/homework', + due=due_date, + children=[ + DummyCourseNode( + 'Problem 1', 'i4x://dummy/homework/problem')]), + DummyCourseNode( + 'Final Exam', 'i4x://dummy/exam', + due=due_date, + children=[ + DummyCourseNode( + 'Problem 2', 'i4x://dummy/exam/problem')])]) + self.homework, self.exam = self.course.children + + patcher = mock.patch('instructor.views.extensions.StudentModule') + self.StudentModule = patcher.start() + self.addCleanup(patcher.stop) + + self.student = mock.Mock(username='fred', first_name='Fred', + last_name='Flintstone') + + def test_set_due_date_extension_success(self): + from ..extensions import set_due_date_extension as fut + self.StudentModule.objects.get.return_value.state = json.dumps({}) + error = fut(self.course, 'i4x://dummy/homework', self.student, + extended_due_date) + self.assertEqual(error, None) + state = json.loads(self.StudentModule.objects.get.return_value.state) + self.assertEqual(state['extended_due'], u'2013-10-12T10:30:00Z') + + def test_set_due_date_extension_bad_url(self): + from ..extensions import set_due_date_extension as fut + error = fut(self.course, 'i4x://foo', self.student, extended_due_date) + self.assertTrue(error.startswith("Couldn't find")) + + def test_dump_students_with_due_date_extensions(self): + from ..extensions import dump_students_with_due_date_extensions as fut + self.StudentModule.objects.filter.return_value = [ + mock.Mock( + student=mock.Mock( + username='fred', + first_name='Fred', + last_name='Flintstone'), + state=json.dumps({'extended_due': u'2013-10-12T10:30:00Z'})), + mock.Mock( + student=mock.Mock( + username='barney', + first_name='Barney', + last_name='Rubble'), + state=json.dumps({'extended_due': u'2013-10-13T10:30:00Z'})), + mock.Mock( + student=mock.Mock( + username='bambam', + first_name='Bam Bam', + last_name='Flintstone'), + state=json.dumps({}))] + error, table = fut(self.course, 'i4x://dummy/homework') + self.assertEqual(error, None) + self.assertEqual(table['header'], + ["Username", "Full Name", "Extended Due Date"]) + self.assertEqual(table['title'], + "Users with due date extensions for Homework 1") + self.assertEqual(table['data'], + [('barney', 'Barney Rubble', '2013-10-13 10:30'), + ('fred', 'Fred Flintstone', '2013-10-12 10:30')]) + + def test_dump_students_with_due_date_extensions_bad_url(self): + from ..extensions import dump_students_with_due_date_extensions as fut + error, table = fut(self.course, 'i4x://foo') + self.assertTrue(error.startswith("Couldn't find")) + self.assertEqual(table, {}) + + def test_dump_due_date_extensions_for_student(self): + from ..extensions import dump_due_date_extensions_for_student as fut + self.StudentModule.objects.filter.return_value = [ + mock.Mock( + module_state_key='i4x://dummy/homework', + state=json.dumps({})), + mock.Mock( + module_state_key='i4x://dummy/exam', + state=json.dumps({'extended_due': u'2013-10-13T10:30:00Z'}))] + error, table = fut(self.course, self.student) + self.assertEqual(error, None) + self.assertEqual(table['header'], ["Unit", "Extended Due Date"]) + self.assertEqual(table['title'], + "Due date extensions for Fred Flintstone (fred)") + self.assertEqual(table['data'], + [('Final Exam', '2013-10-13 10:30')]) + + +class DummyCourseNode(object): + id = 1 + children = [] + + def __init__(self, display_name, url, **kw): + self.display_name = display_name + self._url = url + self.__dict__.update(kw) + + @property + def location(self): + return self + + def url(self): + return self._url + + def get_children(self): + return self.children + From 04b70a589899afd38a587121a55aa9e08d7216e5 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Mon, 14 Oct 2013 15:34:49 -0400 Subject: [PATCH 13/32] Don't gather units with due dates unless due date extension is active. --- lms/djangoapps/instructor/views/legacy.py | 3 ++- lms/envs/dev.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index d80eb730e564..9bd04574dcc5 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -858,10 +858,11 @@ def get_analytics_result(analytics_name): 'instructor_tasks': instructor_tasks, 'offline_grade_log': offline_grades_available(course_id), 'cohorts_ajax_url': reverse('cohorts', kwargs={'course_id': course_id}), - 'units_with_due_dates': get_units_with_due_date_options(course), 'analytics_results': analytics_results, } + if settings.MITX_FEATURES.get('INDIVIDUAL_DUE_DATES'): + context['units_with_due_dates'] = get_units_with_due_date_options(course) if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BETA_DASHBOARD'): context['beta_dashboard_url'] = reverse('instructor_dashboard_2', kwargs={'course_id': course_id}) diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 622ff6acf7e2..5deca8628fbd 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -18,6 +18,7 @@ DEBUG = True TEMPLATE_DEBUG = True +MITX_FEATURES['INDIVIDUAL_DUE_DATES'] = True # crossi dev, delete if checked in MITX_FEATURES['DISABLE_START_DATES'] = True MITX_FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True From aff69709ce2bfb8889c0eeb6be6dc2e78e23fd8c Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 17 Oct 2013 09:29:48 -0400 Subject: [PATCH 14/32] Require user to actively choose a unit for due date extensions. User friendly error message if user fails to do so. --- lms/djangoapps/instructor/views/legacy.py | 19 +++++++++++++------ .../courseware/instructor_dashboard.html | 1 + 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 9bd04574dcc5..2240e7d582cf 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -500,6 +500,8 @@ def domatch(x): unique_student_identifier = request.POST.get( 'unique_student_identifier', '') url = request.POST.get('url') + if not url: + msg += 'Must choose a unit. ' # try to uniquely id student by email address or username message, student = get_student_from_identifier(unique_student_identifier) @@ -513,7 +515,7 @@ def domatch(x): error = set_due_date_extension( course, url, student, due_date) if error: - msg += '{0}'.format(error) + msg += '{0} '.format(error) log.debug(error) else: msg += 'Successfully changed due date.' @@ -523,6 +525,8 @@ def domatch(x): unique_student_identifier = request.POST.get( 'unique_student_identifier', '') url = request.POST.get('url') + if not url: + msg += 'Must choose a unit. ' # try to uniquely id student by email address or username message, student = get_student_from_identifier(unique_student_identifier) @@ -532,7 +536,7 @@ def domatch(x): error = set_due_date_extension( course, url, student, None) if error: - msg += '{0}'.format(error) + msg += '{0} '.format(error) log.debug(error) else: msg += 'Successfully reset due date.' @@ -540,9 +544,12 @@ def domatch(x): elif "Dump list of students with due date extensions" in action: url = request.POST.get('url') - error, datatable = dump_students_with_due_date_extensions(course, url) - if error: - msg += '{0}'.format(error) + if not url: + msg += 'Must choose a unit. ' + else: + error, datatable = dump_students_with_due_date_extensions(course, url) + if error: + msg += '{0} '.format(error) elif "Dump due date extensions for student" in action: unique_student_identifier = request.POST.get( @@ -554,7 +561,7 @@ def domatch(x): error, datatable = dump_due_date_extensions_for_student( course, student) if error: - msg += '{0}'.format(error) + msg += '{0} '.format(error) #---------------------------------------- # Admin diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index ba0b54d34f73..68acc689cae3 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -288,6 +288,7 @@

${_("Individual due date extensions")}

${_("Choose the graded unit:")} +

+

+ ${_("Choose the graded unit:")} + +

+

+ ${_("Specify the individual due date and time in UTC:")} + +

+

+

+

+ + +

+

+ + +

From 28a2bce8c868e0c3f2338edf0cda3a4fbe173cd6 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 25 Oct 2013 10:41:26 -0400 Subject: [PATCH 17/32] Show students with due date extensions. --- lms/djangoapps/instructor/views/api.py | 30 ++++++++- .../instructor_dashboard/extensions.coffee | 61 ++++++++++++++++++- .../instructor_dashboard_2/extensions.html | 9 ++- 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 0348b0ea77b6..32f00ba44db5 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -45,6 +45,7 @@ from bulk_email.models import CourseEmail from .extensions import ( + dump_students_with_due_date_extensions, set_due_date_extension) log = logging.getLogger(__name__) @@ -943,8 +944,25 @@ def change_due_date(request, course_id): @require_level('staff') @require_query_params('student', 'url') def reset_due_date(request, course_id): + course = get_course_by_id(course_id) student = get_student_from_identifier(request.GET.get('student')) - return HttpResponseBadRequest(json.dumps({'error': 'Not implemented'})) + url = request.GET.get('url') + error, unit = set_due_date_extension(course, url, student, None) + if error: + return HttpResponseBadRequest(json.dumps({'error': error})) + + studentname = '{0} {1} ({2})'.format( + student.first_name, student.last_name, student.username) + unitname = getattr(unit, 'display_name', None) + if unitname: + unitname = '{0} ({1})'.format(unitname, unit.location.url()) + due_date = unit.due + msg = ( + 'Successfully reset due date for student {0} for {1} ' + 'to {2}').format(studentname, unitname, + due_date.strftime('%Y-%m-%d %H:%M')) + + return JsonResponse(msg) @ensure_csrf_cookie @@ -952,7 +970,15 @@ def reset_due_date(request, course_id): @require_level('staff') @require_query_params('url') def show_unit_extensions(request, course_id): - return HttpResponseBadRequest(json.dumps({'error': 'Not implemented'})) + course = get_course_by_id(course_id) + url = request.GET.get('url') + error, data = dump_students_with_due_date_extensions(course, url) + if error: + return HttpResponseBadRequest(json.dumps({'error': error})) + data['header'] = header = [ + col.lower().replace(' ', '_') for col in data['header']] + data['data'] = [dict(zip(header, row)) for row in data['data']] + return JsonResponse(data) @ensure_csrf_cookie diff --git a/lms/static/coffee/src/instructor_dashboard/extensions.coffee b/lms/static/coffee/src/instructor_dashboard/extensions.coffee index bb6d147ec25d..eaca0e67dc14 100644 --- a/lms/static/coffee/src/instructor_dashboard/extensions.coffee +++ b/lms/static/coffee/src/instructor_dashboard/extensions.coffee @@ -35,8 +35,14 @@ class Extensions @$task_response.hide() @$task_error.hide() + # Gather grid elements + $grid_display = @$section.find '.data-display' + @$grid_text = $grid_display.find '.data-display-text' + @$grid_table = $grid_display.find '.data-display-table' + # Click handlers @$change_due_date.click => + @clear_display() send_data = student: @$student_input.val() url: @$url_input.val() @@ -48,11 +54,39 @@ class Extensions data: send_data success: (data) => @display_response data error: (xhr) => @fail_with_error "Error changing due date", xhr - + + @$reset_due_date.click => + @clear_display() + send_data = + student: @$student_input.val() + url: @$url_input.val() + + $.ajax + dataType: 'json' + url: @$reset_due_date.data 'endpoint' + data: send_data + success: (data) => @display_response data + error: (xhr) => @fail_with_error "Error reseting due date", xhr + + @$show_unit_extensions.click => + @clear_display() + @$grid_table.text 'Loading...' + + url = @$show_unit_extensions.data 'endpoint' + send_data = + url: @$url_input.val() + $.ajax + dataType: 'json' + url: url + data: send_data + error: (xhr) => @fail_with_error "Error getting due dates", xhr + success: (data) => @display_grid data + # handler for when the section title is clicked. onClickTitle: -> fail_with_error: (msg, xhr) -> + @clear_display() data = $.parseJSON xhr.responseText msg += ": " + data['error'] console.warn msg @@ -61,11 +95,32 @@ class Extensions @$task_error.text msg @$task_error.show() - display_response: (data_from_server) -> + display_response: (data) -> @$task_error.empty().hide() - @$task_response.empty().text data_from_server + @$task_response.empty().text data @$task_response.show() + display_grid: (data) -> + @clear_display() + + # display on a SlickGrid + options = + enableCellNavigation: true + enableColumnReorder: false + forceFitColumns: true + + columns = ({id: col, field: col, name: col} for col in data.header) + grid_data = data.data + + $table_placeholder = $ '
', class: 'slickgrid', style: 'min-height: 400px' + @$grid_table.append $table_placeholder + grid = new Slick.Grid($table_placeholder, grid_data, columns, options) + + clear_display: -> + @$grid_text.empty() + @$grid_table.empty() + @$task_error.empty().hide() + @$task_response.empty().hide() # export for use # create parent namespaces if they do not already exist. diff --git a/lms/templates/instructor/instructor_dashboard_2/extensions.html b/lms/templates/instructor/instructor_dashboard_2/extensions.html index 96a7533ee494..e01ca77a5a69 100644 --- a/lms/templates/instructor/instructor_dashboard_2/extensions.html +++ b/lms/templates/instructor/instructor_dashboard_2/extensions.html @@ -30,8 +30,13 @@

${_("Individual due date extensions")}

data-endpoint="${section_data['reset_due_date_url']}">

- -

+ +
+
+
+
From 7834d822233528c93e113d39fd6b5533017eed37 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 25 Oct 2013 11:04:03 -0400 Subject: [PATCH 18/32] Looks like they changed where the user's name is kept. --- lms/djangoapps/instructor/views/api.py | 9 +++------ lms/djangoapps/instructor/views/extensions.py | 2 +- lms/djangoapps/instructor/views/legacy.py | 6 ++---- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 32f00ba44db5..e1c09d5ae1e7 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -926,8 +926,7 @@ def change_due_date(request, course_id): if error: return HttpResponseBadRequest(json.dumps({'error': error})) - studentname = '{0} {1} ({2})'.format( - student.first_name, student.last_name, student.username) + studentname = student.profile.name unitname = getattr(unit, 'display_name', None) if unitname: unitname = '{0} ({1})'.format(unitname, unit.location.url()) @@ -951,8 +950,7 @@ def reset_due_date(request, course_id): if error: return HttpResponseBadRequest(json.dumps({'error': error})) - studentname = '{0} {1} ({2})'.format( - student.first_name, student.last_name, student.username) + studentname = student.profile.name unitname = getattr(unit, 'display_name', None) if unitname: unitname = '{0} ({1})'.format(unitname, unit.location.url()) @@ -975,8 +973,7 @@ def show_unit_extensions(request, course_id): error, data = dump_students_with_due_date_extensions(course, url) if error: return HttpResponseBadRequest(json.dumps({'error': error})) - data['header'] = header = [ - col.lower().replace(' ', '_') for col in data['header']] + header = data['header'] data['data'] = [dict(zip(header, row)) for row in data['data']] return JsonResponse(data) diff --git a/lms/djangoapps/instructor/views/extensions.py b/lms/djangoapps/instructor/views/extensions.py index fc531d259975..327180a292d4 100644 --- a/lms/djangoapps/instructor/views/extensions.py +++ b/lms/djangoapps/instructor/views/extensions.py @@ -92,7 +92,7 @@ def dump_students_with_due_date_extensions(course, url): continue extended_due = date_field.from_json(extended_due) extended_due = extended_due.strftime("%Y-%m-%d %H:%M") - fullname = '%s %s' % (sm.student.first_name, sm.student.last_name) + fullname = sm.student.profile.name data.append((sm.student.username, fullname, extended_due)) data.sort(key=lambda x: x[0]) return None, { diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index fe65731b22c8..1bf6f66dcbbe 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -541,8 +541,7 @@ def domatch(x): msg += '{0} '.format(error) log.debug(error) else: - studentname = '{0} {1} ({2})'.format( - student.first_name, student.last_name, student.username) + studentname = student.profile.name unitname = getattr(unit, 'display_name', None) if unitname: unitname = '{0} ({1})'.format(unitname, unit.location.url()) @@ -570,8 +569,7 @@ def domatch(x): msg += '{0} '.format(error) log.debug(error) else: - studentname = '{0} {1} ({2})'.format( - student.first_name, student.last_name, student.username) + studentname = student.profile.name unitname = getattr(unit, 'display_name', None) if unitname: unitname = '{0} ({1})'.format(unitname, unit.location.url()) From b95a991d83b53b9b0619251e5e20c33e53eb3242 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 25 Oct 2013 11:16:31 -0400 Subject: [PATCH 19/32] Show due date extensions for student. --- lms/djangoapps/instructor/views/api.py | 9 ++++++++- .../src/instructor_dashboard/extensions.coffee | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index e1c09d5ae1e7..5af743c59c81 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -45,6 +45,7 @@ from bulk_email.models import CourseEmail from .extensions import ( + dump_due_date_extensions_for_student, dump_students_with_due_date_extensions, set_due_date_extension) @@ -984,7 +985,13 @@ def show_unit_extensions(request, course_id): @require_query_params('student') def show_student_extensions(request, course_id): student = get_student_from_identifier(request.GET.get('student')) - return HttpResponseBadRequest(json.dumps({'error': 'Not implemented'})) + course = get_course_by_id(course_id) + error, data = dump_due_date_extensions_for_student(course, student) + if error: + return HttpResponseBadRequest(json.dumps({'error': error})) + header = data['header'] + data['data'] = [dict(zip(header, row)) for row in data['data']] + return JsonResponse(data) def _split_input_list(str_list): diff --git a/lms/static/coffee/src/instructor_dashboard/extensions.coffee b/lms/static/coffee/src/instructor_dashboard/extensions.coffee index eaca0e67dc14..4d59bc214f71 100644 --- a/lms/static/coffee/src/instructor_dashboard/extensions.coffee +++ b/lms/static/coffee/src/instructor_dashboard/extensions.coffee @@ -81,6 +81,20 @@ class Extensions data: send_data error: (xhr) => @fail_with_error "Error getting due dates", xhr success: (data) => @display_grid data + + @$show_student_extensions.click => + @clear_display() + @$grid_table.text 'Loading...' + + url = @$show_student_extensions.data 'endpoint' + send_data = + student: @$student_input.val() + $.ajax + dataType: 'json' + url: url + data: send_data + error: (xhr) => @fail_with_error "Error getting due dates", xhr + success: (data) => @display_grid data # handler for when the section title is clicked. onClickTitle: -> @@ -102,6 +116,7 @@ class Extensions display_grid: (data) -> @clear_display() + @$grid_text.text data.title # display on a SlickGrid options = From 53325df3359d2204bc6922ab3a68c7ce969f8f4b Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Mon, 9 Dec 2013 15:46:34 -0500 Subject: [PATCH 20/32] MITX_FEATURES has been renamed just FEATURES. --- lms/djangoapps/instructor/views/instructor_dashboard.py | 2 +- lms/envs/dev.py | 2 +- lms/templates/courseware/instructor_dashboard.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 1fc9d05970d8..06e44185af79 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -58,7 +58,7 @@ def instructor_dashboard_2(request, course_id): _section_analytics(course_id, access), ] - if (settings.MITX_FEATURES.get('INDIVIDUAL_DUE_DATES') + if (settings.FEATURES.get('INDIVIDUAL_DUE_DATES') and access['instructor']): sections.insert(3, _section_extensions(course)) diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 586822750193..92241825d45b 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -23,7 +23,7 @@ ) TEMPLATE_DEBUG = True -MITX_FEATURES['INDIVIDUAL_DUE_DATES'] = True # crossi dev, delete if checked in +FEATURES['INDIVIDUAL_DUE_DATES'] = True # crossi dev, delete if checked in FEATURES['DISABLE_START_DATES'] = False FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 4285cc10efc0..d35fbe9d83f3 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -320,7 +320,7 @@

${_("Student-specific grade inspection and adjustment")}

##----------------------------------------------------------------------------- %if modeflag.get('Extensions'): - %if settings.MITX_FEATURES.get('INDIVIDUAL_DUE_DATES'): + %if settings.FEATURES.get('INDIVIDUAL_DUE_DATES'):

${_("Individual due date extensions")}

From f5eb670af1444815aed46e77071414691cdb2797 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Mon, 9 Dec 2013 16:54:36 -0500 Subject: [PATCH 21/32] Fix test. --- lms/djangoapps/instructor/views/tests/test_extensions.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/instructor/views/tests/test_extensions.py b/lms/djangoapps/instructor/views/tests/test_extensions.py index 85744a3594da..43e1ba9cd18b 100644 --- a/lms/djangoapps/instructor/views/tests/test_extensions.py +++ b/lms/djangoapps/instructor/views/tests/test_extensions.py @@ -52,18 +52,19 @@ def test_set_due_date_extension_bad_url(self): def test_dump_students_with_due_date_extensions(self): from ..extensions import dump_students_with_due_date_extensions as fut + class DummyProfile(object): + def __init__(self, name): + self.name = name self.StudentModule.objects.filter.return_value = [ mock.Mock( student=mock.Mock( username='fred', - first_name='Fred', - last_name='Flintstone'), + profile=DummyProfile('Fred Flintstone')), state=json.dumps({'extended_due': u'2013-10-12T10:30:00Z'})), mock.Mock( student=mock.Mock( username='barney', - first_name='Barney', - last_name='Rubble'), + profile=DummyProfile('Barney Rubble')), state=json.dumps({'extended_due': u'2013-10-13T10:30:00Z'})), mock.Mock( student=mock.Mock( From 97c562bf0bc189a27c088e15d912d4276e841e9d Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Wed, 11 Dec 2013 15:19:38 -0500 Subject: [PATCH 22/32] Pep8 --- lms/djangoapps/instructor/views/api.py | 3 ++- lms/djangoapps/instructor/views/extensions.py | 5 +++-- .../instructor/views/instructor_dashboard.py | 3 +-- .../instructor/views/tests/test_extensions.py | 11 ++++++----- lms/envs/dev.py | 2 -- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index f342ec69a705..dad9b643f764 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1013,6 +1013,7 @@ def parse_datetime(s): error = _("Unable to parse date: ") + s return HttpResponseBadRequest(json.dumps({'error': error})) + @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_level('staff') @@ -1023,7 +1024,7 @@ def change_due_date(request, course_id): url = request.GET.get('url') due_date = parse_datetime(request.GET.get('due_datetime')) if isinstance(due_date, HttpResponse): - return due_date # error + return due_date # error error, unit = set_due_date_extension(course, url, student, due_date) if error: return HttpResponseBadRequest(json.dumps({'error': error})) diff --git a/lms/djangoapps/instructor/views/extensions.py b/lms/djangoapps/instructor/views/extensions.py index 327180a292d4..d54cc549eb6a 100644 --- a/lms/djangoapps/instructor/views/extensions.py +++ b/lms/djangoapps/instructor/views/extensions.py @@ -34,7 +34,7 @@ def set_due_date(node): set_due_date(unit) - return None, unit # no error + return None, unit # no error def find_unit(node, url): @@ -52,12 +52,13 @@ def find_unit(node, url): def get_units_with_due_date(course): units = [] + def visit(node, level=0): if getattr(node, 'due', None): units.append(node) else: for child in node.get_children(): - visit(child, level+1) + visit(child, level + 1) visit(course) units.sort(key=title_or_url) return units diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 06e44185af79..02313719493f 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -58,8 +58,7 @@ def instructor_dashboard_2(request, course_id): _section_analytics(course_id, access), ] - if (settings.FEATURES.get('INDIVIDUAL_DUE_DATES') - and access['instructor']): + if (settings.FEATURES.get('INDIVIDUAL_DUE_DATES') and access['instructor']): sections.insert(3, _section_extensions(course)) # Gate access to course email by feature flag & by course-specific authorization diff --git a/lms/djangoapps/instructor/views/tests/test_extensions.py b/lms/djangoapps/instructor/views/tests/test_extensions.py index 43e1ba9cd18b..ed473dbc7bbb 100644 --- a/lms/djangoapps/instructor/views/tests/test_extensions.py +++ b/lms/djangoapps/instructor/views/tests/test_extensions.py @@ -13,7 +13,7 @@ class ExtensionsTests(unittest.TestCase): def setUp(self): self.course = DummyCourseNode( 'Dummy Course', 'i4x://dummy', - children = [ + children=[ DummyCourseNode( 'Homework 1', 'i4x://dummy/homework', due=due_date, @@ -52,6 +52,7 @@ def test_set_due_date_extension_bad_url(self): def test_dump_students_with_due_date_extensions(self): from ..extensions import dump_students_with_due_date_extensions as fut + class DummyProfile(object): def __init__(self, name): self.name = name @@ -78,9 +79,10 @@ def __init__(self, name): ["Username", "Full Name", "Extended Due Date"]) self.assertEqual(table['title'], "Users with due date extensions for Homework 1") - self.assertEqual(table['data'], - [('barney', 'Barney Rubble', '2013-10-13 10:30'), - ('fred', 'Fred Flintstone', '2013-10-12 10:30')]) + self.assertEqual( + table['data'], + [('barney', 'Barney Rubble', '2013-10-13 10:30'), + ('fred', 'Fred Flintstone', '2013-10-12 10:30')]) def test_dump_students_with_due_date_extensions_bad_url(self): from ..extensions import dump_students_with_due_date_extensions as fut @@ -124,4 +126,3 @@ def url(self): def get_children(self): return self.children - diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 92241825d45b..99fc25a2c84b 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -23,8 +23,6 @@ ) TEMPLATE_DEBUG = True -FEATURES['INDIVIDUAL_DUE_DATES'] = True # crossi dev, delete if checked in - FEATURES['DISABLE_START_DATES'] = False FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False # Enable to test subdomains--otherwise, want all courses to show up From 8e9138c43f6759f8995e61aa22dee616bc529ef7 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Wed, 11 Dec 2013 17:28:06 -0500 Subject: [PATCH 23/32] Pyling --- common/lib/xmodule/xmodule/utils.py | 8 ++ lms/djangoapps/instructor/views/api.py | 38 +++++--- lms/djangoapps/instructor/views/extensions.py | 96 +++++++++++++------ lms/djangoapps/instructor/views/legacy.py | 31 +++--- .../instructor/views/tests/test_extensions.py | 70 ++++++++++---- 5 files changed, 164 insertions(+), 79 deletions(-) diff --git a/common/lib/xmodule/xmodule/utils.py b/common/lib/xmodule/xmodule/utils.py index 92e4a0d14d3c..9487e80a97c8 100644 --- a/common/lib/xmodule/xmodule/utils.py +++ b/common/lib/xmodule/xmodule/utils.py @@ -1,6 +1,14 @@ +""" +Miscellaneous utility functions. +""" def get_extended_due_date(node): + """ + Gets the actual due date for the logged in student for this node, returning + the extendeded due date if one has been granted, otherwise returning the + global due date for the unit. + """ due_date = getattr(node, 'extended_due', None) if not due_date: due_date = getattr(node, 'due', None) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index dad9b643f764..69a3f3868ec2 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -47,8 +47,8 @@ from bulk_email.models import CourseEmail from .extensions import ( - dump_due_date_extensions_for_student, - dump_students_with_due_date_extensions, + dump_student_extensions, + dump_module_extensions, set_due_date_extension) log = logging.getLogger(__name__) @@ -998,19 +998,18 @@ def proxy_legacy_analytics(request, course_id): ) -def parse_datetime(s): +def parse_datetime(timestamp): """ Constructs a datetime object in UTC from user input. """ - dt = None try: - d, t = s.split() - mm, dd, yy = map(int, d.split('/')) - h, m = map(int, t.split(':')) - dt = datetime.datetime(yy, mm, dd, h, m, tzinfo=timezone.utc) - return dt + date, time = timestamp.split() + month, day, year = map(int, date.split('/')) + hour, minute = map(int, time.split(':')) + return datetime.datetime(year, month, day, hour, minute, + tzinfo=timezone.utc) except: - error = _("Unable to parse date: ") + s + error = _("Unable to parse date: ") + timestamp return HttpResponseBadRequest(json.dumps({'error': error})) @@ -1019,6 +1018,9 @@ def parse_datetime(s): @require_level('staff') @require_query_params('student', 'url', 'due_datetime') def change_due_date(request, course_id): + """ + Grants a due date extension to a student for a particular unit. + """ course = get_course_by_id(course_id) student = get_student_from_identifier(request.GET.get('student')) url = request.GET.get('url') @@ -1046,6 +1048,9 @@ def change_due_date(request, course_id): @require_level('staff') @require_query_params('student', 'url') def reset_due_date(request, course_id): + """ + Rescinds a due date extension for a student on a particular unit. + """ course = get_course_by_id(course_id) student = get_student_from_identifier(request.GET.get('student')) url = request.GET.get('url') @@ -1071,9 +1076,12 @@ def reset_due_date(request, course_id): @require_level('staff') @require_query_params('url') def show_unit_extensions(request, course_id): + """ + Shows all of the students which have due date extensions for the given unit. + """ course = get_course_by_id(course_id) url = request.GET.get('url') - error, data = dump_students_with_due_date_extensions(course, url) + error, data = dump_module_extensions(course, url) if error: return HttpResponseBadRequest(json.dumps({'error': error})) header = data['header'] @@ -1086,11 +1094,13 @@ def show_unit_extensions(request, course_id): @require_level('staff') @require_query_params('student') def show_student_extensions(request, course_id): + """ + Shows all of the due date extensions granted to a particular student in a + particular course. + """ student = get_student_from_identifier(request.GET.get('student')) course = get_course_by_id(course_id) - error, data = dump_due_date_extensions_for_student(course, student) - if error: - return HttpResponseBadRequest(json.dumps({'error': error})) + data = dump_student_extensions(course, student) header = data['header'] data['data'] = [dict(zip(header, row)) for row in data['data']] return JsonResponse(data) diff --git a/lms/djangoapps/instructor/views/extensions.py b/lms/djangoapps/instructor/views/extensions.py index d54cc549eb6a..340b05d4e4bf 100644 --- a/lms/djangoapps/instructor/views/extensions.py +++ b/lms/djangoapps/instructor/views/extensions.py @@ -1,8 +1,13 @@ +""" +Common library for working with individual due date extensions that can be used +by either the legacy or the beta instructor dashboard. +""" + import json from courseware.models import StudentModule from xmodule.fields import Date -date_field = Date() +DATE_FIELD = Date() def set_due_date_extension(course, url, student, due_date): @@ -10,11 +15,14 @@ def set_due_date_extension(course, url, student, due_date): Sets a due date extension. Factored to be usable in both legacy and beta instructor dashboards. """ - unit = find_unit(course, url) + unit = _find_unit(course, url) if not unit: return "Couldn't find module for url: {0}".format(url), None def set_due_date(node): + """ + Recursively set the due date on a node and all of its children. + """ try: student_module = StudentModule.objects.get( student_id=student.id, @@ -23,7 +31,7 @@ def set_due_date(node): ) state = json.loads(student_module.state) - state['extended_due'] = date_field.to_json(due_date) + state['extended_due'] = DATE_FIELD.to_json(due_date) student_module.state = json.dumps(state) student_module.save() except StudentModule.DoesNotExist: @@ -37,48 +45,75 @@ def set_due_date(node): return None, unit # no error -def find_unit(node, url): +def _find_unit(node, url): """ Find node in course tree for url. """ if node.location.url() == url: return node for child in node.get_children(): - found = find_unit(child, url) + found = _find_unit(child, url) if found: return found return None -def get_units_with_due_date(course): +def _get_units_with_due_date(course): + """ + Returns all top level units which have due dates. Does not return + descendents of those nodes. + """ units = [] - def visit(node, level=0): + def visit(node): + """ + Visit a node. Checks to see if node has a due date and appends to + `units` if it does. Otherwise recurses into children to search for + nodes with due dates. + """ if getattr(node, 'due', None): units.append(node) else: for child in node.get_children(): - visit(child, level + 1) + visit(child) visit(course) - units.sort(key=title_or_url) + units.sort(key=_title_or_url) return units def get_units_with_due_date_options(course): + """ + Finds all top level units that have a due date and returns them as a + sequence of (title, url) tuples suitable for populating the pull down to + select a unit in the 'Extensions' tab. + """ def make_option(node): - return title_or_url(node), node.location.url() - return map(make_option, get_units_with_due_date(course)) + "Returns (title, url) tuple for a node." + return _title_or_url(node), node.location.url() + return map(make_option, _get_units_with_due_date(course)) -def title_or_url(node): +def _title_or_url(node): + """ + Returns the `display_name` attribute of the passed in node of the course + tree, if it has one. Otherwise returns the node's url. + """ title = getattr(node, 'display_name', None) if not title: title = node.location.url() return title -def dump_students_with_due_date_extensions(course, url): - unit = find_unit(course, url) +def dump_module_extensions(course, url): + """ + Dumps data about students with due date extensions for a particular module, + specified by 'url', in a particular course. Returns a tuple of (error, + data). If there is an error, `error` will be a strong suitable for + displaying to the user and `data` will be None. Otherwise `error` will be + None, and `data` will be a data structure formatted for use by the legacy + instructor dashboard's 'datatable'. + """ + unit = _find_unit(course, url) if not unit: return "Couldn't find module for url: {0}".format(url), {} @@ -86,43 +121,48 @@ def dump_students_with_due_date_extensions(course, url): query = StudentModule.objects.filter( course_id=course.id, module_state_key=url) - for sm in query: - state = json.loads(sm.state) + for module in query: + state = json.loads(module.state) extended_due = state.get("extended_due") if not extended_due: continue - extended_due = date_field.from_json(extended_due) + extended_due = DATE_FIELD.from_json(extended_due) extended_due = extended_due.strftime("%Y-%m-%d %H:%M") - fullname = sm.student.profile.name - data.append((sm.student.username, fullname, extended_due)) + fullname = module.student.profile.name + data.append((module.student.username, fullname, extended_due)) data.sort(key=lambda x: x[0]) return None, { "header": ["Username", "Full Name", "Extended Due Date"], "title": "Users with due date extensions for {0}".format( - title_or_url(unit)), + _title_or_url(unit)), "data": data } -def dump_due_date_extensions_for_student(course, student): +def dump_student_extensions(course, student): + """ + Dumps data about the due date extensions granted for a particular student + in a particular course. Returns a data structure formatted for use by the + legacy instructor dashboard's 'datatable'. + """ data = [] - units = get_units_with_due_date(course) + units = _get_units_with_due_date(course) units = dict([(u.location.url(), u) for u in units]) query = StudentModule.objects.filter( course_id=course.id, student_id=student.id) - for sm in query: - state = json.loads(sm.state) - if sm.module_state_key not in units: + for module in query: + state = json.loads(module.state) + if module.module_state_key not in units: continue extended_due = state.get("extended_due") if not extended_due: continue - extended_due = date_field.from_json(extended_due) + extended_due = DATE_FIELD.from_json(extended_due) extended_due = extended_due.strftime("%Y-%m-%d %H:%M") - title = title_or_url(units[sm.module_state_key]) + title = _title_or_url(units[module.module_state_key]) data.append((title, extended_due)) - return None, { + return { "header": ["Unit", "Extended Due Date"], "title": "Due date extensions for {0} {1} ({2})".format( student.first_name, student.last_name, student.username), diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 6afade0610aa..a2688997d8e6 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -65,8 +65,8 @@ from lms.lib.xblock.runtime import handler_prefix from .extensions import ( - dump_students_with_due_date_extensions, - dump_due_date_extensions_for_student, + dump_module_extensions, + dump_student_extensions, get_units_with_due_date_options, set_due_date_extension) @@ -189,21 +189,22 @@ def get_student_from_identifier(unique_student_identifier): msg += "Couldn't find student with that email or username. " return msg, student - def parse_datetime(s): + def parse_datetime(datestr): """ Constructs a datetime object in UTC from user input. """ msg = "" - dt = None + timestamp = None try: - d, t = s.split() - mm, dd, yy = map(int, d.split('/')) - h, m = map(int, t.split(':')) - dt = datetime.datetime(yy, mm, dd, h, m, tzinfo=timezone.utc) - return msg, dt - except: - msg = "Unable to parse date: {0} ".format(s) - return msg, dt + date, time = datestr.split() + month, day, year = map(int, date.split('/')) + hour, minute = map(int, time.split(':')) + timestamp = datetime.datetime(year, month, day, hour, minute, + tzinfo=timezone.utc) + except ValueError: + msg = "Unable to parse date: {0} ".format( + datestr) + return msg, timestamp # process actions from form POST action = request.POST.get('action', '') @@ -562,7 +563,7 @@ def domatch(x): if not url: msg += 'Must choose a unit. ' else: - error, datatable = dump_students_with_due_date_extensions(course, url) + error, datatable = dump_module_extensions(course, url) if error: msg += '{0} '.format(error) @@ -573,10 +574,8 @@ def domatch(x): msg += message if student: - error, datatable = dump_due_date_extensions_for_student( + datatable = dump_student_extensions( course, student) - if error: - msg += '{0} '.format(error) #---------------------------------------- # Admin diff --git a/lms/djangoapps/instructor/views/tests/test_extensions.py b/lms/djangoapps/instructor/views/tests/test_extensions.py index ed473dbc7bbb..47ab21e6d0d9 100644 --- a/lms/djangoapps/instructor/views/tests/test_extensions.py +++ b/lms/djangoapps/instructor/views/tests/test_extensions.py @@ -1,62 +1,82 @@ +""" +Test due date extensions library. +""" + import datetime import json import mock import unittest -due_date = datetime.datetime(2010, 5, 12, 2, 42) -extended_due_date = datetime.datetime(2013, 10, 12, 10, 30) +DUE_DATE = datetime.datetime(2010, 5, 12, 2, 42) +EXTENDED_DUE_DATE = datetime.datetime(2013, 10, 12, 10, 30) class ExtensionsTests(unittest.TestCase): + """ + Test due date extensions library. + """ def setUp(self): + """ + Set up test fixtures. + """ self.course = DummyCourseNode( 'Dummy Course', 'i4x://dummy', children=[ DummyCourseNode( 'Homework 1', 'i4x://dummy/homework', - due=due_date, + due=DUE_DATE, children=[ DummyCourseNode( 'Problem 1', 'i4x://dummy/homework/problem')]), DummyCourseNode( 'Final Exam', 'i4x://dummy/exam', - due=due_date, + due=DUE_DATE, children=[ DummyCourseNode( 'Problem 2', 'i4x://dummy/exam/problem')])]) self.homework, self.exam = self.course.children patcher = mock.patch('instructor.views.extensions.StudentModule') - self.StudentModule = patcher.start() + self.student_module = patcher.start() self.addCleanup(patcher.stop) self.student = mock.Mock(username='fred', first_name='Fred', last_name='Flintstone') def test_set_due_date_extension_success(self): + """ + Test setting a due date extension. + """ from ..extensions import set_due_date_extension as fut - self.StudentModule.objects.get.return_value.state = json.dumps({}) - error, unit = fut(self.course, 'i4x://dummy/homework', self.student, - extended_due_date) + self.student_module.objects.get.return_value.state = json.dumps({}) + error, _ = fut(self.course, 'i4x://dummy/homework', self.student, + EXTENDED_DUE_DATE) self.assertEqual(error, None) - state = json.loads(self.StudentModule.objects.get.return_value.state) + state = json.loads(self.student_module.objects.get.return_value.state) self.assertEqual(state['extended_due'], u'2013-10-12T10:30:00Z') def test_set_due_date_extension_bad_url(self): + """ + Test attempt to set due date extension with bad url. + """ from ..extensions import set_due_date_extension as fut - error, unit = fut(self.course, 'i4x://foo', self.student, - extended_due_date) + error, _ = fut(self.course, 'i4x://foo', self.student, + EXTENDED_DUE_DATE) self.assertTrue(error.startswith("Couldn't find")) - def test_dump_students_with_due_date_extensions(self): - from ..extensions import dump_students_with_due_date_extensions as fut + def test_dump_module_extensions(self): + """ + Test dump of students with due date extensions. + """ + from ..extensions import dump_module_extensions as fut class DummyProfile(object): + "Mock Profile" def __init__(self, name): self.name = name - self.StudentModule.objects.filter.return_value = [ + self.student_module.objects.filter.return_value = [ mock.Mock( student=mock.Mock( username='fred', @@ -84,23 +104,28 @@ def __init__(self, name): [('barney', 'Barney Rubble', '2013-10-13 10:30'), ('fred', 'Fred Flintstone', '2013-10-12 10:30')]) - def test_dump_students_with_due_date_extensions_bad_url(self): - from ..extensions import dump_students_with_due_date_extensions as fut + def test_dump_module_extensions_bad_url(self): + """ + Test attempt to dump students with due date extenions with bad url. + """ + from ..extensions import dump_module_extensions as fut error, table = fut(self.course, 'i4x://foo') self.assertTrue(error.startswith("Couldn't find")) self.assertEqual(table, {}) - def test_dump_due_date_extensions_for_student(self): - from ..extensions import dump_due_date_extensions_for_student as fut - self.StudentModule.objects.filter.return_value = [ + def test_dump_student_extensions(self): + """ + Test dump due date extensions for student. + """ + from ..extensions import dump_student_extensions as fut + self.student_module.objects.filter.return_value = [ mock.Mock( module_state_key='i4x://dummy/homework', state=json.dumps({})), mock.Mock( module_state_key='i4x://dummy/exam', state=json.dumps({'extended_due': u'2013-10-13T10:30:00Z'}))] - error, table = fut(self.course, self.student) - self.assertEqual(error, None) + table = fut(self.course, self.student) self.assertEqual(table['header'], ["Unit", "Extended Due Date"]) self.assertEqual(table['title'], "Due date extensions for Fred Flintstone (fred)") @@ -109,6 +134,9 @@ def test_dump_due_date_extensions_for_student(self): class DummyCourseNode(object): + """ + Mock of a CourseNode. + """ id = 1 children = [] From 4dc3a5ce2bf8f0f69e921b42892004f933629776 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Wed, 11 Dec 2013 17:59:38 -0500 Subject: [PATCH 24/32] Make sure all new code is i18n. Note that I did not make the new legacy dashboard code i18n, since the existing legacy dashboard code is not i18n. --- lms/djangoapps/instructor/views/api.py | 4 ++-- lms/djangoapps/instructor/views/extensions.py | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 69a3f3868ec2..ccd8dc38fa7c 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1035,7 +1035,7 @@ def change_due_date(request, course_id): unitname = getattr(unit, 'display_name', None) if unitname: unitname = '{0} ({1})'.format(unitname, unit.location.url()) - msg = ( + msg = _( 'Successfully changed due date for student {0} for {1} ' 'to {2}').format(studentname, unitname, due_date.strftime('%Y-%m-%d %H:%M')) @@ -1063,7 +1063,7 @@ def reset_due_date(request, course_id): if unitname: unitname = '{0} ({1})'.format(unitname, unit.location.url()) due_date = unit.due - msg = ( + msg = _( 'Successfully reset due date for student {0} for {1} ' 'to {2}').format(studentname, unitname, due_date.strftime('%Y-%m-%d %H:%M')) diff --git a/lms/djangoapps/instructor/views/extensions.py b/lms/djangoapps/instructor/views/extensions.py index 340b05d4e4bf..70ee4c0db1d9 100644 --- a/lms/djangoapps/instructor/views/extensions.py +++ b/lms/djangoapps/instructor/views/extensions.py @@ -5,6 +5,7 @@ import json from courseware.models import StudentModule +from django.utils.translation import ugettext as _ from xmodule.fields import Date DATE_FIELD = Date() @@ -17,7 +18,7 @@ def set_due_date_extension(course, url, student, due_date): """ unit = _find_unit(course, url) if not unit: - return "Couldn't find module for url: {0}".format(url), None + return _("Couldn't find module for url: {0}").format(url), None def set_due_date(node): """ @@ -115,7 +116,7 @@ def dump_module_extensions(course, url): """ unit = _find_unit(course, url) if not unit: - return "Couldn't find module for url: {0}".format(url), {} + return _("Couldn't find module for url: {0}").format(url), {} data = [] query = StudentModule.objects.filter( @@ -132,8 +133,8 @@ def dump_module_extensions(course, url): data.append((module.student.username, fullname, extended_due)) data.sort(key=lambda x: x[0]) return None, { - "header": ["Username", "Full Name", "Extended Due Date"], - "title": "Users with due date extensions for {0}".format( + "header": [_("Username"), _("Full Name"), _("Extended Due Date")], + "title": _("Users with due date extensions for {0}").format( _title_or_url(unit)), "data": data } @@ -163,7 +164,7 @@ def dump_student_extensions(course, student): title = _title_or_url(units[module.module_state_key]) data.append((title, extended_due)) return { - "header": ["Unit", "Extended Due Date"], - "title": "Due date extensions for {0} {1} ({2})".format( + "header": [_("Unit"), _("Extended Due Date")], + "title": _("Due date extensions for {0} {1} ({2})").format( student.first_name, student.last_name, student.username), "data": data} From 9d1998625a047a8953547d727d0a08bd6a5a5ffa Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Wed, 18 Dec 2013 10:52:40 -0500 Subject: [PATCH 25/32] Use dateutil. --- lms/djangoapps/instructor/views/api.py | 28 +++++++---------------- lms/djangoapps/instructor/views/legacy.py | 28 +++++++---------------- 2 files changed, 16 insertions(+), 40 deletions(-) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index ccd8dc38fa7c..5b6505eb2c6b 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -6,16 +6,16 @@ Many of these GETs may become PUTs in the future. """ -import datetime import json import logging import re import requests +from dateutil.parser import parse as parse_datetime from django.conf import settings from django_future.csrf import ensure_csrf_cookie from django.views.decorators.cache import cache_control from django.core.urlresolvers import reverse -from django.utils import timezone +from django.utils.timezone import utc from django.utils.translation import ugettext as _ from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from util.json_request import JsonResponse @@ -998,21 +998,6 @@ def proxy_legacy_analytics(request, course_id): ) -def parse_datetime(timestamp): - """ - Constructs a datetime object in UTC from user input. - """ - try: - date, time = timestamp.split() - month, day, year = map(int, date.split('/')) - hour, minute = map(int, time.split(':')) - return datetime.datetime(year, month, day, hour, minute, - tzinfo=timezone.utc) - except: - error = _("Unable to parse date: ") + timestamp - return HttpResponseBadRequest(json.dumps({'error': error})) - - @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_level('staff') @@ -1024,9 +1009,12 @@ def change_due_date(request, course_id): course = get_course_by_id(course_id) student = get_student_from_identifier(request.GET.get('student')) url = request.GET.get('url') - due_date = parse_datetime(request.GET.get('due_datetime')) - if isinstance(due_date, HttpResponse): - return due_date # error + datestr = request.GET.get('due_datetime') + try: + due_date = parse_datetime(datestr).replace(tzinfo=utc) + except ValueError: + error = _("Unable to parse date: ") + datestr + return HttpResponseBadRequest(json.dumps({'error': error})) error, unit = set_due_date_extension(course, url, student, due_date) if error: return HttpResponseBadRequest(json.dumps({'error': error})) diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index a2688997d8e6..7b873639c58a 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -2,7 +2,6 @@ Instructor Views """ import csv -import datetime import json import logging import os @@ -10,6 +9,7 @@ import requests from collections import defaultdict, OrderedDict +from dateutil.parser import parse as parse_datetime from functools import partial from markupsafe import escape from requests.status_codes import codes @@ -189,23 +189,6 @@ def get_student_from_identifier(unique_student_identifier): msg += "Couldn't find student with that email or username. " return msg, student - def parse_datetime(datestr): - """ - Constructs a datetime object in UTC from user input. - """ - msg = "" - timestamp = None - try: - date, time = datestr.split() - month, day, year = map(int, date.split('/')) - hour, minute = map(int, time.split(':')) - timestamp = datetime.datetime(year, month, day, hour, minute, - tzinfo=timezone.utc) - except ValueError: - msg = "Unable to parse date: {0} ".format( - datestr) - return msg, timestamp - # process actions from form POST action = request.POST.get('action', '') use_offline = request.POST.get('use_offline_grades', False) @@ -511,8 +494,13 @@ def domatch(x): msg += message # parse datetime - message, due_date = parse_datetime(request.POST.get('due_datetime')) - msg += message + datestr = request.POST.get('due_datetime') + try: + due_date = parse_datetime(datestr).replace(tzinfo=timezone.utc) + except ValueError: + due_date = None + msg += "Unable to parse date: {0} ".format( + datestr) if url and student and due_date: error, unit = set_due_date_extension( From 1f00bf55110bf61c6b36023544674f7f5cf6e0f6 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Wed, 18 Dec 2013 11:05:48 -0500 Subject: [PATCH 26/32] Use global due date if it is later than the individual 'extension' due date. --- common/lib/xmodule/xmodule/capa_module.py | 5 +++-- .../xmodule/xmodule/combined_open_ended_module.py | 5 +++-- common/lib/xmodule/xmodule/foldit_module.py | 5 +++-- .../xmodule/xmodule/modulestore/inheritance.py | 5 +++-- common/lib/xmodule/xmodule/peer_grading_module.py | 5 +++-- common/lib/xmodule/xmodule/seq_module.py | 5 +++-- common/lib/xmodule/xmodule/utils.py | 15 +++++++++------ 7 files changed, 27 insertions(+), 18 deletions(-) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index df843225a151..7a7103e60893 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -99,8 +99,9 @@ class CapaFields(object): due = Date(help="Date that this problem is due by", scope=Scope.settings) extended_due = Date( help="Date that this problem is due by for a particular student. This " - "may differ from the global due date if an instructor has granted " - "an extension to the student.", + "can be set by an instructor, and will override the global due " + "date if it is set to a date that is later than the global due " + "date.", default=None, scope=Scope.user_state, ) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index c78e4cd8f46b..ea49ec8bfd22 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -265,8 +265,9 @@ class CombinedOpenEndedFields(object): ) extended_due = Date( help="Date that this problem is due by for a particular student. This " - "may differ from the global due date if an instructor has granted " - "an extension to the student.", + "can be set by an instructor, and will override the global due " + "date if it is set to a date that is later than the global due " + "date.", default=None, scope=Scope.user_state, ) diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index 6d5c83466b8b..d5d771f838b1 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -23,8 +23,9 @@ class FolditFields(object): due = Date(help="Date that this problem is due by", scope=Scope.settings) extended_due = Date( help="Date that this problem is due by for a particular student. This " - "may differ from the global due date if an instructor has granted " - "an extension to the student.", + "can be set by an instructor, and will override the global due " + "date if it is set to a date that is later than the global due " + "date.", default=None, scope=Scope.user_state, ) diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index a7793f05a506..e413c061a749 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -23,8 +23,9 @@ class InheritanceMixin(XBlockMixin): due = Date(help="Date that this problem is due by", scope=Scope.settings) extended_due = Date( help="Date that this problem is due by for a particular student. This " - "may differ from the global due date if an instructor has granted " - "an extension to the student.", + "can be set by an instructor, and will override the global due " + "date if it is set to a date that is later than the global due " + "date.", default=None, scope=Scope.user_state, ) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index e65d28a26739..2de306f10c7d 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -49,8 +49,9 @@ class PeerGradingFields(object): scope=Scope.settings) extended_due = Date( help="Date that this problem is due by for a particular student. This " - "may differ from the global due date if an instructor has granted " - "an extension to the student.", + "can be set by an instructor, and will override the global due " + "date if it is set to a date that is later than the global due " + "date.", default=None, scope=Scope.user_state, ) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index 95fa653772be..ded26c598214 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -30,8 +30,9 @@ class SequenceFields(object): due = Date(help="Date that this problem is due by", scope=Scope.settings) extended_due = Date( help="Date that this problem is due by for a particular student. This " - "may differ from the global due date if an instructor has granted " - "an extension to the student.", + "can be set by an instructor, and will override the global due " + "date if it is set to a date that is later than the global due " + "date.", default=None, scope=Scope.user_state, ) diff --git a/common/lib/xmodule/xmodule/utils.py b/common/lib/xmodule/xmodule/utils.py index 9487e80a97c8..2af303977584 100644 --- a/common/lib/xmodule/xmodule/utils.py +++ b/common/lib/xmodule/xmodule/utils.py @@ -6,10 +6,13 @@ def get_extended_due_date(node): """ Gets the actual due date for the logged in student for this node, returning - the extendeded due date if one has been granted, otherwise returning the - global due date for the unit. + the extendeded due date if one has been granted and it is later than the + global due date, otherwise returning the global due date for the unit. """ - due_date = getattr(node, 'extended_due', None) - if not due_date: - due_date = getattr(node, 'due', None) - return due_date + extended = getattr(node, 'extended_due', None) + due_date = getattr(node, 'due', None) + if not extended and not due_date: + return None + elif not extended or extended < due_date: + return due_date + return extended From 757267c2eb5922fefc154dd5081722048bb0e139 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 26 Dec 2013 13:40:20 -0500 Subject: [PATCH 27/32] Revert changes to legacy dashboard. IDDE feature will only be available in beta dashboard. --- lms/djangoapps/instructor/views/legacy.py | 97 ----------------------- 1 file changed, 97 deletions(-) diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 7b873639c58a..ef6fee897d54 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -9,7 +9,6 @@ import requests from collections import defaultdict, OrderedDict -from dateutil.parser import parse as parse_datetime from functools import partial from markupsafe import escape from requests.status_codes import codes @@ -64,13 +63,6 @@ from django.utils.translation import ugettext as _u from lms.lib.xblock.runtime import handler_prefix -from .extensions import ( - dump_module_extensions, - dump_student_extensions, - get_units_with_due_date_options, - set_due_date_extension) - - log = logging.getLogger(__name__) # internal commands for managing forum roles: @@ -478,93 +470,6 @@ def domatch(x): msg2, _ = _do_remote_gradebook(request.user, course, 'post-grades', files=files) msg += msg2 - #---------------------------------------- - # Extensions - - elif "Change due date for student" in action: - # get the form data - unique_student_identifier = request.POST.get( - 'unique_student_identifier', '') - url = request.POST.get('url') - if not url: - msg += 'Must choose a unit. ' - - # try to uniquely id student by email address or username - message, student = get_student_from_identifier(unique_student_identifier) - msg += message - - # parse datetime - datestr = request.POST.get('due_datetime') - try: - due_date = parse_datetime(datestr).replace(tzinfo=timezone.utc) - except ValueError: - due_date = None - msg += "Unable to parse date: {0} ".format( - datestr) - - if url and student and due_date: - error, unit = set_due_date_extension( - course, url, student, due_date) - if error: - msg += '{0} '.format(error) - log.debug(error) - else: - studentname = student.profile.name - unitname = getattr(unit, 'display_name', None) - if unitname: - unitname = '{0} ({1})'.format(unitname, unit.location.url()) - msg += ( - 'Successfully changed due date for student {0} for {1} ' - 'to {2}').format(studentname, unitname, - due_date.strftime('%Y-%m-%d %H:%M')) - - elif "Reset due date for student" in action: - # get the form data - unique_student_identifier = request.POST.get( - 'unique_student_identifier', '') - url = request.POST.get('url') - if not url: - msg += 'Must choose a unit. ' - - # try to uniquely id student by email address or username - message, student = get_student_from_identifier(unique_student_identifier) - msg += message - - if url and student: - error, unit = set_due_date_extension( - course, url, student, None) - if error: - msg += '{0} '.format(error) - log.debug(error) - else: - studentname = student.profile.name - unitname = getattr(unit, 'display_name', None) - if unitname: - unitname = '{0} ({1})'.format(unitname, unit.location.url()) - msg += ( - 'Successfully reset due date for student {0} for {1} ' - 'to {2}').format(studentname, unitname, - unit.due.strftime('%Y-%m-%d %H:%M')) - - elif "Dump list of students with due date extensions" in action: - url = request.POST.get('url') - if not url: - msg += 'Must choose a unit. ' - else: - error, datatable = dump_module_extensions(course, url) - if error: - msg += '{0} '.format(error) - - elif "Dump due date extensions for student" in action: - unique_student_identifier = request.POST.get( - 'unique_student_identifier', '') - message, student = get_student_from_identifier(unique_student_identifier) - msg += message - - if student: - datatable = dump_student_extensions( - course, student) - #---------------------------------------- # Admin @@ -941,8 +846,6 @@ def get_analytics_result(analytics_name): 'disable_buttons': disable_buttons } - if settings.FEATURES.get('INDIVIDUAL_DUE_DATES'): - context['units_with_due_dates'] = get_units_with_due_date_options(course) if settings.FEATURES.get('ENABLE_INSTRUCTOR_BETA_DASHBOARD'): context['beta_dashboard_url'] = reverse('instructor_dashboard_2', kwargs={'course_id': course_id}) From a164e4dc4b580df54874528fafbd5fa9fe495c0d Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 27 Dec 2013 12:52:24 -0500 Subject: [PATCH 28/32] Refactor to be more functional/testable, use edX test fixtures per Sarina, et al. --- lms/djangoapps/instructor/tests/test_tools.py | 151 ++++++++++++++++++ lms/djangoapps/instructor/views/api.py | 26 +-- lms/djangoapps/instructor/views/extensions.py | 17 +- lms/djangoapps/instructor/views/tools.py | 105 ++++++++++++ 4 files changed, 277 insertions(+), 22 deletions(-) create mode 100644 lms/djangoapps/instructor/tests/test_tools.py diff --git a/lms/djangoapps/instructor/tests/test_tools.py b/lms/djangoapps/instructor/tests/test_tools.py new file mode 100644 index 000000000000..c730a7d62401 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_tools.py @@ -0,0 +1,151 @@ +""" +Tests for views/tools.py. +""" + +import datetime +import mock +import json +import unittest + +from django.utils.timezone import utc +from django.test.utils import override_settings + +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +from ..views import tools + + +class TestDashboardError(unittest.TestCase): + """ + Test DashboardError exceptions. + """ + def test_response(self): + error = tools.DashboardError(u'Oh noes!') + response = json.loads(error.response().content) + self.assertEqual(response, {'error': 'Oh noes!'}) + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class TestFindUnit(ModuleStoreTestCase): + """ + Test the find_unit function. + """ + + def setUp(self): + """ + Fixtures. + """ + course = CourseFactory.create() + week1 = ItemFactory.create() + homework = ItemFactory.create(parent_location=week1.location) + week1.children.append(homework.location) + course.children.append(week1.location) + + self.course = course + self.homework = homework + + def test_find_unit_success(self): + """ + Test finding a nested unit. + """ + url = self.homework.location.url() + self.assertEqual(tools.find_unit(self.course, url), self.homework) + + def test_find_unit_notfound(self): + """ + Test attempt to find a unit that does not exist. + """ + url = "i4x://MITx/999/chapter/notfound" + with self.assertRaises(tools.DashboardError): + tools.find_unit(self.course, url) + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class TestGetUnitsWithDueDate(ModuleStoreTestCase): + """ + Test the get_units_with_due_date function. + """ + def setUp(self): + """ + Fixtures. + """ + due = datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc) + course = CourseFactory.create() + week1 = ItemFactory.create(due=due) + week2 = ItemFactory.create(due=due) + course.children = [week1.location.url(), week2.location.url()] + + homework = ItemFactory.create( + parent_location=week1.location, + due=due + ) + week1.children = [homework.location.url()] + + self.course = course + self.week1 = week1 + self.week2 = week2 + + def test_it(self): + + def urls(seq): + "URLs for sequence of nodes." + return sorted(i.location.url() for i in seq) + + self.assertEquals( + urls(tools.get_units_with_due_date(self.course)), + urls((self.week1, self.week2))) + + +class TestTitleOrUrl(unittest.TestCase): + """ + Test the title_or_url funciton. + """ + def test_title(self): + unit = mock.Mock(display_name='hello') + self.assertEquals(tools.title_or_url(unit), 'hello') + + def test_url(self): + unit = mock.Mock(display_name=None) + unit.location.url.return_value = 'test:hello' + self.assertEquals(tools.title_or_url(unit), 'test:hello') + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class TestSetDueDateExtension(ModuleStoreTestCase): + """ + Test the set_due_date_extensions function. + """ + def setUp(self): + """ + Fixtures. + """ + due = datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc) + course = CourseFactory.create() + week1 = ItemFactory.create(due=due) + week2 = ItemFactory.create(due=due) + course.children = [week1.location.url(), week2.location.url()] + + homework = ItemFactory.create( + parent_location=week1.location, + due=due + ) + week1.children = [homework.location.url()] + + user = UserFactory.create() + + self.course = course + self.week1 = week1 + self.homeowrk = homework + self.week2 = week2 + self.user = user + + def test_set_due_date_extension(self): + extended = datetime.datetime(2013, 12, 25, 0, 0, tzinfo=utc) + tools.set_due_date_extension(self.course, self.week1, self.user, + extended) + import pdb; pdb.set_trace() + self.assertEqual(self.week1.extended_due, extended) + self.assertEqual(self.homework.extended_due, extended) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 5b6505eb2c6b..8aeb06d4823a 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1010,19 +1010,22 @@ def change_due_date(request, course_id): student = get_student_from_identifier(request.GET.get('student')) url = request.GET.get('url') datestr = request.GET.get('due_datetime') + try: due_date = parse_datetime(datestr).replace(tzinfo=utc) except ValueError: error = _("Unable to parse date: ") + datestr return HttpResponseBadRequest(json.dumps({'error': error})) - error, unit = set_due_date_extension(course, url, student, due_date) - if error: - return HttpResponseBadRequest(json.dumps({'error': error})) + + try: + unit = set_due_date_extension(course, url, student, due_date) + except ValueError, error: + return HttpResponseBadRequest(json.dumps({'error': unicode(error)})) studentname = student.profile.name unitname = getattr(unit, 'display_name', None) if unitname: - unitname = '{0} ({1})'.format(unitname, unit.location.url()) + unitname = u'{0} ({1})'.format(unitname, unit.location.url()) msg = _( 'Successfully changed due date for student {0} for {1} ' 'to {2}').format(studentname, unitname, @@ -1042,14 +1045,16 @@ def reset_due_date(request, course_id): course = get_course_by_id(course_id) student = get_student_from_identifier(request.GET.get('student')) url = request.GET.get('url') - error, unit = set_due_date_extension(course, url, student, None) - if error: - return HttpResponseBadRequest(json.dumps({'error': error})) + + try: + unit = set_due_date_extension(course, url, student, None) + except ValueError, error: + return HttpResponseBadRequest(json.dumps({'error': unicode(error)})) studentname = student.profile.name unitname = getattr(unit, 'display_name', None) if unitname: - unitname = '{0} ({1})'.format(unitname, unit.location.url()) + unitname = u'{0} ({1})'.format(unitname, unit.location.url()) due_date = unit.due msg = _( 'Successfully reset due date for student {0} for {1} ' @@ -1069,8 +1074,9 @@ def show_unit_extensions(request, course_id): """ course = get_course_by_id(course_id) url = request.GET.get('url') - error, data = dump_module_extensions(course, url) - if error: + try: + data = dump_module_extensions(course, url) + except ValueError, error: return HttpResponseBadRequest(json.dumps({'error': error})) header = data['header'] data['data'] = [dict(zip(header, row)) for row in data['data']] diff --git a/lms/djangoapps/instructor/views/extensions.py b/lms/djangoapps/instructor/views/extensions.py index 70ee4c0db1d9..4fec4a31ad0a 100644 --- a/lms/djangoapps/instructor/views/extensions.py +++ b/lms/djangoapps/instructor/views/extensions.py @@ -13,12 +13,11 @@ def set_due_date_extension(course, url, student, due_date): """ - Sets a due date extension. Factored to be usable in both legacy and beta - instructor dashboards. + Sets a due date extension. """ unit = _find_unit(course, url) if not unit: - return _("Couldn't find module for url: {0}").format(url), None + raise ValueError(_("Couldn't find module for url: {0}").format(url)) def set_due_date(node): """ @@ -43,8 +42,6 @@ def set_due_date(node): set_due_date(unit) - return None, unit # no error - def _find_unit(node, url): """ @@ -108,15 +105,11 @@ def _title_or_url(node): def dump_module_extensions(course, url): """ Dumps data about students with due date extensions for a particular module, - specified by 'url', in a particular course. Returns a tuple of (error, - data). If there is an error, `error` will be a strong suitable for - displaying to the user and `data` will be None. Otherwise `error` will be - None, and `data` will be a data structure formatted for use by the legacy - instructor dashboard's 'datatable'. + specified by 'url', in a particular course. """ unit = _find_unit(course, url) if not unit: - return _("Couldn't find module for url: {0}").format(url), {} + raise ValueError(_("Couldn't find module for url: {0}").format(url)) data = [] query = StudentModule.objects.filter( @@ -132,7 +125,7 @@ def dump_module_extensions(course, url): fullname = module.student.profile.name data.append((module.student.username, fullname, extended_due)) data.sort(key=lambda x: x[0]) - return None, { + return { "header": [_("Username"), _("Full Name"), _("Extended Due Date")], "title": _("Users with due date extensions for {0}").format( _title_or_url(unit)), diff --git a/lms/djangoapps/instructor/views/tools.py b/lms/djangoapps/instructor/views/tools.py index cbf6b6468a6c..5dde139c7844 100644 --- a/lms/djangoapps/instructor/views/tools.py +++ b/lms/djangoapps/instructor/views/tools.py @@ -1,7 +1,26 @@ """ Tools for the instructor dashboard """ +import json + from django.contrib.auth.models import User +from django.http import HttpResponseBadRequest +from django.utils.translation import ugettext as _ + +from courseware.models import StudentModule +from xmodule.fields import Date + +DATE_FIELD = Date() + + +class DashboardError(Exception): + """ + Errors arising from use of the instructor dashboard. + """ + def response(self): + error = unicode(self) + return HttpResponseBadRequest(json.dumps({'error': error})) + def strip_if_string(value): if isinstance(value, basestring): @@ -23,3 +42,89 @@ def get_student_from_identifier(unique_student_identifier): else: student = User.objects.get(username=unique_student_identifier) return student + + +def find_unit(course, url): + """ + Finds the unit (block, module, whatever the terminology is) with the given + url in the course tree and returns the unit. Raises DashboardError if no + unit is found. + """ + def find(node, url): + """ + Find node in course tree for url. + """ + if node.location.url() == url: + return node + for child in node.get_children(): + found = find(child, url) + if found: + return found + return None + + unit = find(course, url) + if unit is None: + raise DashboardError(_("Couldn't find module for url: {0}").format(url)) + return unit + + +def get_units_with_due_date(course): + """ + Returns all top level units which have due dates. Does not return + descendents of those nodes. + """ + units = [] + + def visit(node): + """ + Visit a node. Checks to see if node has a due date and appends to + `units` if it does. Otherwise recurses into children to search for + nodes with due dates. + """ + if getattr(node, 'due', None): + units.append(node) + else: + for child in node.get_children(): + visit(child) + visit(course) + #units.sort(key=_title_or_url) + return units + + +def title_or_url(node): + """ + Returns the `display_name` attribute of the passed in node of the course + tree, if it has one. Otherwise returns the node's url. + """ + title = getattr(node, 'display_name', None) + if not title: + title = node.location.url() + return title + + +def set_due_date_extension(course, unit, student, due_date): + """ + Sets a due date extension. + """ + def set_due_date(node): + """ + Recursively set the due date on a node and all of its children. + """ + try: + student_module = StudentModule.objects.get( + student_id=student.id, + course_id=course.id, + module_state_key=node.location.url() + ) + + state = json.loads(student_module.state) + state['extended_due'] = DATE_FIELD.to_json(due_date) + student_module.state = json.dumps(state) + student_module.save() + except StudentModule.DoesNotExist: + pass + + for child in node.get_children(): + set_due_date(child) + + set_due_date(unit) From b1c71db3e9d9f431f2ca85a1121bc21e38aba343 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Fri, 27 Dec 2013 13:30:05 -0500 Subject: [PATCH 29/32] Use student module directly to get at extended_due date in test. --- lms/djangoapps/instructor/tests/test_tools.py | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/lms/djangoapps/instructor/tests/test_tools.py b/lms/djangoapps/instructor/tests/test_tools.py index c730a7d62401..ffaa865887a4 100644 --- a/lms/djangoapps/instructor/tests/test_tools.py +++ b/lms/djangoapps/instructor/tests/test_tools.py @@ -3,6 +3,7 @@ """ import datetime +import functools import mock import json import unittest @@ -10,13 +11,17 @@ from django.utils.timezone import utc from django.test.utils import override_settings +from courseware.models import StudentModule from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from student.tests.factories import UserFactory +from xmodule.fields import Date from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from ..views import tools +DATE_FIELD = Date() + class TestDashboardError(unittest.TestCase): """ @@ -135,17 +140,42 @@ def setUp(self): week1.children = [homework.location.url()] user = UserFactory.create() + StudentModule( + state='{}', + student_id=user.id, + course_id=course.id, + module_state_key=week1.location.url()).save() + StudentModule( + state='{}', + student_id=user.id, + course_id=course.id, + module_state_key=homework.location.url()).save() self.course = course self.week1 = week1 - self.homeowrk = homework + self.homework = homework self.week2 = week2 self.user = user + self.extended_due = functools.partial( + get_extended_due, course, student=user) + def test_set_due_date_extension(self): extended = datetime.datetime(2013, 12, 25, 0, 0, tzinfo=utc) tools.set_due_date_extension(self.course, self.week1, self.user, extended) - import pdb; pdb.set_trace() - self.assertEqual(self.week1.extended_due, extended) - self.assertEqual(self.homework.extended_due, extended) + self.assertEqual(self.extended_due(self.week1), extended) + self.assertEqual(self.extended_due(self.homework), extended) + + +def get_extended_due(course, unit, student): + student_module = StudentModule.objects.get( + student_id=student.id, + course_id=course.id, + module_state_key=unit.location.url() + ) + + state = json.loads(student_module.state) + extended = state.get('extended_due', None) + if extended: + return DATE_FIELD.from_json(extended) From 4edbae79efcdfa63e75d37d07c0e1428ab02d911 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Mon, 30 Dec 2013 10:14:38 -0500 Subject: [PATCH 30/32] Finish refactor. --- lms/djangoapps/instructor/tests/test_tools.py | 163 +++++++++++++++++- lms/djangoapps/instructor/views/api.py | 92 ++++------ lms/djangoapps/instructor/views/extensions.py | 163 ------------------ .../instructor/views/instructor_dashboard.py | 5 +- .../instructor/views/tests/test_extensions.py | 156 ----------------- lms/djangoapps/instructor/views/tools.py | 86 +++++++++ .../courseware/instructor_dashboard.html | 40 ----- 7 files changed, 288 insertions(+), 417 deletions(-) delete mode 100644 lms/djangoapps/instructor/views/extensions.py delete mode 100644 lms/djangoapps/instructor/views/tests/test_extensions.py diff --git a/lms/djangoapps/instructor/tests/test_tools.py b/lms/djangoapps/instructor/tests/test_tools.py index ffaa865887a4..8025fab8ca95 100644 --- a/lms/djangoapps/instructor/tests/test_tools.py +++ b/lms/djangoapps/instructor/tests/test_tools.py @@ -8,8 +8,8 @@ import json import unittest -from django.utils.timezone import utc from django.test.utils import override_settings +from django.utils.timezone import utc from courseware.models import StudentModule from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE @@ -33,6 +33,46 @@ def test_response(self): self.assertEqual(response, {'error': 'Oh noes!'}) +class TestHandleDashboardError(unittest.TestCase): + """ + Test handle_dashboard_error decorator. + """ + def test_error(self): + @tools.handle_dashboard_error + def view(request, course_id): + """ + Raises DashboardError. + """ + raise tools.DashboardError("Oh noes!") + + response = json.loads(view(None, None).content) + self.assertEqual(response, {'error': 'Oh noes!'}) + + def test_no_error(self): + @tools.handle_dashboard_error + def view(request, course_id): + """ + Returns "Oh yes!" + """ + return "Oh yes!" + + self.assertEqual(view(None, None), "Oh yes!") + + +class TestParseDatetime(unittest.TestCase): + """ + Test date parsing. + """ + def test_parse_no_error(self): + self.assertEqual( + tools.parse_datetime('5/12/2010 2:42'), + datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc)) + + def test_parse_error(self): + with self.assertRaises(tools.DashboardError): + tools.parse_datetime('foo') + + @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestFindUnit(ModuleStoreTestCase): """ @@ -167,6 +207,127 @@ def test_set_due_date_extension(self): self.assertEqual(self.extended_due(self.week1), extended) self.assertEqual(self.extended_due(self.homework), extended) + def test_reset_due_date_extension(self): + tools.set_due_date_extension(self.course, self.week1, self.user, None) + self.assertEqual(self.extended_due(self.week1), None) + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class TestDataDumps(ModuleStoreTestCase): + """ + Test data dumps for reporting. + """ + + def setUp(self): + """ + Fixtures. + """ + due = datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc) + course = CourseFactory.create() + week1 = ItemFactory.create(due=due) + week2 = ItemFactory.create(due=due) + week3 = ItemFactory.create(due=due) + course.children = [week1.location.url(), week2.location.url(), + week3.location.url()] + + homework = ItemFactory.create( + parent_location=week1.location, + due=due + ) + week1.children = [homework.location.url()] + + user1 = UserFactory.create() + StudentModule( + state='{}', + student_id=user1.id, + course_id=course.id, + module_state_key=week1.location.url()).save() + StudentModule( + state='{}', + student_id=user1.id, + course_id=course.id, + module_state_key=week2.location.url()).save() + StudentModule( + state='{}', + student_id=user1.id, + course_id=course.id, + module_state_key=week3.location.url()).save() + StudentModule( + state='{}', + student_id=user1.id, + course_id=course.id, + module_state_key=homework.location.url()).save() + + user2 = UserFactory.create() + StudentModule( + state='{}', + student_id=user2.id, + course_id=course.id, + module_state_key=week1.location.url()).save() + StudentModule( + state='{}', + student_id=user2.id, + course_id=course.id, + module_state_key=homework.location.url()).save() + + user3 = UserFactory.create() + StudentModule( + state='{}', + student_id=user3.id, + course_id=course.id, + module_state_key=week1.location.url()).save() + StudentModule( + state='{}', + student_id=user3.id, + course_id=course.id, + module_state_key=homework.location.url()).save() + + + self.course = course + self.week1 = week1 + self.homework = homework + self.week2 = week2 + self.user1 = user1 + self.user2 = user2 + + def test_dump_module_extensions(self): + extended = datetime.datetime(2013, 12, 25, 0, 0, tzinfo=utc) + tools.set_due_date_extension(self.course, self.week1, self.user1, + extended) + tools.set_due_date_extension(self.course, self.week1, self.user2, + extended) + report = tools.dump_module_extensions(self.course, self.week1) + self.assertEqual( + report['title'], u'Users with due date extensions for ' + + self.week1.display_name) + self.assertEqual( + report['header'], ["Username", "Full Name", "Extended Due Date"]) + self.assertEqual(report['data'], [ + {"Username": self.user1.username, + "Full Name": self.user1.profile.name, + "Extended Due Date": "2013-12-25 00:00"}, + {"Username": self.user2.username, + "Full Name": self.user2.profile.name, + "Extended Due Date": "2013-12-25 00:00"}]) + + def test_dump_student_extensions(self): + extended = datetime.datetime(2013, 12, 25, 0, 0, tzinfo=utc) + tools.set_due_date_extension(self.course, self.week1, self.user1, + extended) + tools.set_due_date_extension(self.course, self.week2, self.user1, + extended) + report = tools.dump_student_extensions(self.course, self.user1) + self.assertEqual( + report['title'], u'Due date extensions for %s (%s)' % + (self.user1.profile.name, self.user1.username)) + self.assertEqual( + report['header'], ["Unit", "Extended Due Date"]) + self.assertEqual(report['data'], [ + {"Unit": self.week1.display_name, + "Extended Due Date": "2013-12-25 00:00"}, + {"Unit": self.week2.display_name, + "Extended Due Date": "2013-12-25 00:00"}]) + def get_extended_due(course, unit, student): student_module = StudentModule.objects.get( diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 8aeb06d4823a..ac09afaf7ea5 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -10,12 +10,10 @@ import logging import re import requests -from dateutil.parser import parse as parse_datetime from django.conf import settings from django_future.csrf import ensure_csrf_cookie from django.views.decorators.cache import cache_control from django.core.urlresolvers import reverse -from django.utils.timezone import utc from django.utils.translation import ugettext as _ from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from util.json_request import JsonResponse @@ -37,7 +35,6 @@ from instructor_task.models import GradesStore import instructor.enrollment as enrollment from instructor.enrollment import enroll_email, unenroll_email, get_email_params -from instructor.views.tools import strip_if_string, get_student_from_identifier from instructor.access import list_with_level, allow_access, revoke_access, update_forum_role import analytics.basic import analytics.distributions @@ -46,10 +43,16 @@ from bulk_email.models import CourseEmail -from .extensions import ( +from .tools import ( dump_student_extensions, dump_module_extensions, - set_due_date_extension) + find_unit, + get_student_from_identifier, + handle_dashboard_error, + parse_datetime, + set_due_date_extension, + strip_if_string, +) log = logging.getLogger(__name__) @@ -998,6 +1001,18 @@ def proxy_legacy_analytics(request, course_id): ) +def _display_unit(unit): + """ + Gets string for displaying unit to user. + """ + name = getattr(unit, 'display_name', None) + if name: + return u'{0} ({1})'.format(name, unit.location.url()) + else: + return unit.location.url() + + +@handle_dashboard_error @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_level('staff') @@ -1008,32 +1023,17 @@ def change_due_date(request, course_id): """ course = get_course_by_id(course_id) student = get_student_from_identifier(request.GET.get('student')) - url = request.GET.get('url') - datestr = request.GET.get('due_datetime') + unit = find_unit(course, request.GET.get('url')) + due_date = parse_datetime(request.GET.get('due_datetime')) + set_due_date_extension(course, unit, student, due_date) - try: - due_date = parse_datetime(datestr).replace(tzinfo=utc) - except ValueError: - error = _("Unable to parse date: ") + datestr - return HttpResponseBadRequest(json.dumps({'error': error})) - - try: - unit = set_due_date_extension(course, url, student, due_date) - except ValueError, error: - return HttpResponseBadRequest(json.dumps({'error': unicode(error)})) - - studentname = student.profile.name - unitname = getattr(unit, 'display_name', None) - if unitname: - unitname = u'{0} ({1})'.format(unitname, unit.location.url()) - msg = _( + return JsonResponse(_( 'Successfully changed due date for student {0} for {1} ' - 'to {2}').format(studentname, unitname, - due_date.strftime('%Y-%m-%d %H:%M')) - - return JsonResponse(msg) + 'to {2}').format(student.profile.name, _display_unit(unit), + due_date.strftime('%Y-%m-%d %H:%M'))) +@handle_dashboard_error @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_level('staff') @@ -1044,26 +1044,16 @@ def reset_due_date(request, course_id): """ course = get_course_by_id(course_id) student = get_student_from_identifier(request.GET.get('student')) - url = request.GET.get('url') + unit = find_unit(course, request.GET.get('url')) + set_due_date_extension(course, unit, student, None) - try: - unit = set_due_date_extension(course, url, student, None) - except ValueError, error: - return HttpResponseBadRequest(json.dumps({'error': unicode(error)})) - - studentname = student.profile.name - unitname = getattr(unit, 'display_name', None) - if unitname: - unitname = u'{0} ({1})'.format(unitname, unit.location.url()) - due_date = unit.due - msg = _( + return JsonResponse(_( 'Successfully reset due date for student {0} for {1} ' - 'to {2}').format(studentname, unitname, - due_date.strftime('%Y-%m-%d %H:%M')) - - return JsonResponse(msg) + 'to {2}').format(student.profile.name, _display_unit(unit), + unit.due.strftime('%Y-%m-%d %H:%M'))) +@handle_dashboard_error @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_level('staff') @@ -1073,16 +1063,11 @@ def show_unit_extensions(request, course_id): Shows all of the students which have due date extensions for the given unit. """ course = get_course_by_id(course_id) - url = request.GET.get('url') - try: - data = dump_module_extensions(course, url) - except ValueError, error: - return HttpResponseBadRequest(json.dumps({'error': error})) - header = data['header'] - data['data'] = [dict(zip(header, row)) for row in data['data']] - return JsonResponse(data) + unit = find_unit(course, request.GET.get('url')) + return JsonResponse(dump_module_extensions(course, unit)) +@handle_dashboard_error @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) @require_level('staff') @@ -1094,10 +1079,7 @@ def show_student_extensions(request, course_id): """ student = get_student_from_identifier(request.GET.get('student')) course = get_course_by_id(course_id) - data = dump_student_extensions(course, student) - header = data['header'] - data['data'] = [dict(zip(header, row)) for row in data['data']] - return JsonResponse(data) + return JsonResponse(dump_student_extensions(course, student)) def _split_input_list(str_list): diff --git a/lms/djangoapps/instructor/views/extensions.py b/lms/djangoapps/instructor/views/extensions.py deleted file mode 100644 index 4fec4a31ad0a..000000000000 --- a/lms/djangoapps/instructor/views/extensions.py +++ /dev/null @@ -1,163 +0,0 @@ -""" -Common library for working with individual due date extensions that can be used -by either the legacy or the beta instructor dashboard. -""" - -import json -from courseware.models import StudentModule -from django.utils.translation import ugettext as _ -from xmodule.fields import Date - -DATE_FIELD = Date() - - -def set_due_date_extension(course, url, student, due_date): - """ - Sets a due date extension. - """ - unit = _find_unit(course, url) - if not unit: - raise ValueError(_("Couldn't find module for url: {0}").format(url)) - - def set_due_date(node): - """ - Recursively set the due date on a node and all of its children. - """ - try: - student_module = StudentModule.objects.get( - student_id=student.id, - course_id=course.id, - module_state_key=node.location.url() - ) - - state = json.loads(student_module.state) - state['extended_due'] = DATE_FIELD.to_json(due_date) - student_module.state = json.dumps(state) - student_module.save() - except StudentModule.DoesNotExist: - pass - - for child in node.get_children(): - set_due_date(child) - - set_due_date(unit) - - -def _find_unit(node, url): - """ - Find node in course tree for url. - """ - if node.location.url() == url: - return node - for child in node.get_children(): - found = _find_unit(child, url) - if found: - return found - return None - - -def _get_units_with_due_date(course): - """ - Returns all top level units which have due dates. Does not return - descendents of those nodes. - """ - units = [] - - def visit(node): - """ - Visit a node. Checks to see if node has a due date and appends to - `units` if it does. Otherwise recurses into children to search for - nodes with due dates. - """ - if getattr(node, 'due', None): - units.append(node) - else: - for child in node.get_children(): - visit(child) - visit(course) - units.sort(key=_title_or_url) - return units - - -def get_units_with_due_date_options(course): - """ - Finds all top level units that have a due date and returns them as a - sequence of (title, url) tuples suitable for populating the pull down to - select a unit in the 'Extensions' tab. - """ - def make_option(node): - "Returns (title, url) tuple for a node." - return _title_or_url(node), node.location.url() - return map(make_option, _get_units_with_due_date(course)) - - -def _title_or_url(node): - """ - Returns the `display_name` attribute of the passed in node of the course - tree, if it has one. Otherwise returns the node's url. - """ - title = getattr(node, 'display_name', None) - if not title: - title = node.location.url() - return title - - -def dump_module_extensions(course, url): - """ - Dumps data about students with due date extensions for a particular module, - specified by 'url', in a particular course. - """ - unit = _find_unit(course, url) - if not unit: - raise ValueError(_("Couldn't find module for url: {0}").format(url)) - - data = [] - query = StudentModule.objects.filter( - course_id=course.id, - module_state_key=url) - for module in query: - state = json.loads(module.state) - extended_due = state.get("extended_due") - if not extended_due: - continue - extended_due = DATE_FIELD.from_json(extended_due) - extended_due = extended_due.strftime("%Y-%m-%d %H:%M") - fullname = module.student.profile.name - data.append((module.student.username, fullname, extended_due)) - data.sort(key=lambda x: x[0]) - return { - "header": [_("Username"), _("Full Name"), _("Extended Due Date")], - "title": _("Users with due date extensions for {0}").format( - _title_or_url(unit)), - "data": data - } - - -def dump_student_extensions(course, student): - """ - Dumps data about the due date extensions granted for a particular student - in a particular course. Returns a data structure formatted for use by the - legacy instructor dashboard's 'datatable'. - """ - data = [] - units = _get_units_with_due_date(course) - units = dict([(u.location.url(), u) for u in units]) - query = StudentModule.objects.filter( - course_id=course.id, - student_id=student.id) - for module in query: - state = json.loads(module.state) - if module.module_state_key not in units: - continue - extended_due = state.get("extended_due") - if not extended_due: - continue - extended_due = DATE_FIELD.from_json(extended_due) - extended_due = extended_due.strftime("%Y-%m-%d %H:%M") - title = _title_or_url(units[module.module_state_key]) - data.append((title, extended_due)) - return { - "header": [_("Unit"), _("Extended Due Date")], - "title": _("Due date extensions for {0} {1} ({2})").format( - student.first_name, student.last_name, student.username), - "data": data} diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 02313719493f..56b0807e6d16 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -27,7 +27,7 @@ from lms.lib.xblock.runtime import handler_prefix -from .extensions import get_units_with_due_date_options +from .tools import get_units_with_due_date, title_or_url @ensure_csrf_cookie @@ -172,7 +172,8 @@ def _section_extensions(course): section_data = { 'section_key': 'extensions', 'section_display_name': _('Extensions'), - 'units_with_due_dates': get_units_with_due_date_options(course), + 'units_with_due_dates': ((title_or_url(unit), unit.location.url()) + for unit in get_units_with_due_date(course)), 'change_due_date_url': reverse('change_due_date', kwargs={'course_id': course.id}), 'reset_due_date_url': reverse('reset_due_date', kwargs={'course_id': course.id}), 'show_unit_extensions_url': reverse('show_unit_extensions', kwargs={'course_id': course.id}), diff --git a/lms/djangoapps/instructor/views/tests/test_extensions.py b/lms/djangoapps/instructor/views/tests/test_extensions.py deleted file mode 100644 index 47ab21e6d0d9..000000000000 --- a/lms/djangoapps/instructor/views/tests/test_extensions.py +++ /dev/null @@ -1,156 +0,0 @@ -""" -Test due date extensions library. -""" - -import datetime -import json -import mock -import unittest - - -DUE_DATE = datetime.datetime(2010, 5, 12, 2, 42) -EXTENDED_DUE_DATE = datetime.datetime(2013, 10, 12, 10, 30) - - -class ExtensionsTests(unittest.TestCase): - """ - Test due date extensions library. - """ - - def setUp(self): - """ - Set up test fixtures. - """ - self.course = DummyCourseNode( - 'Dummy Course', 'i4x://dummy', - children=[ - DummyCourseNode( - 'Homework 1', 'i4x://dummy/homework', - due=DUE_DATE, - children=[ - DummyCourseNode( - 'Problem 1', 'i4x://dummy/homework/problem')]), - DummyCourseNode( - 'Final Exam', 'i4x://dummy/exam', - due=DUE_DATE, - children=[ - DummyCourseNode( - 'Problem 2', 'i4x://dummy/exam/problem')])]) - self.homework, self.exam = self.course.children - - patcher = mock.patch('instructor.views.extensions.StudentModule') - self.student_module = patcher.start() - self.addCleanup(patcher.stop) - - self.student = mock.Mock(username='fred', first_name='Fred', - last_name='Flintstone') - - def test_set_due_date_extension_success(self): - """ - Test setting a due date extension. - """ - from ..extensions import set_due_date_extension as fut - self.student_module.objects.get.return_value.state = json.dumps({}) - error, _ = fut(self.course, 'i4x://dummy/homework', self.student, - EXTENDED_DUE_DATE) - self.assertEqual(error, None) - state = json.loads(self.student_module.objects.get.return_value.state) - self.assertEqual(state['extended_due'], u'2013-10-12T10:30:00Z') - - def test_set_due_date_extension_bad_url(self): - """ - Test attempt to set due date extension with bad url. - """ - from ..extensions import set_due_date_extension as fut - error, _ = fut(self.course, 'i4x://foo', self.student, - EXTENDED_DUE_DATE) - self.assertTrue(error.startswith("Couldn't find")) - - def test_dump_module_extensions(self): - """ - Test dump of students with due date extensions. - """ - from ..extensions import dump_module_extensions as fut - - class DummyProfile(object): - "Mock Profile" - def __init__(self, name): - self.name = name - self.student_module.objects.filter.return_value = [ - mock.Mock( - student=mock.Mock( - username='fred', - profile=DummyProfile('Fred Flintstone')), - state=json.dumps({'extended_due': u'2013-10-12T10:30:00Z'})), - mock.Mock( - student=mock.Mock( - username='barney', - profile=DummyProfile('Barney Rubble')), - state=json.dumps({'extended_due': u'2013-10-13T10:30:00Z'})), - mock.Mock( - student=mock.Mock( - username='bambam', - first_name='Bam Bam', - last_name='Flintstone'), - state=json.dumps({}))] - error, table = fut(self.course, 'i4x://dummy/homework') - self.assertEqual(error, None) - self.assertEqual(table['header'], - ["Username", "Full Name", "Extended Due Date"]) - self.assertEqual(table['title'], - "Users with due date extensions for Homework 1") - self.assertEqual( - table['data'], - [('barney', 'Barney Rubble', '2013-10-13 10:30'), - ('fred', 'Fred Flintstone', '2013-10-12 10:30')]) - - def test_dump_module_extensions_bad_url(self): - """ - Test attempt to dump students with due date extenions with bad url. - """ - from ..extensions import dump_module_extensions as fut - error, table = fut(self.course, 'i4x://foo') - self.assertTrue(error.startswith("Couldn't find")) - self.assertEqual(table, {}) - - def test_dump_student_extensions(self): - """ - Test dump due date extensions for student. - """ - from ..extensions import dump_student_extensions as fut - self.student_module.objects.filter.return_value = [ - mock.Mock( - module_state_key='i4x://dummy/homework', - state=json.dumps({})), - mock.Mock( - module_state_key='i4x://dummy/exam', - state=json.dumps({'extended_due': u'2013-10-13T10:30:00Z'}))] - table = fut(self.course, self.student) - self.assertEqual(table['header'], ["Unit", "Extended Due Date"]) - self.assertEqual(table['title'], - "Due date extensions for Fred Flintstone (fred)") - self.assertEqual(table['data'], - [('Final Exam', '2013-10-13 10:30')]) - - -class DummyCourseNode(object): - """ - Mock of a CourseNode. - """ - id = 1 - children = [] - - def __init__(self, display_name, url, **kw): - self.display_name = display_name - self._url = url - self.__dict__.update(kw) - - @property - def location(self): - return self - - def url(self): - return self._url - - def get_children(self): - return self.children diff --git a/lms/djangoapps/instructor/views/tools.py b/lms/djangoapps/instructor/views/tools.py index 5dde139c7844..8af69bb3d503 100644 --- a/lms/djangoapps/instructor/views/tools.py +++ b/lms/djangoapps/instructor/views/tools.py @@ -1,10 +1,12 @@ """ Tools for the instructor dashboard """ +import dateutil import json from django.contrib.auth.models import User from django.http import HttpResponseBadRequest +from django.utils.timezone import utc from django.utils.translation import ugettext as _ from courseware.models import StudentModule @@ -22,6 +24,23 @@ def response(self): return HttpResponseBadRequest(json.dumps({'error': error})) +def handle_dashboard_error(view): + """ + Decorator which adds seamless DashboardError handling to a view. If a + DashboardError is raised during view processing, an HttpResponseBadRequest + is sent back to the client with JSON data about the error. + """ + def wrapper(request, course_id): + """ + """ + try: + return view(request, course_id=course_id) + except DashboardError, error: + return error.response() + + return wrapper + + def strip_if_string(value): if isinstance(value, basestring): return value.strip() @@ -44,6 +63,13 @@ def get_student_from_identifier(unique_student_identifier): return student +def parse_datetime(datestr): + try: + return dateutil.parser.parse(datestr).replace(tzinfo=utc) + except ValueError: + raise DashboardError(_("Unable to parse date: ") + datestr) + + def find_unit(course, url): """ Finds the unit (block, module, whatever the terminology is) with the given @@ -128,3 +154,63 @@ def set_due_date(node): set_due_date(child) set_due_date(unit) + + +def dump_module_extensions(course, unit): + """ + Dumps data about students with due date extensions for a particular module, + specified by 'url', in a particular course. + """ + data = [] + header = [_("Username"), _("Full Name"), _("Extended Due Date")] + query = StudentModule.objects.filter( + course_id=course.id, + module_state_key=unit.location.url()) + for module in query: + state = json.loads(module.state) + extended_due = state.get("extended_due") + if not extended_due: + continue + extended_due = DATE_FIELD.from_json(extended_due) + extended_due = extended_due.strftime("%Y-%m-%d %H:%M") + fullname = module.student.profile.name + data.append(dict(zip( + header, + (module.student.username, fullname, extended_due)))) + data.sort(key=lambda x: x[header[0]]) + return { + "header": header, + "title": _("Users with due date extensions for {0}").format( + title_or_url(unit)), + "data": data + } + + +def dump_student_extensions(course, student): + """ + Dumps data about the due date extensions granted for a particular student + in a particular course. + """ + data = [] + header = [_("Unit"), _("Extended Due Date")] + units = get_units_with_due_date(course) + units = dict([(u.location.url(), u) for u in units]) + query = StudentModule.objects.filter( + course_id=course.id, + student_id=student.id) + for module in query: + state = json.loads(module.state) + if module.module_state_key not in units: + continue + extended_due = state.get("extended_due") + if not extended_due: + continue + extended_due = DATE_FIELD.from_json(extended_due) + extended_due = extended_due.strftime("%Y-%m-%d %H:%M") + title = title_or_url(units[module.module_state_key]) + data.append(dict(zip(header, (title, extended_due)))) + return { + "header": header, + "title": _("Due date extensions for {0} {1} ({2})").format( + student.first_name, student.last_name, student.username), + "data": data} diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index d35fbe9d83f3..cd78ef0c6ca9 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -126,9 +126,6 @@

${_("Instructor Dashboard")}

${_("Student-specific grade inspection and adjustment")}

%endif - -##----------------------------------------------------------------------------- -%if modeflag.get('Extensions'): - %if settings.FEATURES.get('INDIVIDUAL_DUE_DATES'): -

${_("Individual due date extensions")}

- -

- ${_("Specify the {platform_name} email address or username of a student here:").format(platform_name=settings.PLATFORM_NAME)} - -

-

- ${_("Choose the graded unit:")} - -

-

- ${_("Specify the individual due date and time in UTC:")} - -

-

- - -

-

- - -

-
- %endif -%endif - - ##----------------------------------------------------------------------------- %if modeflag.get('Psychometrics'): From 685314e689f847516aa87cbd5b663ba27574a082 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Mon, 30 Dec 2013 11:59:11 -0500 Subject: [PATCH 31/32] Test API. --- lms/djangoapps/instructor/tests/test_api.py | 136 +++++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 016b5bc39c95..73b2bf68ff8a 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -15,12 +15,13 @@ from django.http import HttpRequest, HttpResponse from django_comment_common.models import FORUM_ROLE_COMMUNITY_TA from django.core import mail +from django.utils.timezone import utc from django.contrib.auth.models import User from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from courseware.tests.helpers import LoginEnrollmentTestCase -from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from student.tests.factories import UserFactory from courseware.tests.factories import StaffFactory, InstructorFactory @@ -34,6 +35,8 @@ from instructor.views.api import _split_input_list, _msk_from_problem_urlname, common_exceptions_400 from instructor_task.api_helper import AlreadyRunningError +from .test_tools import get_extended_due + @common_exceptions_400 def view_success(request): # pylint: disable=W0613 @@ -1426,3 +1429,134 @@ def test_msk_from_problem_urlname(self): def test_msk_from_problem_urlname_error(self): args = ('notagoodcourse', 'L2Node1') _msk_from_problem_urlname(*args) + + +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class TestDueDateExtensions(ModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Test data dumps for reporting. + """ + + def setUp(self): + """ + Fixtures. + """ + due = datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc) + course = CourseFactory.create() + week1 = ItemFactory.create(due=due) + week2 = ItemFactory.create(due=due) + week3 = ItemFactory.create(due=due) + course.children = [week1.location.url(), week2.location.url(), + week3.location.url()] + + homework = ItemFactory.create( + parent_location=week1.location, + due=due + ) + week1.children = [homework.location.url()] + + user1 = UserFactory.create() + StudentModule( + state='{}', + student_id=user1.id, + course_id=course.id, + module_state_key=week1.location.url()).save() + StudentModule( + state='{}', + student_id=user1.id, + course_id=course.id, + module_state_key=week2.location.url()).save() + StudentModule( + state='{}', + student_id=user1.id, + course_id=course.id, + module_state_key=week3.location.url()).save() + StudentModule( + state='{}', + student_id=user1.id, + course_id=course.id, + module_state_key=homework.location.url()).save() + + user2 = UserFactory.create() + StudentModule( + state='{}', + student_id=user2.id, + course_id=course.id, + module_state_key=week1.location.url()).save() + StudentModule( + state='{}', + student_id=user2.id, + course_id=course.id, + module_state_key=homework.location.url()).save() + + user3 = UserFactory.create() + StudentModule( + state='{}', + student_id=user3.id, + course_id=course.id, + module_state_key=week1.location.url()).save() + StudentModule( + state='{}', + student_id=user3.id, + course_id=course.id, + module_state_key=homework.location.url()).save() + + + self.course = course + self.week1 = week1 + self.homework = homework + self.week2 = week2 + self.user1 = user1 + self.user2 = user2 + + self.instructor = InstructorFactory(course=course.location) + self.client.login(username=self.instructor.username, password='test') + + def test_change_due_date(self): + url = reverse('change_due_date', kwargs={'course_id': self.course.id}) + response = self.client.get(url, { + 'student': self.user1.username, + 'url': self.week1.location.url(), + 'due_datetime': '12/30/2013 00:00' + }) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(datetime.datetime(2013, 12, 30, 0, 0, tzinfo=utc), + get_extended_due(self.course, self.week1, self.user1)) + + def test_reset_date(self): + self.test_change_due_date() + url = reverse('reset_due_date', kwargs={'course_id': self.course.id}) + response = self.client.get(url, { + 'student': self.user1.username, + 'url': self.week1.location.url(), + }) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(None, + get_extended_due(self.course, self.week1, self.user1)) + + def test_show_unit_extensions(self): + self.test_change_due_date() + url = reverse('show_unit_extensions', + kwargs={'course_id': self.course.id}) + response = self.client.get(url, {'url': self.week1.location.url()}) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(json.loads(response.content), { + u'data': [{u'Extended Due Date': u'2013-12-30 00:00', + u'Full Name': self.user1.profile.name, + u'Username': self.user1.username}], + u'header': [u'Username', u'Full Name', u'Extended Due Date'], + u'title': u'Users with due date extensions for %s' % + self.week1.display_name}) + + def test_show_student_extensions(self): + self.test_change_due_date() + url = reverse('show_student_extensions', + kwargs={'course_id': self.course.id}) + response = self.client.get(url, {'student': self.user1.username}) + self.assertEqual(response.status_code, 200, response.content) + self.assertEqual(json.loads(response.content), { + u'data': [{u'Extended Due Date': u'2013-12-30 00:00', + u'Unit': self.week1.display_name}], + u'header': [u'Unit', u'Extended Due Date'], + u'title': u'Due date extensions for %s (%s)' % ( + self.user1.profile.name, self.user1.username)}) From 93292e5d3634c91bf0fbaf00dbae3d6eb8e36cbd Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Tue, 31 Dec 2013 09:31:09 -0500 Subject: [PATCH 32/32] Don't need a 'utils' module and a 'util' package. Rearrange and add unittests. --- common/lib/xmodule/xmodule/capa_module.py | 2 +- common/lib/xmodule/xmodule/foldit_module.py | 2 +- .../xmodule/xmodule/peer_grading_module.py | 2 +- .../xmodule/tests/test_util_duedate.py | 52 +++++++++++++++++++ .../xmodule/{utils.py => util/duedate.py} | 8 +-- lms/djangoapps/courseware/grades.py | 2 +- lms/djangoapps/courseware/module_render.py | 2 +- 7 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 common/lib/xmodule/xmodule/tests/test_util_duedate.py rename common/lib/xmodule/xmodule/{utils.py => util/duedate.py} (81%) diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 7a7103e60893..cc253ade31cd 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -23,7 +23,7 @@ from .fields import Timedelta, Date from django.utils.timezone import UTC from django.utils.translation import ugettext as _ -from .utils import get_extended_due_date +from .util.duedate import get_extended_due_date log = logging.getLogger("edx.courseware") diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index d5d771f838b1..e24a71527b4a 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -8,7 +8,7 @@ from xmodule.xml_module import XmlDescriptor from xblock.fields import Scope, Integer, String from .fields import Date -from .utils import get_extended_due_date +from .util.duedate import get_extended_due_date log = logging.getLogger(__name__) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 2de306f10c7d..95835a0ba4fc 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -10,7 +10,7 @@ from .raw_module import RawDescriptor from .modulestore.exceptions import ItemNotFoundError, NoPathToItem from .timeinfo import TimeInfo -from .utils import get_extended_due_date +from .util.duedate import get_extended_due_date from xblock.fields import Dict, String, Scope, Boolean, Float from xmodule.fields import Date, Timedelta diff --git a/common/lib/xmodule/xmodule/tests/test_util_duedate.py b/common/lib/xmodule/xmodule/tests/test_util_duedate.py new file mode 100644 index 000000000000..8d8d80fcb2dd --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_util_duedate.py @@ -0,0 +1,52 @@ +import mock +import unittest + +from ..util import duedate + + +class TestGetExtendedDueDate(unittest.TestCase): + """ + Test `get_extended_due_date` function. + """ + + def call_fut(self, node): + """ + Call function under test. + """ + fut = duedate.get_extended_due_date + return fut(node) + + def test_no_due_date(self): + """ + Test no due date. + """ + node = object() + self.assertEqual(self.call_fut(node), None) + + def test_due_date_no_extension(self): + """ + Test due date without extension. + """ + node = mock.Mock(due=1, extended_due=None) + self.assertEqual(self.call_fut(node), 1) + + def test_due_date_with_extension(self): + """ + Test due date with extension. + """ + node = mock.Mock(due=1, extended_due=2) + self.assertEqual(self.call_fut(node), 2) + + def test_due_date_extension_is_earlier(self): + """ + Test due date with extension, but due date is later than extension. + """ + node = mock.Mock(due=2, extended_due=1) + self.assertEqual(self.call_fut(node), 2) + + def test_extension_without_due_date(self): + """ + Test non-sensical extension without due date. + """ + node = mock.Mock(due=None, extended_due=1) + self.assertEqual(self.call_fut(node), None) diff --git a/common/lib/xmodule/xmodule/utils.py b/common/lib/xmodule/xmodule/util/duedate.py similarity index 81% rename from common/lib/xmodule/xmodule/utils.py rename to common/lib/xmodule/xmodule/util/duedate.py index 2af303977584..52df12e31741 100644 --- a/common/lib/xmodule/xmodule/utils.py +++ b/common/lib/xmodule/xmodule/util/duedate.py @@ -9,10 +9,10 @@ def get_extended_due_date(node): the extendeded due date if one has been granted and it is later than the global due date, otherwise returning the global due date for the unit. """ - extended = getattr(node, 'extended_due', None) due_date = getattr(node, 'due', None) - if not extended and not due_date: - return None - elif not extended or extended < due_date: + if not due_date: + return due_date + extended = getattr(node, 'extended_due', None) + if not extended or extended < due_date: return due_date return extended diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index d33c362fbef1..2e793879146d 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -19,7 +19,7 @@ from xmodule import graders from xmodule.capa_module import CapaModule from xmodule.graders import Score -from xmodule.utils import get_extended_due_date +from xmodule.util.duedate import get_extended_due_date from .models import StudentModule from .module_render import get_module, get_module_for_descriptor diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 9e3b02bce99d..75e2acfd1d62 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -36,7 +36,7 @@ from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError -from xmodule.utils import get_extended_due_date +from xmodule.util.duedate import get_extended_due_date from xmodule_modifiers import replace_course_urls, replace_jump_to_id_urls, replace_static_urls, add_histogram, wrap_xblock from xmodule.lti_module import LTIModule