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 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..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 @@ -229,6 +231,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..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__) @@ -20,6 +21,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') @@ -40,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): """ 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 1ef3883d8274..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 @@ -48,6 +49,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 @@ -110,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 @@ -522,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) 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/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 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/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/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) 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, }) 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')) diff --git a/lms/djangoapps/instructor/views/extensions.py b/lms/djangoapps/instructor/views/extensions.py new file mode 100644 index 000000000000..fc531d259975 --- /dev/null +++ b/lms/djangoapps/instructor/views/extensions.py @@ -0,0 +1,128 @@ +import json +from courseware.models import StudentModule +from xmodule.fields import Date + +date_field = 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. + """ + unit = find_unit(course, url) + if not unit: + return "Couldn't find module for url: {0}".format(url), None + + 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'] = 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) + + return None, unit # 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}".format(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)) + 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)), + "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 6a02b5be7d4c..01852b28902b 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,12 @@ import track.views from mitxmako.shortcuts import render_to_string +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__) @@ -187,6 +194,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 +492,92 @@ 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 + message, due_date = parse_datetime(request.POST.get('due_datetime')) + msg += message + + 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 = '{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()) + 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 = '{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()) + 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_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 @@ -771,10 +880,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}), - '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/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..85744a3594da --- /dev/null +++ b/lms/djangoapps/instructor/views/tests/test_extensions.py @@ -0,0 +1,126 @@ +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, unit = 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, unit = 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 + 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/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 diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index 5b254fc86e58..68acc689cae3 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -110,6 +110,9 @@
+ ${_("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:")} + +
++ + +
++ + +
+