Merge pull request #2062 from jazkarta/feature-idde2
Individual Due Date Extension feature
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -19,6 +19,9 @@ cms/envs/private.py
|
||||
.redcar/
|
||||
codekit-config.json
|
||||
|
||||
### NFS artifacts
|
||||
.nfs*
|
||||
|
||||
### OS X artifacts
|
||||
*.DS_Store
|
||||
.AppleDouble
|
||||
|
||||
@@ -22,6 +22,7 @@ from xmodule.exceptions import NotFoundError, ProcessingError
|
||||
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 @@ class CapaModule(CapaFields, XModule):
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -20,6 +20,7 @@ V1_SETTINGS_ATTRIBUTES = [
|
||||
"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
|
||||
|
||||
@@ -8,6 +8,7 @@ from xmodule.x_module import XModule
|
||||
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 @@ class FolditModule(FolditFields, XModule):
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -6,9 +6,9 @@ from xmodule.timeinfo import TimeInfo
|
||||
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 @@ class CombinedOpenEndedV1Module():
|
||||
'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)
|
||||
|
||||
@@ -7,9 +7,10 @@ from datetime import datetime
|
||||
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 @@ class PeerGradingModule(PeerGradingFields, XModule):
|
||||
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 @@ class PeerGradingModule(PeerGradingFields, XModule):
|
||||
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)
|
||||
|
||||
@@ -3,15 +3,17 @@ import logging
|
||||
|
||||
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):
|
||||
|
||||
@@ -78,17 +78,10 @@ class CapaFactory(object):
|
||||
|
||||
@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 @@ class CapaFactory(object):
|
||||
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 @@ class CapaModuleTest(unittest.TestCase):
|
||||
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
|
||||
|
||||
62
common/lib/xmodule/xmodule/tests/test_util_duedate.py
Normal file
62
common/lib/xmodule/xmodule/tests/test_util_duedate.py
Normal file
@@ -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)
|
||||
23
common/lib/xmodule/xmodule/util/duedate.py
Normal file
23
common/lib/xmodule/xmodule/util/duedate.py
Normal file
@@ -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
|
||||
@@ -6,9 +6,7 @@ import random
|
||||
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 dogapi import dog_stats_api
|
||||
|
||||
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,
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from django.core.exceptions import PermissionDenied
|
||||
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.exceptions import NotFoundError, ProcessingError
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -15,12 +15,13 @@ from django.core.urlresolvers import reverse
|
||||
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 @@ import instructor.views.api
|
||||
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 @@ class TestInstructorAPIHelpers(TestCase):
|
||||
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)})
|
||||
|
||||
346
lms/djangoapps/instructor/tests/test_tools.py
Normal file
346
lms/djangoapps/instructor/tests/test_tools.py
Normal file
@@ -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)
|
||||
@@ -6,9 +6,9 @@ JSON views which the instructor dashboard requests.
|
||||
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.views import get_task_completion_info
|
||||
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 @@ import csv
|
||||
|
||||
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.
|
||||
|
||||
@@ -37,6 +37,14 @@ urlpatterns = patterns('', # nopep8
|
||||
'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$',
|
||||
|
||||
@@ -27,6 +27,9 @@ from bulk_email.models import CourseAuthorization
|
||||
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 = {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -163,6 +163,9 @@ FEATURES = {
|
||||
# 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,
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ LANGUAGES = (
|
||||
)
|
||||
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
|
||||
|
||||
151
lms/static/coffee/src/instructor_dashboard/extensions.coffee
Normal file
151
lms/static/coffee/src/instructor_dashboard/extensions.coffee
Normal file
@@ -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 = $ '<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
|
||||
@@ -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"
|
||||
|
||||
111
lms/templates/instructor/instructor_dashboard_2/extensions.html
Normal file
111
lms/templates/instructor/instructor_dashboard_2/extensions.html
Normal file
@@ -0,0 +1,111 @@
|
||||
<%! 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>
|
||||
|
||||
Reference in New Issue
Block a user