diff --git a/.gitignore b/.gitignore index 1a23b36b2ad2..551b09709766 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ cms/envs/private.py .redcar/ codekit-config.json +### NFS artifacts +.nfs* + ### OS X artifacts *.DS_Store .AppleDouble diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index e4a2ada3746e..60bf55b9f3c8 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -22,6 +22,7 @@ from xblock.fields import Scope, String, Boolean, Dict, Integer, Float from .fields import Timedelta, Date from django.utils.timezone import UTC +from .util.duedate import get_extended_due_date log = logging.getLogger("edx.courseware") @@ -95,6 +96,14 @@ 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 " + "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, + ) graceperiod = Timedelta( help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings @@ -191,7 +200,7 @@ def __init__(self, *args, **kwargs): """ super(CapaModule, self).__init__(*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 a8d4afaa11f0..00ea775b6476 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -20,6 +20,7 @@ "accept_file_upload", "skip_spelling_checks", "due", + "extended_due", "graceperiod", "weight", "min_to_calibrate", @@ -262,6 +263,14 @@ class CombinedOpenEndedFields(object): 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 " + "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, + ) graceperiod = Timedelta( help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index 655ff1911ab6..e24a71527b4a 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.fields import Scope, Integer, String from .fields import Date +from .util.duedate import get_extended_due_date log = logging.getLogger(__name__) @@ -20,6 +21,14 @@ 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 " + "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, + ) show_basic_score = String(scope=Scope.settings, default='false') show_leaderboard = String(scope=Scope.settings, default='false') @@ -40,7 +49,7 @@ def __init__(self, *args, **kwargs): show_leaderboard="false"/> """ super(FolditModule, self).__init__(*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/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index 8fa46045a648..5839dd6d1c5b 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -21,6 +21,14 @@ class InheritanceMixin(XBlockMixin): 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 " + "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, + ) giturl = String(help="url root for course data git repository", scope=Scope.settings) xqa_key = String(help="DO NOT USE", scope=Scope.settings) graceperiod = Timedelta( 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 d23edebf0f3b..c2b10577ac5c 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 @@ -6,9 +6,9 @@ from xmodule.capa_module import ComplexEncoder from xmodule.progress import Progress from xmodule.stringify import stringify_children -from xmodule.open_ended_grading_classes import self_assessment_module -from xmodule.open_ended_grading_classes import open_ended_module -from functools import partial +from xmodule.open_ended_grading_classes import self_assessment_module +from xmodule.open_ended_grading_classes import open_ended_module +from xmodule.util.duedate import get_extended_due_date from .combined_open_ended_rubric import CombinedOpenEndedRubric, GRADER_TYPE_IMAGE_DICT, HUMAN_GRADER_TYPE, LEGEND_LIST from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, MockPeerGradingService, GradingServiceError from xmodule.open_ended_grading_classes.openendedchild import OpenEndedChild @@ -132,8 +132,7 @@ def __init__(self, system, location, definition, descriptor, 'peer_grade_finished_submissions_when_none_pending', False ) - due_date = instance_state.get('due', None) - + due_date = get_extended_due_date(instance_state) grace_period_string = instance_state.get('graceperiod', None) try: self.timeinfo = TimeInfo(due_date, grace_period_string) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 054af321e260..95835a0ba4fc 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -7,9 +7,10 @@ from pkg_resources import resource_string from .capa_module import ComplexEncoder from .x_module import XModule, module_attr -from xmodule.raw_module import RawDescriptor -from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem +from .raw_module import RawDescriptor +from .modulestore.exceptions import ItemNotFoundError, NoPathToItem from .timeinfo import TimeInfo +from .util.duedate import get_extended_due_date from xblock.fields import Dict, String, Scope, Boolean, Float from xmodule.fields import Date, Timedelta @@ -46,6 +47,14 @@ class PeerGradingFields(object): due = Date( help="Due date that should be displayed.", scope=Scope.settings) + extended_due = Date( + help="Date that this problem is due by for a particular student. This " + "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, + ) graceperiod = Timedelta( help="Amount of grace to give on the due date.", scope=Scope.settings @@ -128,7 +137,8 @@ def __init__(self, *args, **kwargs): self.linked_problem = self.system.get_module(linked_descriptors[0]) 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 @@ -556,7 +566,7 @@ def peer_grading(self, _data=None): except (NoPathToItem, ItemNotFoundError): continue if descriptor: - problem['due'] = descriptor.due + problem['due'] = get_extended_due_date(descriptor) grace_period = descriptor.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 d686a0556477..26820c765810 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -3,15 +3,17 @@ 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.fields import Integer, Scope from xblock.fragment import Fragment 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 @@ -25,6 +27,15 @@ 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 " + "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, + ) 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 b7b7d8b6f40b..a76dc4eba771 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -78,17 +78,10 @@ def answer_key(cls, input_num=2): @classmethod def create(cls, - graceperiod=None, - due=None, - max_attempts=None, - showanswer=None, - rerandomize=None, - force_save_button=None, attempts=None, problem_state=None, correct=False, - done=None, - text_customization=None + **kwargs ): """ All parameters are optional, and are added to the created problem if specified. @@ -109,24 +102,7 @@ def create(cls, location = Location(["i4x", "edX", "capa_test", "problem", "SampleProblem{0}".format(cls.next_num())]) field_data = {'data': cls.sample_problem_xml} - - if graceperiod is not None: - field_data['graceperiod'] = graceperiod - if due is not None: - field_data['due'] = due - if max_attempts is not None: - field_data['max_attempts'] = max_attempts - if showanswer is not None: - field_data['showanswer'] = showanswer - if force_save_button is not None: - field_data['force_save_button'] = force_save_button - if rerandomize is not None: - field_data['rerandomize'] = rerandomize - if done is not None: - field_data['done'] = done - if text_customization is not None: - field_data['text_customization'] = text_customization - + field_data.update(kwargs) descriptor = Mock(weight="1") if problem_state is not None: field_data.update(problem_state) @@ -379,6 +355,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): # Valid GET param dict 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..923fbe54f746 --- /dev/null +++ b/common/lib/xmodule/xmodule/tests/test_util_duedate.py @@ -0,0 +1,62 @@ +""" +Tests for extended due date utilities. +""" +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) + + def test_due_date_with_extension_dict(self): + """ + Test due date with extension when node is a dict. + """ + node = {'due': 1, 'extended_due': 2} + self.assertEqual(self.call_fut(node), 2) diff --git a/common/lib/xmodule/xmodule/util/duedate.py b/common/lib/xmodule/xmodule/util/duedate.py new file mode 100644 index 000000000000..7c5dec5eaa9b --- /dev/null +++ b/common/lib/xmodule/xmodule/util/duedate.py @@ -0,0 +1,23 @@ +""" +Miscellaneous utility functions. +""" +from functools import partial + + +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 and it is later than the + global due date, otherwise returning the global due date for the unit. + """ + if isinstance(node, dict): + get = node.get + else: + get = partial(getattr, node) + due_date = get('due', None) + if not due_date: + return due_date + extended = get('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 f36ea50a37d9..4015d0adfee1 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -6,9 +6,7 @@ import logging from contextlib import contextmanager -from collections import defaultdict from django.conf import settings -from django.contrib.auth.models import User from django.db import transaction from django.test.client import RequestFactory @@ -16,13 +14,13 @@ from courseware import courses from courseware.model_data import FieldDataCache -from xblock.fields import Scope from xmodule import graders from xmodule.graders import Score from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.util.duedate import get_extended_due_date from .models import StudentModule -from .module_render import get_module, get_module_for_descriptor +from .module_render import get_module_for_descriptor log = logging.getLogger("edx.courseware") @@ -372,7 +370,7 @@ def _progress_summary(student, request, course): 'scores': scores, 'section_total': section_total, 'format': module_format, - 'due': section_module.due, + 'due': get_extended_due_date(section_module), 'graded': graded, }) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 377a238ee60f..39371a3448cf 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -14,7 +14,7 @@ from django.core.urlresolvers import reverse from django.http import Http404, HttpResponse import django.utils -from django.views.decorators.csrf import csrf_exempt, csrf_protect +from django.views.decorators.csrf import csrf_exempt from capa.xqueue_interface import XQueueInterface from courseware.access import has_access @@ -37,6 +37,7 @@ from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError +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 from xmodule.x_module import XModuleDescriptor @@ -112,7 +113,7 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_ sections.append({'display_name': section.display_name_with_default, 'url_name': section.url_name, 'format': section.format if section.format is not None else '', - 'due': section.due, + 'due': get_extended_due_date(section), 'active': active, 'graded': section.graded, }) diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 016b5bc39c95..2ca206a74ce8 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,133 @@ 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)}) diff --git a/lms/djangoapps/instructor/tests/test_tools.py b/lms/djangoapps/instructor/tests/test_tools.py new file mode 100644 index 000000000000..1389de145af1 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_tools.py @@ -0,0 +1,346 @@ +""" +Tests for views/tools.py. +""" + +import datetime +import functools +import mock +import json +import unittest + +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 +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): + """ + 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!'}) + + +class TestHandleDashboardError(unittest.TestCase): + """ + Test handle_dashboard_error decorator. + """ + def test_error(self): + #pylint: disable=W0613 + @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): + #pylint: disable=W0613 + @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): + """ + 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() + 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.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) + 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): + """ + Get the extended due date out of a student's state for a particular unit. + """ + 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) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 215f775d7742..ac09afaf7ea5 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -6,9 +6,9 @@ Many of these GETs may become PUTs in the future. """ -import re -import logging import json +import logging +import re import requests from django.conf import settings from django_future.csrf import ensure_csrf_cookie @@ -35,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 @@ -44,6 +43,17 @@ from bulk_email.models import CourseEmail +from .tools import ( + dump_student_extensions, + dump_module_extensions, + find_unit, + get_student_from_identifier, + handle_dashboard_error, + parse_datetime, + set_due_date_extension, + strip_if_string, +) + log = logging.getLogger(__name__) @@ -991,6 +1001,87 @@ 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') +@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')) + 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) + + return JsonResponse(_( + 'Successfully changed due date for student {0} for {1} ' + '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') +@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')) + unit = find_unit(course, request.GET.get('url')) + set_due_date_extension(course, unit, student, None) + + return JsonResponse(_( + 'Successfully reset due date for student {0} for {1} ' + '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') +@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) + 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') +@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) + return JsonResponse(dump_student_extensions(course, student)) + + def _split_input_list(str_list): """ Separate out individual student email from the comma, or space separated string. diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index f016b9820fd8..e575a1ea9ad5 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -37,6 +37,14 @@ 'instructor.views.api.proxy_legacy_analytics', name="proxy_legacy_analytics"), url(r'^send_email$', 'instructor.views.api.send_email', name="send_email"), + url(r'^change_due_date$', 'instructor.views.api.change_due_date', + name='change_due_date'), + url(r'^reset_due_date$', 'instructor.views.api.reset_due_date', + name='reset_due_date'), + url(r'^show_unit_extensions$', 'instructor.views.api.show_unit_extensions', + name='show_unit_extensions'), + url(r'^show_student_extensions$', 'instructor.views.api.show_student_extensions', + name='show_student_extensions'), # Grade downloads... url(r'^list_grade_downloads$', diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index d170d09360e6..2000c8390057 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -27,6 +27,9 @@ from lms.lib.xblock.runtime import handler_prefix +from .tools import get_units_with_due_date, title_or_url + + @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) def instructor_dashboard_2(request, course_id): @@ -55,6 +58,9 @@ def instructor_dashboard_2(request, course_id): _section_analytics(course_id, access), ] + 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 if settings.FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and \ is_studio_course and CourseAuthorization.instructor_email_enabled(course_id): @@ -161,6 +167,21 @@ def _section_student_admin(course_id, access): return section_data +def _section_extensions(course): + """ Provide data for the corresponding dashboard section """ + section_data = { + 'section_key': 'extensions', + 'section_display_name': _('Extensions'), + '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}), + 'show_student_extensions_url': reverse('show_student_extensions', kwargs={'course_id': course.id}), + } + return section_data + + def _section_data_download(course_id, access): """ Provide data for the corresponding dashboard section """ section_data = { diff --git a/lms/djangoapps/instructor/views/tools.py b/lms/djangoapps/instructor/views/tools.py index cbf6b6468a6c..703610cb6498 100644 --- a/lms/djangoapps/instructor/views/tools.py +++ b/lms/djangoapps/instructor/views/tools.py @@ -1,7 +1,49 @@ """ 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 +from xmodule.fields import Date + +DATE_FIELD = Date() + + +class DashboardError(Exception): + """ + Errors arising from use of the instructor dashboard. + """ + def response(self): + """ + Generate an instance of HttpResponseBadRequest for this error. + """ + error = unicode(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): + """ + Wrap the view. + """ + 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): @@ -23,3 +65,160 @@ def get_student_from_identifier(unique_student_identifier): else: student = User.objects.get(username=unique_student_identifier) return student + + +def parse_datetime(datestr): + """ + Convert user input date string into an instance of `datetime.datetime` in + UTC. + """ + 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 + 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) + + +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/envs/common.py b/lms/envs/common.py index 150b9ab8b846..7c47e2b75eba 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -163,6 +163,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': True, diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 30053d3da3e5..5cdece692766 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -23,7 +23,6 @@ ) TEMPLATE_DEBUG = True - 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 diff --git a/lms/static/coffee/src/instructor_dashboard/extensions.coffee b/lms/static/coffee/src/instructor_dashboard/extensions.coffee new file mode 100644 index 000000000000..acfcf7d36359 --- /dev/null +++ b/lms/static/coffee/src/instructor_dashboard/extensions.coffee @@ -0,0 +1,151 @@ +### +Extensions Section + +imports from other modules. +wrap in (-> ... apply) to defer evaluation +such that the value can be defined later than this assignment (file load order). +### + +plantTimeout = -> window.InstructorDashboard.util.plantTimeout.apply this, arguments +std_ajax_err = -> window.InstructorDashboard.util.std_ajax_err.apply this, arguments + +# Extensions Section +class Extensions + + constructor: (@$section) -> + # attach self to html + # so that instructor_dashboard.coffee can find this object + # to call event handlers like 'onClickTitle' + @$section.data 'wrapper', @ + + # Gather buttons + @$change_due_date = @$section.find("input[name='change-due-date']") + @$reset_due_date = @$section.find("input[name='reset-due-date']") + @$show_unit_extensions = @$section.find("input[name='show-unit-extensions']") + @$show_student_extensions = @$section.find("input[name='show-student-extensions']") + + # Gather notification areas + @$section.find(".request-response").hide() + @$section.find(".request-response-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() + @$student_input = @$section.find("#set-extension input[name='student']") + @$url_input = @$section.find("#set-extension select[name='url']") + @$due_datetime_input = @$section.find("#set-extension input[name='due_datetime']") + send_data = + student: @$student_input.val() + url: @$url_input.val() + due_datetime: @$due_datetime_input.val() + + $.ajax + dataType: 'json' + url: @$change_due_date.data 'endpoint' + data: send_data + success: (data) => @display_response "set-extension", data + error: (xhr) => @fail_with_error "set-extension", "Error changing due date", xhr + + @$reset_due_date.click => + @clear_display() + @$student_input = @$section.find("#reset-extension input[name='student']") + @$url_input = @$section.find("#reset-extension select[name='url']") + 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 "reset-extension", data + error: (xhr) => @fail_with_error "reset-extension", "Error reseting due date", xhr + + @$show_unit_extensions.click => + @clear_display() + @$grid_table.text 'Loading...' + + @$url_input = @$section.find("#view-extensions select[name='url']") + 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 "view-extensions", "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' + @$student_input = @$section.find("#view-extensions input[name='student']") + send_data = + student: @$student_input.val() + $.ajax + dataType: 'json' + url: url + data: send_data + error: (xhr) => @fail_with_error "view-extensions", "Error getting due dates", xhr + success: (data) => @display_grid data + + # handler for when the section title is clicked. + onClickTitle: -> + + fail_with_error: (id, msg, xhr) -> + $task_error = @$section.find("#" + id + " .request-response-error") + $task_response = @$section.find("#" + id + " .request-response") + @clear_display() + data = $.parseJSON xhr.responseText + msg += ": " + data['error'] + console.warn msg + $task_response.empty() + $task_error.empty() + $task_error.text msg + $task_error.show() + + display_response: (id, data) -> + $task_error = @$section.find("#" + id + " .request-response-error") + $task_response = @$section.find("#" + id + " .request-response") + $task_error.empty().hide() + $task_response.empty().text data + $task_response.show() + + display_grid: (data) -> + @clear_display() + @$grid_text.text data.title + + # 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() + @$section.find(".request-response-error").empty().hide() + @$section.find(".request-response").empty().hide() + +# export for use +# create parent namespaces if they do not already exist. +# abort if underscore can not be found. +if _? + _.defaults window, InstructorDashboard: {} + _.defaults window.InstructorDashboard, sections: {} + _.defaults window.InstructorDashboard.sections, + Extensions: Extensions diff --git a/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee index 313e5b496763..9d25ce670cbd 100644 --- a/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee @@ -161,6 +161,9 @@ setup_instructor_dashboard_sections = (idash_content) -> , constructor: window.InstructorDashboard.sections.StudentAdmin $element: idash_content.find ".#{CSS_IDASH_SECTION}#student_admin" + , + constructor: window.InstructorDashboard.sections.Extensions + $element: idash_content.find ".#{CSS_IDASH_SECTION}#extensions" , constructor: window.InstructorDashboard.sections.Email $element: idash_content.find ".#{CSS_IDASH_SECTION}#send_email" diff --git a/lms/templates/instructor/instructor_dashboard_2/extensions.html b/lms/templates/instructor/instructor_dashboard_2/extensions.html new file mode 100644 index 000000000000..e583148e9301 --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/extensions.html @@ -0,0 +1,111 @@ +<%! from django.utils.translation import ugettext as _ %> +<%page args="section_data"/> + ++ ${_("In this section, you have the ability to grant extensions on specific " + "units to individual students. Please note that the latest date is always " + "taken; you cannot use this tool to make an assignment due earlier for a " + "particular student.")} +
++ ${_("Specify the {platform_name} email address or username of a student " + "here:").format(platform_name=settings.PLATFORM_NAME)} + +
++ ${_("Choose the graded unit:")} + +
++ ${_("Specify the extension due date and time " + "(in UTC; please specify MM/DD/YYYY HH:MM)")} + +
+ + ++ +
++ ${_("Here you can see what extensions have been granted on particular " + "units or for a particular student.")} +
++ ${_("Choose a graded unit and click the button to obtain a list of all " + "students who have extensions for the given unit.")} +
++ ${_("Choose the graded unit:")} + + +
++ ${_("Specify a specific student to see all of that student's extensions.")} +
++ ${_("Specify the {platform_name} email address or username of a student " + "here:").format(platform_name=settings.PLATFORM_NAME)} + + +
+ + ++ ${_("Resetting a problem's due date rescinds a due date extension for a " + "student on a particular unit. This will revert the due date for the " + "student back to the problem's original due date.")} +
++ ${_("Specify the {platform_name} email address or username of a student " + "here:").format(platform_name=settings.PLATFORM_NAME)} + +
++ ${_("Choose the graded unit:")} + +
+ + ++ +
+