Commit 129c2447 by Sarina Canelake

Merge pull request #2062 from jazkarta/feature-idde2

Individual Due Date Extension feature
parents e475f835 831f907c
......@@ -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
......
"""
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
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)})
......@@ -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 = {
......
"""
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
......
###
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) ->
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"
,
......
<%! 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