diff --git a/.gitignore b/.gitignore index 46964cd2af10..028c6a870f2a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ cms/envs/private.py .idea/ .redcar/ +### NFS artifacts +.nfs* + ### OS X artifacts *.DS_Store .AppleDouble diff --git a/common/lib/xmodule/xmodule/capa_module.py b/common/lib/xmodule/xmodule/capa_module.py index 0034e9d89932..03c7315eab9d 100644 --- a/common/lib/xmodule/xmodule/capa_module.py +++ b/common/lib/xmodule/xmodule/capa_module.py @@ -22,6 +22,7 @@ from .fields import Timedelta, Date from django.utils.timezone import UTC from django.utils.translation import ugettext as _ +from .utils import get_extended_due_date log = logging.getLogger("mitx.courseware") @@ -95,6 +96,13 @@ class CapaFields(object): values={"min": 0}, scope=Scope.settings ) due = Date(help="Date that this problem is due by", scope=Scope.settings) + extended_due = Date( + help="Date that this problem is due by for a particular student. This " + "may differ from the global due date if an instructor has granted " + "an extension to the student.", + default=None, + scope=Scope.user_state, + ) graceperiod = Timedelta( help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings @@ -186,7 +194,7 @@ def __init__(self, *args, **kwargs): """ XModule.__init__(self, *args, **kwargs) - due_date = self.due + due_date = get_extended_due_date(self) if self.graceperiod is not None and due_date: self.close_date = due_date + self.graceperiod diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 6dc2ac045bff..bc7ee4f1e34d 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -20,6 +20,7 @@ "accept_file_upload", "skip_spelling_checks", "due", + "extended_due", "graceperiod", "weight", "min_to_calibrate", @@ -262,6 +263,13 @@ 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 " + "may differ from the global due date if an instructor has granted " + "an extension to the student.", + default=None, + scope=Scope.user_state, + ) graceperiod = Timedelta( help="Amount of time after the due date that submissions will be accepted", scope=Scope.settings diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index e1714ff96b61..820befb4fe2a 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -8,6 +8,7 @@ from xmodule.xml_module import XmlDescriptor from xblock.fields import Scope, Integer, String from .fields import Date +from .utils import get_extended_due_date log = logging.getLogger(__name__) @@ -20,6 +21,13 @@ class FolditFields(object): required_level = Integer(default=4, scope=Scope.settings) required_sublevel = Integer(default=5, scope=Scope.settings) due = Date(help="Date that this problem is due by", scope=Scope.settings) + extended_due = Date( + help="Date that this problem is due by for a particular student. This " + "may differ from the global due date if an instructor has granted " + "an extension to the student.", + default=None, + scope=Scope.user_state, + ) show_basic_score = String(scope=Scope.settings, default='false') show_leaderboard = String(scope=Scope.settings, default='false') @@ -40,7 +48,7 @@ def __init__(self, *args, **kwargs): show_leaderboard="false"/> """ XModule.__init__(self, *args, **kwargs) - self.due_time = self.due + self.due_time = get_extended_due_date(self) def is_complete(self): """ diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index a518034e610f..ef91f948562d 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -21,6 +21,13 @@ 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 " + "may differ from the global due date if an instructor has granted " + "an extension to the student.", + default=None, + scope=Scope.user_state, + ) giturl = String(help="url root for course data git repository", scope=Scope.settings) xqa_key = String(help="DO NOT USE", scope=Scope.settings) graceperiod = Timedelta( diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 8c3da6d5b23d..379d28561103 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -131,7 +131,9 @@ def __init__(self, system, location, definition, descriptor, 'peer_grade_finished_submissions_when_none_pending', False ) - due_date = instance_state.get('due', None) + due_date = instance_state.get('extended_due', None) + if due_date is None: + due_date = instance_state.get('due', None) grace_period_string = instance_state.get('graceperiod', None) try: diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 3bd101afcf96..dfd0ba79bd45 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -7,9 +7,10 @@ from pkg_resources import resource_string from .capa_module import ComplexEncoder from .x_module import XModule, module_attr -from xmodule.raw_module import RawDescriptor -from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem +from .raw_module import RawDescriptor +from .modulestore.exceptions import ItemNotFoundError, NoPathToItem from .timeinfo import TimeInfo +from .utils import get_extended_due_date from xblock.fields import Dict, String, Scope, Boolean, Float from xmodule.fields import Date, Timedelta @@ -46,6 +47,13 @@ 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 " + "may differ from the global due date if an instructor has granted " + "an extension to the student.", + default=None, + scope=Scope.user_state, + ) graceperiod = Timedelta( help="Amount of grace to give on the due date.", scope=Scope.settings @@ -130,7 +138,8 @@ def __init__(self, *args, **kwargs): raise 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 @@ -552,7 +561,7 @@ def peer_grading(self, _data=None): except (NoPathToItem, ItemNotFoundError): continue if descriptor: - problem['due'] = descriptor.due + problem['due'] = get_extended_due_date(descriptor) grace_period = descriptor.graceperiod try: problem_timeinfo = TimeInfo(problem['due'], grace_period) diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py index c0c9bfc1a0df..aea7b2e73622 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -3,15 +3,17 @@ from lxml import etree -from xmodule.mako_module import MakoModuleDescriptor -from xmodule.xml_module import XmlDescriptor -from xmodule.x_module import XModule -from xmodule.progress import Progress -from xmodule.exceptions import NotFoundError from xblock.fields import Integer, Scope from xblock.fragment import Fragment from pkg_resources import resource_string +from .exceptions import NotFoundError +from .fields import Date +from .mako_module import MakoModuleDescriptor +from .progress import Progress +from .x_module import XModule +from .xml_module import XmlDescriptor + log = logging.getLogger(__name__) # HACK: This shouldn't be hard-coded to two types @@ -25,6 +27,14 @@ class SequenceFields(object): # NOTE: Position is 1-indexed. This is silly, but there are now student # positions saved on prod, so it's not easy to fix. position = Integer(help="Last tab viewed in this sequence", scope=Scope.user_state) + due = Date(help="Date that this problem is due by", scope=Scope.settings) + extended_due = Date( + help="Date that this problem is due by for a particular student. This " + "may differ from the global due date if an instructor has granted " + "an extension to the student.", + default=None, + scope=Scope.user_state, + ) class SequenceModule(SequenceFields, XModule): diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 7cbdc130ae4c..af1c04948d03 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -68,17 +68,10 @@ def answer_key(): "_2_1") @staticmethod - def create(graceperiod=None, - due=None, - max_attempts=None, - showanswer=None, - rerandomize=None, - force_save_button=None, - attempts=None, + def create(attempts=None, problem_state=None, correct=False, - done=None, - text_customization=None + **kwargs ): """ All parameters are optional, and are added to the created problem if specified. @@ -99,24 +92,7 @@ def create(graceperiod=None, location = Location(["i4x", "edX", "capa_test", "problem", "SampleProblem{0}".format(CapaFactory.next_num())]) field_data = {'data': CapaFactory.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) @@ -328,6 +304,13 @@ def test_closed(self): due=self.yesterday_str) self.assertTrue(module.closed()) + def test_due_date_extension(self): + + module = CapaFactory.create( + max_attempts="1", attempts="0", due=self.yesterday_str, + extended_due=self.tomorrow_str) + self.assertFalse(module.closed()) + def test_parse_get_params(self): # We have to set up Django settings in order to use QueryDict diff --git a/common/lib/xmodule/xmodule/utils.py b/common/lib/xmodule/xmodule/utils.py new file mode 100644 index 000000000000..92e4a0d14d3c --- /dev/null +++ b/common/lib/xmodule/xmodule/utils.py @@ -0,0 +1,7 @@ + + +def get_extended_due_date(node): + due_date = getattr(node, 'extended_due', None) + if not due_date: + due_date = getattr(node, 'due', None) + return due_date diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 4640cd3f823b..4d9cd02ab37a 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -14,6 +14,7 @@ from xmodule import graders from xmodule.capa_module import CapaModule from xmodule.graders import Score +from xmodule.utils import get_extended_due_date from .models import StudentModule log = logging.getLogger("mitx.courseware") @@ -328,6 +329,7 @@ def progress_summary(student, request, course, field_data_cache): 'section_total': section_total, 'format': module_format, 'due': section_module.due, + 'due': get_extended_due_date(section_module), 'graded': graded, }) diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index f4b1c64a9fc1..078516b00cf1 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -24,6 +24,7 @@ from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.utils import get_extended_due_date from xmodule.x_module import ModuleSystem from xmodule_modifiers import replace_course_urls, replace_jump_to_id_urls, replace_static_urls, add_histogram, wrap_xblock @@ -110,7 +111,7 @@ def toc_for_course(user, request, course, active_chapter, active_section, field_ sections.append({'display_name': section.display_name_with_default, 'url_name': section.url_name, 'format': section.format if section.format is not None else '', - 'due': section.due, + 'due': get_extended_due_date(section), 'active': active, 'graded': section.graded, }) diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index facf64858056..5af743c59c81 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -6,13 +6,16 @@ Many of these GETs may become PUTs in the future. """ -import re +import datetime +import json import logging +import re import requests from django.conf import settings from django_future.csrf import ensure_csrf_cookie from django.views.decorators.cache import cache_control from django.core.urlresolvers import reverse +from django.utils import timezone from django.utils.translation import ugettext as _ from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from util.json_request import JsonResponse @@ -41,6 +44,11 @@ from bulk_email.models import CourseEmail +from .extensions import ( + dump_due_date_extensions_for_student, + dump_students_with_due_date_extensions, + set_due_date_extension) + log = logging.getLogger(__name__) @@ -889,6 +897,103 @@ def proxy_legacy_analytics(request, course_id): ) +def parse_datetime(s): + """ + Constructs a datetime object in UTC from user input. + """ + dt = None + try: + d, t = s.split() + mm, dd, yy = map(int, d.split('/')) + h, m = map(int, t.split(':')) + dt = datetime.datetime(yy, mm, dd, h, m, tzinfo=timezone.utc) + return dt + except: + error = _("Unable to parse date: ") + s + return HttpResponseBadRequest(json.dumps({'error': 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): + course = get_course_by_id(course_id) + student = get_student_from_identifier(request.GET.get('student')) + url = request.GET.get('url') + due_date = parse_datetime(request.GET.get('due_datetime')) + if isinstance(due_date, HttpResponse): + return due_date # error + error, unit = set_due_date_extension(course, url, student, due_date) + if error: + return HttpResponseBadRequest(json.dumps({'error': error})) + + studentname = student.profile.name + unitname = getattr(unit, 'display_name', None) + if unitname: + unitname = '{0} ({1})'.format(unitname, unit.location.url()) + msg = ( + 'Successfully changed due date for student {0} for {1} ' + 'to {2}').format(studentname, unitname, + due_date.strftime('%Y-%m-%d %H:%M')) + + return JsonResponse(msg) + + +@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): + course = get_course_by_id(course_id) + student = get_student_from_identifier(request.GET.get('student')) + url = request.GET.get('url') + error, unit = set_due_date_extension(course, url, student, None) + if error: + return HttpResponseBadRequest(json.dumps({'error': error})) + + studentname = student.profile.name + unitname = getattr(unit, 'display_name', None) + if unitname: + unitname = '{0} ({1})'.format(unitname, unit.location.url()) + due_date = unit.due + msg = ( + 'Successfully reset due date for student {0} for {1} ' + 'to {2}').format(studentname, unitname, + due_date.strftime('%Y-%m-%d %H:%M')) + + return JsonResponse(msg) + + +@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): + course = get_course_by_id(course_id) + url = request.GET.get('url') + error, data = dump_students_with_due_date_extensions(course, url) + if error: + return HttpResponseBadRequest(json.dumps({'error': error})) + header = data['header'] + data['data'] = [dict(zip(header, row)) for row in data['data']] + return JsonResponse(data) + + +@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): + student = get_student_from_identifier(request.GET.get('student')) + course = get_course_by_id(course_id) + error, data = dump_due_date_extensions_for_student(course, student) + if error: + return HttpResponseBadRequest(json.dumps({'error': error})) + header = data['header'] + data['data'] = [dict(zip(header, row)) for row in data['data']] + return JsonResponse(data) + + def _split_input_list(str_list): """ Separate out individual student email from the comma, or space separated string. diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 79cdcf7e69c6..08c43032b3d7 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -34,5 +34,13 @@ url(r'^proxy_legacy_analytics$', 'instructor.views.api.proxy_legacy_analytics', name="proxy_legacy_analytics"), url(r'^send_email$', - 'instructor.views.api.send_email', name="send_email") + 'instructor.views.api.send_email', name="send_email"), + url(r'^change_due_date$', 'instructor.views.api.change_due_date', + name='change_due_date'), + url(r'^reset_due_date$', 'instructor.views.api.reset_due_date', + name='reset_due_date'), + url(r'^show_unit_extensions$', 'instructor.views.api.show_unit_extensions', + name='show_unit_extensions'), + url(r'^show_student_extensions$', 'instructor.views.api.show_student_extensions', + name='show_student_extensions'), ) diff --git a/lms/djangoapps/instructor/views/extensions.py b/lms/djangoapps/instructor/views/extensions.py new file mode 100644 index 000000000000..327180a292d4 --- /dev/null +++ b/lms/djangoapps/instructor/views/extensions.py @@ -0,0 +1,128 @@ +import json +from courseware.models import StudentModule +from xmodule.fields import Date + +date_field = Date() + + +def set_due_date_extension(course, url, student, due_date): + """ + Sets a due date extension. Factored to be usable in both legacy and beta + instructor dashboards. + """ + unit = find_unit(course, url) + if not unit: + return "Couldn't find module for url: {0}".format(url), None + + def set_due_date(node): + try: + student_module = StudentModule.objects.get( + student_id=student.id, + course_id=course.id, + module_state_key=node.location.url() + ) + + state = json.loads(student_module.state) + state['extended_due'] = date_field.to_json(due_date) + student_module.state = json.dumps(state) + student_module.save() + except StudentModule.DoesNotExist: + pass + + for child in node.get_children(): + set_due_date(child) + + set_due_date(unit) + + return None, unit # no error + + +def find_unit(node, url): + """ + Find node in course tree for url. + """ + if node.location.url() == url: + return node + for child in node.get_children(): + found = find_unit(child, url) + if found: + return found + return None + + +def get_units_with_due_date(course): + units = [] + def visit(node, level=0): + if getattr(node, 'due', None): + units.append(node) + else: + for child in node.get_children(): + visit(child, level+1) + visit(course) + units.sort(key=title_or_url) + return units + + +def get_units_with_due_date_options(course): + def make_option(node): + return title_or_url(node), node.location.url() + return map(make_option, get_units_with_due_date(course)) + + +def title_or_url(node): + title = getattr(node, 'display_name', None) + if not title: + title = node.location.url() + return title + + +def dump_students_with_due_date_extensions(course, url): + unit = find_unit(course, url) + if not unit: + return "Couldn't find module for url: {0}".format(url), {} + + data = [] + query = StudentModule.objects.filter( + course_id=course.id, + module_state_key=url) + for sm in query: + state = json.loads(sm.state) + extended_due = state.get("extended_due") + if not extended_due: + continue + extended_due = date_field.from_json(extended_due) + extended_due = extended_due.strftime("%Y-%m-%d %H:%M") + fullname = sm.student.profile.name + data.append((sm.student.username, fullname, extended_due)) + data.sort(key=lambda x: x[0]) + return None, { + "header": ["Username", "Full Name", "Extended Due Date"], + "title": "Users with due date extensions for {0}".format( + title_or_url(unit)), + "data": data + } + + +def dump_due_date_extensions_for_student(course, student): + data = [] + units = get_units_with_due_date(course) + units = dict([(u.location.url(), u) for u in units]) + query = StudentModule.objects.filter( + course_id=course.id, + student_id=student.id) + for sm in query: + state = json.loads(sm.state) + if sm.module_state_key not in units: + continue + extended_due = state.get("extended_due") + if not extended_due: + continue + extended_due = date_field.from_json(extended_due) + extended_due = extended_due.strftime("%Y-%m-%d %H:%M") + title = title_or_url(units[sm.module_state_key]) + data.append((title, extended_due)) + return None, { + "header": ["Unit", "Extended Due Date"], + "title": "Due date extensions for {0} {1} ({2})".format( + student.first_name, student.last_name, student.username), + "data": data} diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index e579593a88a2..8f450d98a9fd 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -24,6 +24,9 @@ from student.models import CourseEnrollment from bulk_email.models import CourseAuthorization +from .extensions import get_units_with_due_date_options + + @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) def instructor_dashboard_2(request, course_id): @@ -52,6 +55,10 @@ def instructor_dashboard_2(request, course_id): _section_analytics(course_id), ] + if (settings.MITX_FEATURES.get('INDIVIDUAL_DUE_DATES') + and access['instructor']): + sections.insert(3, _section_extensions(course)) + enrollment_count = sections[0]['enrollment_count'] disable_buttons = False max_enrollment_for_buttons = settings.MITX_FEATURES.get("MAX_ENROLLMENT_INSTR_BUTTONS") @@ -148,6 +155,20 @@ 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': get_units_with_due_date_options(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): """ Provide data for the corresponding dashboard section """ section_data = { diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index f16b2c8350dd..1bf6f66dcbbe 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -3,6 +3,7 @@ """ from collections import defaultdict import csv +import datetime import json import logging from markupsafe import escape @@ -60,6 +61,13 @@ from xblock.fields import ScopeIds from django.utils.translation import ugettext as _u +from .extensions import ( + dump_students_with_due_date_extensions, + dump_due_date_extensions_for_student, + get_units_with_due_date_options, + set_due_date_extension) + + log = logging.getLogger(__name__) # internal commands for managing forum roles: @@ -202,6 +210,22 @@ def get_student_from_identifier(unique_student_identifier): msg += "Couldn't find student with that email or username. " return msg, student + def parse_datetime(s): + """ + Constructs a datetime object in UTC from user input. + """ + msg = "" + dt = None + try: + d, t = s.split() + mm, dd, yy = map(int, d.split('/')) + h, m = map(int, t.split(':')) + dt = datetime.datetime(yy, mm, dd, h, m, tzinfo=timezone.utc) + return msg, dt + except: + msg = "Unable to parse date: {0} ".format(s) + return msg, dt + # process actions from form POST action = request.POST.get('action', '') use_offline = request.POST.get('use_offline_grades', False) @@ -476,7 +500,7 @@ def domatch(x): except IndexError: log.debug('No grade for assignment %s (%s) for student %s' % (aidx, aname, x.email)) datatable['data'] = ddata - + datatable['title'] = 'Grades for assignment "%s"' % aname if 'Export CSV' in action: @@ -491,6 +515,90 @@ def domatch(x): msg2, _ = _do_remote_gradebook(request.user, course, 'post-grades', files=files) msg += msg2 + #---------------------------------------- + # Extensions + + elif "Change due date for student" in action: + # get the form data + unique_student_identifier = request.POST.get( + 'unique_student_identifier', '') + url = request.POST.get('url') + if not url: + msg += 'Must choose a unit. ' + + # try to uniquely id student by email address or username + message, student = get_student_from_identifier(unique_student_identifier) + msg += message + + # parse datetime + message, due_date = parse_datetime(request.POST.get('due_datetime')) + msg += message + + if url and student and due_date: + error, unit = set_due_date_extension( + course, url, student, due_date) + if error: + msg += '{0} '.format(error) + log.debug(error) + else: + studentname = student.profile.name + unitname = getattr(unit, 'display_name', None) + if unitname: + unitname = '{0} ({1})'.format(unitname, unit.location.url()) + msg += ( + 'Successfully changed due date for student {0} for {1} ' + 'to {2}').format(studentname, unitname, + due_date.strftime('%Y-%m-%d %H:%M')) + + elif "Reset due date for student" in action: + # get the form data + unique_student_identifier = request.POST.get( + 'unique_student_identifier', '') + url = request.POST.get('url') + if not url: + msg += 'Must choose a unit. ' + + # try to uniquely id student by email address or username + message, student = get_student_from_identifier(unique_student_identifier) + msg += message + + if url and student: + error, unit = set_due_date_extension( + course, url, student, None) + if error: + msg += '{0} '.format(error) + log.debug(error) + else: + studentname = student.profile.name + unitname = getattr(unit, 'display_name', None) + if unitname: + unitname = '{0} ({1})'.format(unitname, unit.location.url()) + msg += ( + 'Successfully reset due date for student {0} for {1} ' + 'to {2}').format(studentname, unitname, + unit.due.strftime('%Y-%m-%d %H:%M')) + + elif "Dump list of students with due date extensions" in action: + url = request.POST.get('url') + if not url: + msg += 'Must choose a unit. ' + else: + error, datatable = dump_students_with_due_date_extensions(course, url) + if error: + msg += '{0} '.format(error) + + elif "Dump due date extensions for student" in action: + unique_student_identifier = request.POST.get( + 'unique_student_identifier', '') + message, student = get_student_from_identifier(unique_student_identifier) + msg += message + + if student: + error, datatable = dump_due_date_extensions_for_student( + course, student) + if error: + msg += '{0} '.format(error) + #---------------------------------------- # Admin @@ -887,6 +995,8 @@ def get_analytics_result(analytics_name): 'disable_buttons': disable_buttons } + if settings.MITX_FEATURES.get('INDIVIDUAL_DUE_DATES'): + context['units_with_due_dates'] = get_units_with_due_date_options(course) if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BETA_DASHBOARD'): context['beta_dashboard_url'] = reverse('instructor_dashboard_2', kwargs={'course_id': course_id}) diff --git a/lms/djangoapps/instructor/views/tests/__init__.py b/lms/djangoapps/instructor/views/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/instructor/views/tests/test_extensions.py b/lms/djangoapps/instructor/views/tests/test_extensions.py new file mode 100644 index 000000000000..85744a3594da --- /dev/null +++ b/lms/djangoapps/instructor/views/tests/test_extensions.py @@ -0,0 +1,126 @@ +import datetime +import json +import mock +import unittest + + +due_date = datetime.datetime(2010, 5, 12, 2, 42) +extended_due_date = datetime.datetime(2013, 10, 12, 10, 30) + + +class ExtensionsTests(unittest.TestCase): + + def setUp(self): + self.course = DummyCourseNode( + 'Dummy Course', 'i4x://dummy', + children = [ + DummyCourseNode( + 'Homework 1', 'i4x://dummy/homework', + due=due_date, + children=[ + DummyCourseNode( + 'Problem 1', 'i4x://dummy/homework/problem')]), + DummyCourseNode( + 'Final Exam', 'i4x://dummy/exam', + due=due_date, + children=[ + DummyCourseNode( + 'Problem 2', 'i4x://dummy/exam/problem')])]) + self.homework, self.exam = self.course.children + + patcher = mock.patch('instructor.views.extensions.StudentModule') + self.StudentModule = patcher.start() + self.addCleanup(patcher.stop) + + self.student = mock.Mock(username='fred', first_name='Fred', + last_name='Flintstone') + + def test_set_due_date_extension_success(self): + from ..extensions import set_due_date_extension as fut + self.StudentModule.objects.get.return_value.state = json.dumps({}) + error, unit = fut(self.course, 'i4x://dummy/homework', self.student, + extended_due_date) + self.assertEqual(error, None) + state = json.loads(self.StudentModule.objects.get.return_value.state) + self.assertEqual(state['extended_due'], u'2013-10-12T10:30:00Z') + + def test_set_due_date_extension_bad_url(self): + from ..extensions import set_due_date_extension as fut + error, unit = fut(self.course, 'i4x://foo', self.student, + extended_due_date) + self.assertTrue(error.startswith("Couldn't find")) + + def test_dump_students_with_due_date_extensions(self): + from ..extensions import dump_students_with_due_date_extensions as fut + self.StudentModule.objects.filter.return_value = [ + mock.Mock( + student=mock.Mock( + username='fred', + first_name='Fred', + last_name='Flintstone'), + state=json.dumps({'extended_due': u'2013-10-12T10:30:00Z'})), + mock.Mock( + student=mock.Mock( + username='barney', + first_name='Barney', + last_name='Rubble'), + state=json.dumps({'extended_due': u'2013-10-13T10:30:00Z'})), + mock.Mock( + student=mock.Mock( + username='bambam', + first_name='Bam Bam', + last_name='Flintstone'), + state=json.dumps({}))] + error, table = fut(self.course, 'i4x://dummy/homework') + self.assertEqual(error, None) + self.assertEqual(table['header'], + ["Username", "Full Name", "Extended Due Date"]) + self.assertEqual(table['title'], + "Users with due date extensions for Homework 1") + self.assertEqual(table['data'], + [('barney', 'Barney Rubble', '2013-10-13 10:30'), + ('fred', 'Fred Flintstone', '2013-10-12 10:30')]) + + def test_dump_students_with_due_date_extensions_bad_url(self): + from ..extensions import dump_students_with_due_date_extensions as fut + error, table = fut(self.course, 'i4x://foo') + self.assertTrue(error.startswith("Couldn't find")) + self.assertEqual(table, {}) + + def test_dump_due_date_extensions_for_student(self): + from ..extensions import dump_due_date_extensions_for_student as fut + self.StudentModule.objects.filter.return_value = [ + mock.Mock( + module_state_key='i4x://dummy/homework', + state=json.dumps({})), + mock.Mock( + module_state_key='i4x://dummy/exam', + state=json.dumps({'extended_due': u'2013-10-13T10:30:00Z'}))] + error, table = fut(self.course, self.student) + self.assertEqual(error, None) + self.assertEqual(table['header'], ["Unit", "Extended Due Date"]) + self.assertEqual(table['title'], + "Due date extensions for Fred Flintstone (fred)") + self.assertEqual(table['data'], + [('Final Exam', '2013-10-13 10:30')]) + + +class DummyCourseNode(object): + id = 1 + children = [] + + def __init__(self, display_name, url, **kw): + self.display_name = display_name + self._url = url + self.__dict__.update(kw) + + @property + def location(self): + return self + + def url(self): + return self._url + + def get_children(self): + return self.children + diff --git a/lms/envs/common.py b/lms/envs/common.py index fe87c8900a5c..99147ff90365 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -160,6 +160,9 @@ # Enable instructor dash to submit background tasks 'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True, + # Enable instructor to assign individual due dates + 'INDIVIDUAL_DUE_DATES': False, + # Enable instructor dash beta version link 'ENABLE_INSTRUCTOR_BETA_DASHBOARD': True, diff --git a/lms/envs/dev.py b/lms/envs/dev.py index e873861196dc..149664b52eb5 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -19,6 +19,7 @@ USE_I18N = True TEMPLATE_DEBUG = True +MITX_FEATURES['INDIVIDUAL_DUE_DATES'] = True # crossi dev, delete if checked in MITX_FEATURES['DISABLE_START_DATES'] = False MITX_FEATURES['ENABLE_SQL_TRACKING_LOGS'] = True diff --git a/lms/static/coffee/src/instructor_dashboard/extensions.coffee b/lms/static/coffee/src/instructor_dashboard/extensions.coffee new file mode 100644 index 000000000000..4d59bc214f71 --- /dev/null +++ b/lms/static/coffee/src/instructor_dashboard/extensions.coffee @@ -0,0 +1,147 @@ +### +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 inputs + @$student_input = @$section.find("input[name='student']") + @$url_input = @$section.find("select[name='url']") + @$due_datetime_input = @$section.find("input[name='due_datetime']") + + # 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 + @$task_response = @$section.find(".request-response") + @$task_error = @$section.find(".request-response-error") + @$task_response.hide() + @$task_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() + 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 data + error: (xhr) => @fail_with_error "Error changing due date", xhr + + @$reset_due_date.click => + @clear_display() + 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 data + error: (xhr) => @fail_with_error "Error reseting due date", xhr + + @$show_unit_extensions.click => + @clear_display() + @$grid_table.text 'Loading...' + + 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 "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' + send_data = + student: @$student_input.val() + $.ajax + dataType: 'json' + url: url + data: send_data + error: (xhr) => @fail_with_error "Error getting due dates", xhr + success: (data) => @display_grid data + + # handler for when the section title is clicked. + onClickTitle: -> + + fail_with_error: (msg, xhr) -> + @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: (data) -> + @$task_error.empty().hide() + @$task_response.empty().text data + @$task_response.show() + + display_grid: (data) -> + @clear_display() + @$grid_text.text data.title + + # display on a SlickGrid + options = + enableCellNavigation: true + enableColumnReorder: false + forceFitColumns: true + + columns = ({id: col, field: col, name: col} for col in data.header) + grid_data = data.data + + $table_placeholder = $ '
', class: 'slickgrid', style: 'min-height: 400px' + @$grid_table.append $table_placeholder + grid = new Slick.Grid($table_placeholder, grid_data, columns, options) + + clear_display: -> + @$grid_text.empty() + @$grid_table.empty() + @$task_error.empty().hide() + @$task_response.empty().hide() + +# export for use +# create parent namespaces if they do not already exist. +# abort if underscore can not be found. +if _? + _.defaults window, InstructorDashboard: {} + _.defaults window.InstructorDashboard, sections: {} + _.defaults window.InstructorDashboard.sections, + Extensions: Extensions diff --git a/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee b/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee index c645fcf67e51..47ffd2ed9890 100644 --- a/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee +++ b/lms/static/coffee/src/instructor_dashboard/instructor_dashboard.coffee @@ -161,6 +161,9 @@ setup_instructor_dashboard_sections = (idash_content) -> , constructor: window.InstructorDashboard.sections.StudentAdmin $element: idash_content.find ".#{CSS_IDASH_SECTION}#student_admin" + , + constructor: window.InstructorDashboard.sections.Extensions + $element: idash_content.find ".#{CSS_IDASH_SECTION}#extensions" , constructor: window.InstructorDashboard.sections.Email $element: idash_content.find ".#{CSS_IDASH_SECTION}#send_email" diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index deded1d0ed3d..928b49fb2fe4 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -126,6 +126,9 @@

${_("Instructor Dashboard")}

${_("Export grades to remote gradebook")}


%endif + %if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_BACKGROUND_TASKS'):

${_("Course-specific grade adjustment")}

@@ -314,6 +318,43 @@

${_("Student-specific grade inspection and adjustment")}

%endif + +##----------------------------------------------------------------------------- +%if modeflag.get('Extensions'): + %if settings.MITX_FEATURES.get('INDIVIDUAL_DUE_DATES'): +

${_("Individual due date extensions")}

+ +

+ ${_("Specify the {platform_name} email address or username of a student here:").format(platform_name=settings.PLATFORM_NAME)} + +

+

+ ${_("Choose the graded unit:")} + +

+

+ ${_("Specify the individual due date and time in UTC:")} + +

+

+ + +

+

+ + +

+
+ %endif +%endif + + ##----------------------------------------------------------------------------- %if modeflag.get('Psychometrics'): diff --git a/lms/templates/instructor/instructor_dashboard_2/extensions.html b/lms/templates/instructor/instructor_dashboard_2/extensions.html new file mode 100644 index 000000000000..e01ca77a5a69 --- /dev/null +++ b/lms/templates/instructor/instructor_dashboard_2/extensions.html @@ -0,0 +1,42 @@ +<%! from django.utils.translation import ugettext as _ %> +<%page args="section_data"/> + +

${_("Individual due date extensions")}

+ +

+ ${_("Specify the {platform_name} email address or username of a student here:").format(platform_name=settings.PLATFORM_NAME)} + +

+

+ ${_("Choose the graded unit:")} + +

+

+ ${_("Specify the individual due date and time in UTC:")} + +

+

+

+

+ + +

+

+ + +

+ +
+
+
+