Commit 831f907c by Chris Rossi

Add individual due dates feature.

Adds a feature to the edX platform which allows instructors to set
individual due dates for students on particular coursework. This code is
meant primarily for on-campus use--it is not intended that this feature
would be used for MOOCs. It adds a new tab, "Extensions", to the beta
instructor dashboard which allows changing due dates per student. This
feature is enabled by setting FEATURES['INDIVIDUAL_DUE_DATES'] = True.
parent 7b6cf0dc
...@@ -19,6 +19,9 @@ cms/envs/private.py ...@@ -19,6 +19,9 @@ cms/envs/private.py
.redcar/ .redcar/
codekit-config.json codekit-config.json
### NFS artifacts
.nfs*
### OS X artifacts ### OS X artifacts
*.DS_Store *.DS_Store
.AppleDouble .AppleDouble
......
...@@ -22,6 +22,7 @@ from xmodule.exceptions import NotFoundError, ProcessingError ...@@ -22,6 +22,7 @@ from xmodule.exceptions import NotFoundError, ProcessingError
from xblock.fields import Scope, String, Boolean, Dict, Integer, Float from xblock.fields import Scope, String, Boolean, Dict, Integer, Float
from .fields import Timedelta, Date from .fields import Timedelta, Date
from django.utils.timezone import UTC from django.utils.timezone import UTC
from .util.duedate import get_extended_due_date
log = logging.getLogger("edx.courseware") log = logging.getLogger("edx.courseware")
...@@ -95,6 +96,14 @@ class CapaFields(object): ...@@ -95,6 +96,14 @@ class CapaFields(object):
values={"min": 0}, scope=Scope.settings values={"min": 0}, scope=Scope.settings
) )
due = Date(help="Date that this problem is due by", 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( graceperiod = Timedelta(
help="Amount of time after the due date that submissions will be accepted", help="Amount of time after the due date that submissions will be accepted",
scope=Scope.settings scope=Scope.settings
...@@ -191,7 +200,7 @@ class CapaModule(CapaFields, XModule): ...@@ -191,7 +200,7 @@ class CapaModule(CapaFields, XModule):
""" """
super(CapaModule, self).__init__(*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: if self.graceperiod is not None and due_date:
self.close_date = due_date + self.graceperiod self.close_date = due_date + self.graceperiod
......
...@@ -20,6 +20,7 @@ V1_SETTINGS_ATTRIBUTES = [ ...@@ -20,6 +20,7 @@ V1_SETTINGS_ATTRIBUTES = [
"accept_file_upload", "accept_file_upload",
"skip_spelling_checks", "skip_spelling_checks",
"due", "due",
"extended_due",
"graceperiod", "graceperiod",
"weight", "weight",
"min_to_calibrate", "min_to_calibrate",
...@@ -262,6 +263,14 @@ class CombinedOpenEndedFields(object): ...@@ -262,6 +263,14 @@ class CombinedOpenEndedFields(object):
help="Date that this problem is due by", help="Date that this problem is due by",
scope=Scope.settings 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( graceperiod = Timedelta(
help="Amount of time after the due date that submissions will be accepted", help="Amount of time after the due date that submissions will be accepted",
scope=Scope.settings scope=Scope.settings
......
...@@ -8,6 +8,7 @@ from xmodule.x_module import XModule ...@@ -8,6 +8,7 @@ from xmodule.x_module import XModule
from xmodule.xml_module import XmlDescriptor from xmodule.xml_module import XmlDescriptor
from xblock.fields import Scope, Integer, String from xblock.fields import Scope, Integer, String
from .fields import Date from .fields import Date
from .util.duedate import get_extended_due_date
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -20,6 +21,14 @@ class FolditFields(object): ...@@ -20,6 +21,14 @@ class FolditFields(object):
required_level = Integer(default=4, scope=Scope.settings) required_level = Integer(default=4, scope=Scope.settings)
required_sublevel = Integer(default=5, scope=Scope.settings) required_sublevel = Integer(default=5, scope=Scope.settings)
due = Date(help="Date that this problem is due by", 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_basic_score = String(scope=Scope.settings, default='false')
show_leaderboard = String(scope=Scope.settings, default='false') show_leaderboard = String(scope=Scope.settings, default='false')
...@@ -40,7 +49,7 @@ class FolditModule(FolditFields, XModule): ...@@ -40,7 +49,7 @@ class FolditModule(FolditFields, XModule):
show_leaderboard="false"/> show_leaderboard="false"/>
""" """
super(FolditModule, self).__init__(*args, **kwargs) super(FolditModule, self).__init__(*args, **kwargs)
self.due_time = self.due self.due_time = get_extended_due_date(self)
def is_complete(self): def is_complete(self):
""" """
......
...@@ -21,6 +21,14 @@ class InheritanceMixin(XBlockMixin): ...@@ -21,6 +21,14 @@ class InheritanceMixin(XBlockMixin):
scope=Scope.settings scope=Scope.settings
) )
due = Date(help="Date that this problem is due by", 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) giturl = String(help="url root for course data git repository", scope=Scope.settings)
xqa_key = String(help="DO NOT USE", scope=Scope.settings) xqa_key = String(help="DO NOT USE", scope=Scope.settings)
graceperiod = Timedelta( graceperiod = Timedelta(
......
...@@ -6,9 +6,9 @@ from xmodule.timeinfo import TimeInfo ...@@ -6,9 +6,9 @@ from xmodule.timeinfo import TimeInfo
from xmodule.capa_module import ComplexEncoder from xmodule.capa_module import ComplexEncoder
from xmodule.progress import Progress from xmodule.progress import Progress
from xmodule.stringify import stringify_children from xmodule.stringify import stringify_children
from xmodule.open_ended_grading_classes import self_assessment_module from xmodule.open_ended_grading_classes import self_assessment_module
from xmodule.open_ended_grading_classes import open_ended_module from xmodule.open_ended_grading_classes import open_ended_module
from functools import partial 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 .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.peer_grading_service import PeerGradingService, MockPeerGradingService, GradingServiceError
from xmodule.open_ended_grading_classes.openendedchild import OpenEndedChild from xmodule.open_ended_grading_classes.openendedchild import OpenEndedChild
...@@ -132,8 +132,7 @@ class CombinedOpenEndedV1Module(): ...@@ -132,8 +132,7 @@ class CombinedOpenEndedV1Module():
'peer_grade_finished_submissions_when_none_pending', False '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) grace_period_string = instance_state.get('graceperiod', None)
try: try:
self.timeinfo = TimeInfo(due_date, grace_period_string) self.timeinfo = TimeInfo(due_date, grace_period_string)
......
...@@ -7,9 +7,10 @@ from datetime import datetime ...@@ -7,9 +7,10 @@ from datetime import datetime
from pkg_resources import resource_string from pkg_resources import resource_string
from .capa_module import ComplexEncoder from .capa_module import ComplexEncoder
from .x_module import XModule, module_attr from .x_module import XModule, module_attr
from xmodule.raw_module import RawDescriptor from .raw_module import RawDescriptor
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem from .modulestore.exceptions import ItemNotFoundError, NoPathToItem
from .timeinfo import TimeInfo from .timeinfo import TimeInfo
from .util.duedate import get_extended_due_date
from xblock.fields import Dict, String, Scope, Boolean, Float from xblock.fields import Dict, String, Scope, Boolean, Float
from xmodule.fields import Date, Timedelta from xmodule.fields import Date, Timedelta
...@@ -46,6 +47,14 @@ class PeerGradingFields(object): ...@@ -46,6 +47,14 @@ class PeerGradingFields(object):
due = Date( due = Date(
help="Due date that should be displayed.", help="Due date that should be displayed.",
scope=Scope.settings) 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( graceperiod = Timedelta(
help="Amount of grace to give on the due date.", help="Amount of grace to give on the due date.",
scope=Scope.settings scope=Scope.settings
...@@ -128,7 +137,8 @@ class PeerGradingModule(PeerGradingFields, XModule): ...@@ -128,7 +137,8 @@ class PeerGradingModule(PeerGradingFields, XModule):
self.linked_problem = self.system.get_module(linked_descriptors[0]) self.linked_problem = self.system.get_module(linked_descriptors[0])
try: try:
self.timeinfo = TimeInfo(self.due, self.graceperiod) self.timeinfo = TimeInfo(
get_extended_due_date(self), self.graceperiod)
except Exception: except Exception:
log.error("Error parsing due date information in location {0}".format(self.location)) log.error("Error parsing due date information in location {0}".format(self.location))
raise raise
...@@ -556,7 +566,7 @@ class PeerGradingModule(PeerGradingFields, XModule): ...@@ -556,7 +566,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
except (NoPathToItem, ItemNotFoundError): except (NoPathToItem, ItemNotFoundError):
continue continue
if descriptor: if descriptor:
problem['due'] = descriptor.due problem['due'] = get_extended_due_date(descriptor)
grace_period = descriptor.graceperiod grace_period = descriptor.graceperiod
try: try:
problem_timeinfo = TimeInfo(problem['due'], grace_period) problem_timeinfo = TimeInfo(problem['due'], grace_period)
......
...@@ -3,15 +3,17 @@ import logging ...@@ -3,15 +3,17 @@ import logging
from lxml import etree 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.fields import Integer, Scope
from xblock.fragment import Fragment from xblock.fragment import Fragment
from pkg_resources import resource_string 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__) log = logging.getLogger(__name__)
# HACK: This shouldn't be hard-coded to two types # HACK: This shouldn't be hard-coded to two types
...@@ -25,6 +27,15 @@ class SequenceFields(object): ...@@ -25,6 +27,15 @@ class SequenceFields(object):
# NOTE: Position is 1-indexed. This is silly, but there are now student # NOTE: Position is 1-indexed. This is silly, but there are now student
# positions saved on prod, so it's not easy to fix. # positions saved on prod, so it's not easy to fix.
position = Integer(help="Last tab viewed in this sequence", scope=Scope.user_state) 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): class SequenceModule(SequenceFields, XModule):
......
...@@ -78,17 +78,10 @@ class CapaFactory(object): ...@@ -78,17 +78,10 @@ class CapaFactory(object):
@classmethod @classmethod
def create(cls, def create(cls,
graceperiod=None,
due=None,
max_attempts=None,
showanswer=None,
rerandomize=None,
force_save_button=None,
attempts=None, attempts=None,
problem_state=None, problem_state=None,
correct=False, correct=False,
done=None, **kwargs
text_customization=None
): ):
""" """
All parameters are optional, and are added to the created problem if specified. All parameters are optional, and are added to the created problem if specified.
...@@ -109,24 +102,7 @@ class CapaFactory(object): ...@@ -109,24 +102,7 @@ class CapaFactory(object):
location = Location(["i4x", "edX", "capa_test", "problem", location = Location(["i4x", "edX", "capa_test", "problem",
"SampleProblem{0}".format(cls.next_num())]) "SampleProblem{0}".format(cls.next_num())])
field_data = {'data': cls.sample_problem_xml} field_data = {'data': cls.sample_problem_xml}
field_data.update(kwargs)
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
descriptor = Mock(weight="1") descriptor = Mock(weight="1")
if problem_state is not None: if problem_state is not None:
field_data.update(problem_state) field_data.update(problem_state)
...@@ -379,6 +355,13 @@ class CapaModuleTest(unittest.TestCase): ...@@ -379,6 +355,13 @@ class CapaModuleTest(unittest.TestCase):
due=self.yesterday_str) due=self.yesterday_str)
self.assertTrue(module.closed()) 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): def test_parse_get_params(self):
# Valid GET param dict # Valid GET param dict
......
"""
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)
"""
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
...@@ -6,9 +6,7 @@ import random ...@@ -6,9 +6,7 @@ import random
import logging import logging
from contextlib import contextmanager from contextlib import contextmanager
from collections import defaultdict
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User
from django.db import transaction from django.db import transaction
from django.test.client import RequestFactory from django.test.client import RequestFactory
...@@ -16,13 +14,13 @@ from dogapi import dog_stats_api ...@@ -16,13 +14,13 @@ from dogapi import dog_stats_api
from courseware import courses from courseware import courses
from courseware.model_data import FieldDataCache from courseware.model_data import FieldDataCache
from xblock.fields import Scope
from xmodule import graders from xmodule import graders
from xmodule.graders import Score from xmodule.graders import Score
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.util.duedate import get_extended_due_date
from .models import StudentModule 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") log = logging.getLogger("edx.courseware")
...@@ -372,7 +370,7 @@ def _progress_summary(student, request, course): ...@@ -372,7 +370,7 @@ def _progress_summary(student, request, course):
'scores': scores, 'scores': scores,
'section_total': section_total, 'section_total': section_total,
'format': module_format, 'format': module_format,
'due': section_module.due, 'due': get_extended_due_date(section_module),
'graded': graded, 'graded': graded,
}) })
......
...@@ -14,7 +14,7 @@ from django.core.exceptions import PermissionDenied ...@@ -14,7 +14,7 @@ from django.core.exceptions import PermissionDenied
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
import django.utils 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 capa.xqueue_interface import XQueueInterface
from courseware.access import has_access from courseware.access import has_access
...@@ -37,6 +37,7 @@ from xmodule.exceptions import NotFoundError, ProcessingError ...@@ -37,6 +37,7 @@ from xmodule.exceptions import NotFoundError, ProcessingError
from xmodule.modulestore import Location from xmodule.modulestore import Location
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError 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_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.lti_module import LTIModule
from xmodule.x_module import XModuleDescriptor from xmodule.x_module import XModuleDescriptor
...@@ -112,7 +113,7 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_ ...@@ -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, sections.append({'display_name': section.display_name_with_default,
'url_name': section.url_name, 'url_name': section.url_name,
'format': section.format if section.format is not None else '', 'format': section.format if section.format is not None else '',
'due': section.due, 'due': get_extended_due_date(section),
'active': active, 'active': active,
'graded': section.graded, 'graded': section.graded,
}) })
......
...@@ -15,12 +15,13 @@ from django.core.urlresolvers import reverse ...@@ -15,12 +15,13 @@ from django.core.urlresolvers import reverse
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django_comment_common.models import FORUM_ROLE_COMMUNITY_TA from django_comment_common.models import FORUM_ROLE_COMMUNITY_TA
from django.core import mail from django.core import mail
from django.utils.timezone import utc
from django.contrib.auth.models import User from django.contrib.auth.models import User
from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from courseware.tests.helpers import LoginEnrollmentTestCase 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 student.tests.factories import UserFactory
from courseware.tests.factories import StaffFactory, InstructorFactory from courseware.tests.factories import StaffFactory, InstructorFactory
...@@ -34,6 +35,8 @@ import instructor.views.api ...@@ -34,6 +35,8 @@ import instructor.views.api
from instructor.views.api import _split_input_list, _msk_from_problem_urlname, common_exceptions_400 from instructor.views.api import _split_input_list, _msk_from_problem_urlname, common_exceptions_400
from instructor_task.api_helper import AlreadyRunningError from instructor_task.api_helper import AlreadyRunningError
from .test_tools import get_extended_due
@common_exceptions_400 @common_exceptions_400
def view_success(request): # pylint: disable=W0613 def view_success(request): # pylint: disable=W0613
...@@ -1426,3 +1429,133 @@ class TestInstructorAPIHelpers(TestCase): ...@@ -1426,3 +1429,133 @@ class TestInstructorAPIHelpers(TestCase):
def test_msk_from_problem_urlname_error(self): def test_msk_from_problem_urlname_error(self):
args = ('notagoodcourse', 'L2Node1') args = ('notagoodcourse', 'L2Node1')
_msk_from_problem_urlname(*args) _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)})
...@@ -6,9 +6,9 @@ JSON views which the instructor dashboard requests. ...@@ -6,9 +6,9 @@ JSON views which the instructor dashboard requests.
Many of these GETs may become PUTs in the future. Many of these GETs may become PUTs in the future.
""" """
import re
import logging
import json import json
import logging
import re
import requests import requests
from django.conf import settings from django.conf import settings
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
...@@ -35,7 +35,6 @@ from instructor_task.views import get_task_completion_info ...@@ -35,7 +35,6 @@ from instructor_task.views import get_task_completion_info
from instructor_task.models import GradesStore from instructor_task.models import GradesStore
import instructor.enrollment as enrollment import instructor.enrollment as enrollment
from instructor.enrollment import enroll_email, unenroll_email, get_email_params 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 from instructor.access import list_with_level, allow_access, revoke_access, update_forum_role
import analytics.basic import analytics.basic
import analytics.distributions import analytics.distributions
...@@ -44,6 +43,17 @@ import csv ...@@ -44,6 +43,17 @@ import csv
from bulk_email.models import CourseEmail 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__) log = logging.getLogger(__name__)
...@@ -991,6 +1001,87 @@ def proxy_legacy_analytics(request, course_id): ...@@ -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): def _split_input_list(str_list):
""" """
Separate out individual student email from the comma, or space separated string. Separate out individual student email from the comma, or space separated string.
......
...@@ -37,6 +37,14 @@ urlpatterns = patterns('', # nopep8 ...@@ -37,6 +37,14 @@ urlpatterns = patterns('', # nopep8
'instructor.views.api.proxy_legacy_analytics', name="proxy_legacy_analytics"), 'instructor.views.api.proxy_legacy_analytics', name="proxy_legacy_analytics"),
url(r'^send_email$', url(r'^send_email$',
'instructor.views.api.send_email', name="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... # Grade downloads...
url(r'^list_grade_downloads$', url(r'^list_grade_downloads$',
......
...@@ -27,6 +27,9 @@ from bulk_email.models import CourseAuthorization ...@@ -27,6 +27,9 @@ from bulk_email.models import CourseAuthorization
from lms.lib.xblock.runtime import handler_prefix from lms.lib.xblock.runtime import handler_prefix
from .tools import get_units_with_due_date, title_or_url
@ensure_csrf_cookie @ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
def instructor_dashboard_2(request, course_id): def instructor_dashboard_2(request, course_id):
...@@ -55,6 +58,9 @@ def instructor_dashboard_2(request, course_id): ...@@ -55,6 +58,9 @@ def instructor_dashboard_2(request, course_id):
_section_analytics(course_id, access), _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 # Gate access to course email by feature flag & by course-specific authorization
if settings.FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and \ if settings.FEATURES['ENABLE_INSTRUCTOR_EMAIL'] and \
is_studio_course and CourseAuthorization.instructor_email_enabled(course_id): is_studio_course and CourseAuthorization.instructor_email_enabled(course_id):
...@@ -161,6 +167,21 @@ def _section_student_admin(course_id, access): ...@@ -161,6 +167,21 @@ def _section_student_admin(course_id, access):
return section_data 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): def _section_data_download(course_id, access):
""" Provide data for the corresponding dashboard section """ """ Provide data for the corresponding dashboard section """
section_data = { section_data = {
......
""" """
Tools for the instructor dashboard Tools for the instructor dashboard
""" """
import dateutil
import json
from django.contrib.auth.models import User 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): def strip_if_string(value):
if isinstance(value, basestring): if isinstance(value, basestring):
...@@ -23,3 +65,160 @@ def get_student_from_identifier(unique_student_identifier): ...@@ -23,3 +65,160 @@ def get_student_from_identifier(unique_student_identifier):
else: else:
student = User.objects.get(username=unique_student_identifier) student = User.objects.get(username=unique_student_identifier)
return student 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}
...@@ -163,6 +163,9 @@ FEATURES = { ...@@ -163,6 +163,9 @@ FEATURES = {
# Enable instructor dash to submit background tasks # Enable instructor dash to submit background tasks
'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True, 'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True,
# Enable instructor to assign individual due dates
'INDIVIDUAL_DUE_DATES': False,
# Enable instructor dash beta version link # Enable instructor dash beta version link
'ENABLE_INSTRUCTOR_BETA_DASHBOARD': True, 'ENABLE_INSTRUCTOR_BETA_DASHBOARD': True,
......
...@@ -23,7 +23,6 @@ LANGUAGES = ( ...@@ -23,7 +23,6 @@ LANGUAGES = (
) )
TEMPLATE_DEBUG = True TEMPLATE_DEBUG = True
FEATURES['DISABLE_START_DATES'] = False FEATURES['DISABLE_START_DATES'] = False
FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True
FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False # Enable to test subdomains--otherwise, want all courses to show up FEATURES['SUBDOMAIN_COURSE_LISTINGS'] = False # Enable to test subdomains--otherwise, want all courses to show up
......
###
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 = $ '<div/>', 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
...@@ -162,6 +162,9 @@ setup_instructor_dashboard_sections = (idash_content) -> ...@@ -162,6 +162,9 @@ setup_instructor_dashboard_sections = (idash_content) ->
constructor: window.InstructorDashboard.sections.StudentAdmin constructor: window.InstructorDashboard.sections.StudentAdmin
$element: idash_content.find ".#{CSS_IDASH_SECTION}#student_admin" $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 constructor: window.InstructorDashboard.sections.Email
$element: idash_content.find ".#{CSS_IDASH_SECTION}#send_email" $element: idash_content.find ".#{CSS_IDASH_SECTION}#send_email"
, ,
......
<%! from django.utils.translation import ugettext as _ %>
<%page args="section_data"/>
<div id="set-extension">
<h2>${_("Individual due date extensions")}</h2>
<p>
${_("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.")}
</p>
<p>
${_("Specify the {platform_name} email address or username of a student "
"here:").format(platform_name=settings.PLATFORM_NAME)}
<input type="text" name="student">
</p>
<p>
${_("Choose the graded unit:")}
<select name="url">
<option value="">Choose one</option>
%for title, url in section_data['units_with_due_dates']:
<option value="${url}">${title}</option>
%endfor
</select>
</p>
<p>
${_("Specify the extension due date and time "
"(in UTC; please specify MM/DD/YYYY HH:MM)")}
<input type="text" name="due_datetime"
placeholder="MM/DD/YYYY HH:MM"/>
</p>
<p class="request-response"></p>
<p class="request-response-error"></p>
<p>
<input type="button" name="change-due-date"
value="${_("Change due date for student")}"
data-endpoint="${section_data['change_due_date_url']}">
</p>
</div>
<hr/>
<div id="view-extensions">
<h2>${_("Viewing granted extensions")}</h2>
<p>
${_("Here you can see what extensions have been granted on particular "
"units or for a particular student.")}
</p>
<p>
${_("Choose a graded unit and click the button to obtain a list of all "
"students who have extensions for the given unit.")}
</p>
<p>
${_("Choose the graded unit:")}
<select name="url">
<option value="">Choose one</option>
%for title, url in section_data['units_with_due_dates']:
<option value="${url}">${title}</option>
%endfor
</select>
<input type="button" name="show-unit-extensions"
value="${_("List all students with due date extensions")}"
data-endpoint="${section_data['show_unit_extensions_url']}">
</p>
<p>
${_("Specify a specific student to see all of that student's extensions.")}
</p>
<p>
${_("Specify the {platform_name} email address or username of a student "
"here:").format(platform_name=settings.PLATFORM_NAME)}
<input type="text" name="student">
<input type="button" name="show-student-extensions"
value="${_("List date extensions for student")}"
data-endpoint="${section_data['show_student_extensions_url']}">
</p>
<p class="request-response"></p>
<p class="request-response-error"></p>
<div class="data-display">
<p class="data-display-text"></p>
<p class="data-display-table"></p>
</div>
</div>
<hr/>
<div id="reset-extension">
<h2>${_("Resetting extensions")}</h2>
<p>
${_("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.")}
</p>
<p>
${_("Specify the {platform_name} email address or username of a student "
"here:").format(platform_name=settings.PLATFORM_NAME)}
<input type="text" name="student">
</p>
<p>
${_("Choose the graded unit:")}
<select name="url">
<option value="">Choose one</option>
%for title, url in section_data['units_with_due_dates']:
<option value="${url}">${title}</option>
%endfor
</select>
</p>
<p class="request-response"></p>
<p class="request-response-error"></p>
<p>
<input type="button" name="reset-due-date"
value="${_("Reset due date for student")}"
data-endpoint="${section_data['reset_due_date_url']}">
</p>
</div>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment