diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index c1b78748e4..b2127bee41 100644 --- a/common/djangoapps/student/roles.py +++ b/common/djangoapps/student/roles.py @@ -267,6 +267,14 @@ class LibraryUserRole(CourseRole): super(LibraryUserRole, self).__init__(self.ROLE, *args, **kwargs) +class CourseCcxCoachRole(CourseRole): + """A CCX Coach""" + ROLE = 'ccx_coach' + + def __init__(self, *args, **kwargs): + super(CourseCcxCoachRole, self).__init__(self.ROLE, *args, **kwargs) + + class OrgStaffRole(OrgRole): """An organization staff member""" def __init__(self, *args, **kwargs): diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 8a06ff60c8..9aad5d538a 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -662,6 +662,17 @@ def dashboard(request): if course.pre_requisite_courses) courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites) + ccx_membership_triplets = [] + if settings.FEATURES.get('CUSTOM_COURSES_EDX', False): + from ccx import ACTIVE_CCX_KEY + from ccx.utils import get_ccx_membership_triplets + ccx_membership_triplets = get_ccx_membership_triplets( + user, course_org_filter, org_filter_out_set + ) + # should we deselect any active CCX at this time so that we don't have + # to change the URL for viewing a course? I think so. + request.session[ACTIVE_CCX_KEY] = None + context = { 'enrollment_message': enrollment_message, 'course_enrollment_pairs': course_enrollment_pairs, @@ -693,6 +704,7 @@ def dashboard(request): 'provider_states': [], 'order_history_list': order_history_list, 'courses_requirements_not_met': courses_requirements_not_met, + 'ccx_membership_triplets': ccx_membership_triplets, } if third_party_auth.is_enabled(): @@ -1810,6 +1822,16 @@ def activate_account(request, key): if cea.auto_enroll: CourseEnrollment.enroll(student[0], cea.course_id) + # enroll student in any pending CCXs he/she may have if auto_enroll flag is set + if settings.FEATURES.get('CUSTOM_COURSES_EDX'): + from ccx.models import CcxMembership, CcxFutureMembership + ccxfms = CcxFutureMembership.objects.filter( + email=student[0].email + ) + for ccxfm in ccxfms: + if ccxfm.auto_enroll: + CcxMembership.auto_enroll(student[0], ccxfm) + resp = render_to_response( "registration/activation_complete.html", { diff --git a/common/lib/xmodule/xmodule/capa_base.py b/common/lib/xmodule/xmodule/capa_base.py index 3b8ce984c2..3082933fe8 100644 --- a/common/lib/xmodule/xmodule/capa_base.py +++ b/common/lib/xmodule/xmodule/capa_base.py @@ -26,7 +26,6 @@ from xmodule.exceptions import NotFoundError 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 from xmodule.capa_base_constants import RANDOMIZATION, SHOWANSWER from django.conf import settings @@ -107,14 +106,6 @@ 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 @@ -218,7 +209,7 @@ class CapaMixin(CapaFields): def __init__(self, *args, **kwargs): super(CapaMixin, self).__init__(*args, **kwargs) - due_date = get_extended_due_date(self) + due_date = self.due 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 03b1f217d5..7e3d1fe8cc 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -23,7 +23,6 @@ V1_SETTINGS_ATTRIBUTES = [ "accept_file_upload", "skip_spelling_checks", "due", - "extended_due", "graceperiod", "weight", "min_to_calibrate", @@ -258,16 +257,6 @@ class CombinedOpenEndedFields(object): help=_("Date that this problem is due by"), scope=Scope.settings ) - extended_due = Date( - help=_( - "Date that this problem is due by for a particular student. This " - "can be set by an instructor, and will override the global due " - "date if it is set to a date that is later than the global due " - "date." - ), - default=None, - scope=Scope.user_state, - ) graceperiod = Timedelta( help=_("Amount of time after the due date that submissions will be accepted"), scope=Scope.settings diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 48a6807510..e71fa5fdb8 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -11,7 +11,7 @@ from datetime import datetime import dateutil.parser from lazy import lazy - +from xmodule.exceptions import UndefinedContext from xmodule.seq_module import SequenceDescriptor, SequenceModule from xmodule.graders import grader_from_conf from xmodule.tabs import CourseTabList @@ -835,8 +835,16 @@ class CourseFields(object): ) +class CourseModule(CourseFields, SequenceModule): # pylint: disable=abstract-method + """ + The CourseDescriptor needs its module_class to be a SequenceModule, but some code that + expects a CourseDescriptor to have all its fields can fail if it gets a SequenceModule instead. + This class is to make sure that all the fields are present in all cases. + """ + + class CourseDescriptor(CourseFields, SequenceDescriptor): - module_class = SequenceModule + module_class = CourseModule def __init__(self, *args, **kwargs): """ @@ -1213,6 +1221,14 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): """ + # If this descriptor has been bound to a student, return the corresponding + # XModule. If not, just use the descriptor itself + try: + module = getattr(self, '_xmodule', None) + if not module: + module = self + except UndefinedContext: + module = self all_descriptors = [] graded_sections = {} diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index 666d247bfd..de5957db94 100644 --- a/common/lib/xmodule/xmodule/foldit_module.py +++ b/common/lib/xmodule/xmodule/foldit_module.py @@ -8,7 +8,6 @@ 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__) @@ -21,14 +20,6 @@ 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') @@ -49,7 +40,7 @@ class FolditModule(FolditFields, XModule): show_leaderboard="false"/> """ super(FolditModule, self).__init__(*args, **kwargs) - self.due_time = get_extended_due_date(self) + self.due_time = self.due def is_complete(self): """ diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py index 2727e2fa9c..d6293647c7 100644 --- a/common/lib/xmodule/xmodule/modulestore/inheritance.py +++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py @@ -44,14 +44,6 @@ class InheritanceMixin(XBlockMixin): help=_("Enter the default date by which problems are due."), 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, - ) visible_to_staff_only = Boolean( help=_("If true, can be seen only by course staff, regardless of start date."), default=False, 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 8e49027203..d99884f392 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 @@ -8,7 +8,6 @@ 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 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 from xmodule.open_ended_grading_classes.openendedchild import OpenEndedChild @@ -150,7 +149,7 @@ class CombinedOpenEndedV1Module(object): 'peer_grade_finished_submissions_when_none_pending', False ) - due_date = get_extended_due_date(instance_state) + due_date = instance_state.get('due', None) grace_period_string = instance_state.get('graceperiod', None) try: self.timeinfo = TimeInfo(due_date, grace_period_string) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 086580f06e..6e0da07a8f 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -11,7 +11,6 @@ from xmodule.fields import Date, Timedelta from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem from xmodule.raw_module import RawDescriptor from xmodule.timeinfo import TimeInfo -from xmodule.util.duedate import get_extended_due_date from xmodule.x_module import XModule, module_attr from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, MockPeerGradingService @@ -52,14 +51,6 @@ 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 @@ -141,8 +132,7 @@ class PeerGradingModule(PeerGradingFields, XModule): self.linked_problem = self.system.get_module(linked_descriptors[0]) try: - self.timeinfo = TimeInfo( - get_extended_due_date(self), self.graceperiod) + self.timeinfo = TimeInfo(self.due, self.graceperiod) except Exception: log.error("Error parsing due date information in location {0}".format(self.location)) raise @@ -570,7 +560,7 @@ class PeerGradingModule(PeerGradingFields, XModule): except (NoPathToItem, ItemNotFoundError): continue if descriptor: - problem['due'] = get_extended_due_date(descriptor) + problem['due'] = descriptor.due 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 1bdc289440..137b7dd31a 100644 --- a/common/lib/xmodule/xmodule/seq_module.py +++ b/common/lib/xmodule/xmodule/seq_module.py @@ -36,14 +36,6 @@ class SequenceFields(object): help=_("Enter the date by which problems are due."), 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, - ) # Entrance Exam flag -- see cms/contentstore/views/entrance_exam.py for usage is_entrance_exam = Boolean( diff --git a/common/lib/xmodule/xmodule/tabs.py b/common/lib/xmodule/xmodule/tabs.py index d4b986d3b6..a28432918a 100644 --- a/common/lib/xmodule/xmodule/tabs.py +++ b/common/lib/xmodule/xmodule/tabs.py @@ -193,6 +193,7 @@ class CourseTab(object): 'edxnotes': EdxNotesTab, 'syllabus': SyllabusTab, 'instructor': InstructorTab, # not persisted + 'ccx_coach': CcxCoachTab, # not persisted } tab_type = tab_dict.get('type') @@ -375,6 +376,10 @@ class DiscussionTab(EnrolledOrStaffTab): ) def can_display(self, course, settings, is_user_authenticated, is_user_staff, is_user_enrolled): + if settings.FEATURES.get('CUSTOM_COURSES_EDX', False): + from ccx.overrides import get_current_ccx # pylint: disable=import-error + if get_current_ccx(): + return False super_can_display = super(DiscussionTab, self).can_display( course, settings, is_user_authenticated, is_user_staff, is_user_enrolled ) @@ -733,6 +738,42 @@ class InstructorTab(StaffTab): ) +class CcxCoachTab(CourseTab): + """ + A tab for the custom course coaches. + """ + type = 'ccx_coach' + + def __init__(self, tab_dict=None): # pylint: disable=unused-argument + super(CcxCoachTab, self).__init__( + name=_('CCX Coach'), + tab_id=self.type, + link_func=link_reverse_func('ccx_coach_dashboard'), + ) + + def can_display(self, course, settings, *args, **kw): + """ + Since we don't get the user here, we use a thread local defined in the ccx + overrides to get it, then use the course to get the coach role and find out if + the user is one. + """ + user_is_coach = False + if settings.FEATURES.get('CUSTOM_COURSES_EDX', False): + from opaque_keys.edx.locations import SlashSeparatedCourseKey + from student.roles import CourseCcxCoachRole # pylint: disable=import-error + from ccx.overrides import get_current_request # pylint: disable=import-error + course_id = course.id.to_deprecated_string() + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) + role = CourseCcxCoachRole(course_key) + request = get_current_request() + if request is not None: + user_is_coach = role.has_user(request.user) + super_can_display = super(CcxCoachTab, self).can_display( + course, settings, *args, **kw + ) + return user_is_coach and super_can_display + + class CourseTabList(List): """ An XBlock field class that encapsulates a collection of Tabs in a course. @@ -833,6 +874,9 @@ class CourseTabList(List): instructor_tab = InstructorTab() if instructor_tab.can_display(course, settings, is_user_authenticated, is_user_staff, is_user_enrolled): yield instructor_tab + ccx_coach_tab = CcxCoachTab() + if ccx_coach_tab.can_display(course, settings, is_user_authenticated, is_user_staff, is_user_enrolled): + yield ccx_coach_tab @staticmethod def iterate_displayable_cms( diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index d1309fded4..2c666743ae 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -430,13 +430,6 @@ 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 @@ -1742,7 +1735,7 @@ class TestProblemCheckTracking(unittest.TestCase): self.maxDiff = None def test_choice_answer_text(self): - factory = self.capa_factory_for_problem_xml("""\ + xml = """\

What color is the open ocean on a sunny day?

@@ -1767,7 +1760,11 @@ class TestProblemCheckTracking(unittest.TestCase):
- """) + """ + + # Whitespace screws up comparisons + xml = ''.join(line.strip() for line in xml.split('\n')) + factory = self.capa_factory_for_problem_xml(xml) module = factory.create() answer_input_dict = { diff --git a/conf/locale/eo/LC_MESSAGES/django.po b/conf/locale/eo/LC_MESSAGES/django.po index 9114ffed40..eb351c00af 100644 --- a/conf/locale/eo/LC_MESSAGES/django.po +++ b/conf/locale/eo/LC_MESSAGES/django.po @@ -1554,19 +1554,6 @@ msgstr "" msgid "Date that this problem is due by" msgstr "Däté thät thïs prößlém ïs düé ßý Ⱡ'σяєм ι#" -#: common/lib/xmodule/xmodule/capa_base.py -#: common/lib/xmodule/xmodule/combined_open_ended_module.py -#: common/lib/xmodule/xmodule/peer_grading_module.py -msgid "" -"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." -msgstr "" -"Däté thät thïs prößlém ïs düé ßý för ä pärtïçülär stüdént. Thïs çän ßé sét " -"ßý än ïnstrüçtör, änd wïll övérrïdé thé glößäl düé däté ïf ït ïs sét tö ä " -"däté thät ïs lätér thän thé glößäl düé däté. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " -"¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє#" - #: common/lib/xmodule/xmodule/capa_base.py #: common/lib/xmodule/xmodule/combined_open_ended_module.py msgid "Amount of time after the due date that submissions will be accepted" @@ -11920,6 +11907,19 @@ msgstr "änd çhöösé ýöür stüdént träçk Ⱡ'σяєм #" msgid "and proceed to verification" msgstr "änd pröçééd tö vérïfïçätïön Ⱡ'σяєм#" +#. Translators: This line appears next a checkbox which users can leave +#. checked +#. or uncheck in order +#. to indicate whether they want to receive emails from the organization +#. offering the course. +#: lms/templates/courseware/mktg_course_about.html +msgid "" +"I would like to receive email about other {organization_full_name} programs " +"and offers." +msgstr "" +"Ì wöüld lïké tö réçéïvé émäïl äßöüt öthér {organization_full_name} prögräms " +"änd öfférs. Ⱡ'σяєм ιρѕυм ∂σłσя #" + #: lms/templates/courseware/mktg_course_about.html msgid "Enrollment Is Closed" msgstr "Énröllmént Ìs Çlöséd Ⱡ'σя#" diff --git a/lms/djangoapps/ccx/__init__.py b/lms/djangoapps/ccx/__init__.py new file mode 100644 index 0000000000..3584f0006a --- /dev/null +++ b/lms/djangoapps/ccx/__init__.py @@ -0,0 +1,4 @@ +""" +we use this to mark the active ccx, for use by ccx middleware and some views +""" +ACTIVE_CCX_KEY = '_ccx_id' diff --git a/lms/djangoapps/ccx/migrations/0001_initial.py b/lms/djangoapps/ccx/migrations/0001_initial.py new file mode 100644 index 0000000000..6ec2fe67ce --- /dev/null +++ b/lms/djangoapps/ccx/migrations/0001_initial.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- +# pylint: disable=invalid-name, missing-docstring, unused-argument, unused-import, line-too-long +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'CustomCourseForEdX' + db.create_table('ccx_customcourseforedx', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)), + ('display_name', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('coach', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + )) + db.send_create_signal('ccx', ['CustomCourseForEdX']) + + # Adding model 'CcxMembership' + db.create_table('ccx_ccxmembership', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('ccx', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['ccx.CustomCourseForEdX'])), + ('student', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('active', self.gf('django.db.models.fields.BooleanField')(default=False)), + )) + db.send_create_signal('ccx', ['CcxMembership']) + + # Adding model 'CcxFutureMembership' + db.create_table('ccx_ccxfuturemembership', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('ccx', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['ccx.CustomCourseForEdX'])), + ('email', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('auto_enroll', self.gf('django.db.models.fields.BooleanField')(default=False)), + )) + db.send_create_signal('ccx', ['CcxFutureMembership']) + + # Adding model 'CcxFieldOverride' + db.create_table('ccx_ccxfieldoverride', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('ccx', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['ccx.CustomCourseForEdX'])), + ('location', self.gf('xmodule_django.models.LocationKeyField')(max_length=255, db_index=True)), + ('field', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('value', self.gf('django.db.models.fields.TextField')(default='null')), + )) + db.send_create_signal('ccx', ['CcxFieldOverride']) + + # Adding unique constraint on 'CcxFieldOverride', fields ['ccx', 'location', 'field'] + db.create_unique('ccx_ccxfieldoverride', ['ccx_id', 'location', 'field']) + + def backwards(self, orm): + # Removing unique constraint on 'CcxFieldOverride', fields ['ccx', 'location', 'field'] + db.delete_unique('ccx_ccxfieldoverride', ['ccx_id', 'location', 'field']) + + # Deleting model 'CustomCourseForEdX' + db.delete_table('ccx_customcourseforedx') + + # Deleting model 'CcxMembership' + db.delete_table('ccx_ccxmembership') + + # Deleting model 'CcxFutureMembership' + db.delete_table('ccx_ccxfuturemembership') + + # Deleting model 'CcxFieldOverride' + db.delete_table('ccx_ccxfieldoverride') + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'ccx.ccxfieldoverride': { + 'Meta': {'unique_together': "(('ccx', 'location', 'field'),)", 'object_name': 'CcxFieldOverride'}, + 'ccx': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ccx.CustomCourseForEdX']"}), + 'field': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'location': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'ccx.ccxfuturemembership': { + 'Meta': {'object_name': 'CcxFutureMembership'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'ccx': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ccx.CustomCourseForEdX']"}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'ccx.ccxmembership': { + 'Meta': {'object_name': 'CcxMembership'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'ccx': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ccx.CustomCourseForEdX']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'ccx.customcourseforedx': { + 'Meta': {'object_name': 'CustomCourseForEdX'}, + 'coach': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + } + } + + complete_apps = ['ccx'] diff --git a/lms/djangoapps/ccx/migrations/__init__.py b/lms/djangoapps/ccx/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/ccx/models.py b/lms/djangoapps/ccx/models.py new file mode 100644 index 0000000000..048b63d9c2 --- /dev/null +++ b/lms/djangoapps/ccx/models.py @@ -0,0 +1,77 @@ +""" +Models for the custom course feature +""" +from django.contrib.auth.models import User +from django.db import models + +from student.models import CourseEnrollment, AlreadyEnrolledError # pylint: disable=import-error +from xmodule_django.models import CourseKeyField, LocationKeyField # pylint: disable=import-error + + +class CustomCourseForEdX(models.Model): + """ + A Custom Course. + """ + course_id = CourseKeyField(max_length=255, db_index=True) + display_name = models.CharField(max_length=255) + coach = models.ForeignKey(User, db_index=True) + + +class CcxMembership(models.Model): + """ + Which students are in a CCX? + """ + ccx = models.ForeignKey(CustomCourseForEdX, db_index=True) + student = models.ForeignKey(User, db_index=True) + active = models.BooleanField(default=False) + + @classmethod + def auto_enroll(cls, student, future_membership): + """convert future_membership to an active membership + """ + if not future_membership.auto_enroll: + msg = "auto enrollment not allowed for {}" + raise ValueError(msg.format(future_membership)) + membership = cls( + ccx=future_membership.ccx, student=student, active=True + ) + try: + CourseEnrollment.enroll( + student, future_membership.ccx.course_id, check_access=True + ) + except AlreadyEnrolledError: + # if the user is already enrolled in the course, great! + pass + + membership.save() + future_membership.delete() + + @classmethod + def memberships_for_user(cls, user, active=True): + """ + active memberships for a user + """ + return cls.objects.filter(student=user, active__exact=active) + + +class CcxFutureMembership(models.Model): + """ + Which emails for non-users are waiting to be added to CCX on registration + """ + ccx = models.ForeignKey(CustomCourseForEdX, db_index=True) + email = models.CharField(max_length=255) + auto_enroll = models.BooleanField(default=0) + + +class CcxFieldOverride(models.Model): + """ + Field overrides for custom courses. + """ + ccx = models.ForeignKey(CustomCourseForEdX, db_index=True) + location = LocationKeyField(max_length=255, db_index=True) + field = models.CharField(max_length=255) + + class Meta: # pylint: disable=missing-docstring,old-style-class + unique_together = (('ccx', 'location', 'field'),) + + value = models.TextField(default='null') diff --git a/lms/djangoapps/ccx/overrides.py b/lms/djangoapps/ccx/overrides.py new file mode 100644 index 0000000000..752de4bca6 --- /dev/null +++ b/lms/djangoapps/ccx/overrides.py @@ -0,0 +1,184 @@ +""" +API related to providing field overrides for individual students. This is used +by the individual custom courses feature. +""" +import json +import threading + +from contextlib import contextmanager + +from django.db import transaction, IntegrityError + +from courseware.field_overrides import FieldOverrideProvider # pylint: disable=import-error +from ccx import ACTIVE_CCX_KEY # pylint: disable=import-error + +from .models import CcxMembership, CcxFieldOverride + + +class CustomCoursesForEdxOverrideProvider(FieldOverrideProvider): + """ + A concrete implementation of + :class:`~courseware.field_overrides.FieldOverrideProvider` which allows for + overrides to be made on a per user basis. + """ + def get(self, block, name, default): + """ + Just call the get_override_for_ccx method if there is a ccx + """ + ccx = get_current_ccx() + if ccx: + return get_override_for_ccx(ccx, block, name, default) + return default + + +class _CcxContext(threading.local): + """ + A threading local used to implement the `with_ccx` context manager, that + keeps track of the CCX currently set as the context. + """ + ccx = None + request = None + + +_CCX_CONTEXT = _CcxContext() + + +@contextmanager +def ccx_context(ccx): + """ + A context manager which can be used to explicitly set the CCX that is in + play for field overrides. This mechanism overrides the standard mechanism + of looking in the user's session to see if they are enrolled in a CCX and + viewing that CCX. + """ + prev = _CCX_CONTEXT.ccx + _CCX_CONTEXT.ccx = ccx + yield + _CCX_CONTEXT.ccx = prev + + +def get_current_ccx(): + """ + Return the ccx that is active for this request. + """ + return _CCX_CONTEXT.ccx + + +def get_current_request(): + """ + Return the active request, so that we can get context information in places + where it is limited, like in the tabs. + """ + request = _CCX_CONTEXT.request + return request + + +def get_override_for_ccx(ccx, block, name, default=None): + """ + Gets the value of the overridden field for the `ccx`. `block` and `name` + specify the block and the name of the field. If the field is not + overridden for the given ccx, returns `default`. + """ + if not hasattr(block, '_ccx_overrides'): + block._ccx_overrides = {} # pylint: disable=protected-access + overrides = block._ccx_overrides.get(ccx.id) # pylint: disable=protected-access + if overrides is None: + overrides = _get_overrides_for_ccx(ccx, block) + block._ccx_overrides[ccx.id] = overrides # pylint: disable=protected-access + return overrides.get(name, default) + + +def _get_overrides_for_ccx(ccx, block): + """ + Returns a dictionary mapping field name to overriden value for any + overrides set on this block for this CCX. + """ + overrides = {} + query = CcxFieldOverride.objects.filter( + ccx=ccx, + location=block.location + ) + for override in query: + field = block.fields[override.field] + value = field.from_json(json.loads(override.value)) + overrides[override.field] = value + return overrides + + +@transaction.commit_on_success +def override_field_for_ccx(ccx, block, name, value): + """ + Overrides a field for the `ccx`. `block` and `name` specify the block + and the name of the field on that block to override. `value` is the + value to set for the given field. + """ + field = block.fields[name] + value = json.dumps(field.to_json(value)) + try: + override = CcxFieldOverride.objects.create( + ccx=ccx, + location=block.location, + field=name, + value=value) + except IntegrityError: + transaction.commit() + override = CcxFieldOverride.objects.get( + ccx=ccx, + location=block.location, + field=name) + override.value = value + override.save() + if hasattr(block, '_ccx_overrides'): + del block._ccx_overrides[ccx.id] # pylint: disable=protected-access + + +def clear_override_for_ccx(ccx, block, name): + """ + Clears a previously set field override for the `ccx`. `block` and `name` + specify the block and the name of the field on that block to clear. + This function is idempotent--if no override is set, nothing action is + performed. + """ + try: + CcxFieldOverride.objects.get( + ccx=ccx, + location=block.location, + field=name).delete() + + if hasattr(block, '_ccx_overrides'): + del block._ccx_overrides[ccx.id] # pylint: disable=protected-access + + except CcxFieldOverride.DoesNotExist: + pass + + +class CcxMiddleware(object): + """ + Checks to see if current session is examining a CCX and sets the CCX as + the current CCX for the override machinery if so. + """ + def process_request(self, request): + """ + Do the check. + """ + ccx_id = request.session.get(ACTIVE_CCX_KEY, None) + if ccx_id is not None: + try: + membership = CcxMembership.objects.get( + student=request.user, active=True, ccx__id__exact=ccx_id + ) + _CCX_CONTEXT.ccx = membership.ccx + except CcxMembership.DoesNotExist: + # if there is no membership, be sure to unset the active ccx + _CCX_CONTEXT.ccx = None + request.session.pop(ACTIVE_CCX_KEY) + + _CCX_CONTEXT.request = request + + def process_response(self, request, response): # pylint: disable=unused-argument + """ + Clean up afterwards. + """ + _CCX_CONTEXT.ccx = None + _CCX_CONTEXT.request = None + return response diff --git a/lms/djangoapps/ccx/tests/__init__.py b/lms/djangoapps/ccx/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/ccx/tests/factories.py b/lms/djangoapps/ccx/tests/factories.py new file mode 100644 index 0000000000..36c976970f --- /dev/null +++ b/lms/djangoapps/ccx/tests/factories.py @@ -0,0 +1,22 @@ +""" +Dummy factories for tests +""" +from factory.django import DjangoModelFactory +from ccx.models import CustomCourseForEdX # pylint: disable=import-error +from ccx.models import CcxMembership # pylint: disable=import-error +from ccx.models import CcxFutureMembership # pylint: disable=import-error + + +class CcxFactory(DjangoModelFactory): # pylint: disable=missing-docstring + FACTORY_FOR = CustomCourseForEdX + display_name = "Test CCX" + id = None # pylint: disable=redefined-builtin, invalid-name + + +class CcxMembershipFactory(DjangoModelFactory): # pylint: disable=missing-docstring + FACTORY_FOR = CcxMembership + active = False + + +class CcxFutureMembershipFactory(DjangoModelFactory): # pylint: disable=missing-docstring + FACTORY_FOR = CcxFutureMembership diff --git a/lms/djangoapps/ccx/tests/test_models.py b/lms/djangoapps/ccx/tests/test_models.py new file mode 100644 index 0000000000..49689c4133 --- /dev/null +++ b/lms/djangoapps/ccx/tests/test_models.py @@ -0,0 +1,127 @@ +""" +tests for the models +""" +from student.models import CourseEnrollment # pylint: disable=import-error +from student.roles import CourseCcxCoachRole # pylint: disable=import-error +from student.tests.factories import ( # pylint: disable=import-error + AdminFactory, + CourseEnrollmentFactory, + UserFactory, +) +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from .factories import ( + CcxFactory, + CcxFutureMembershipFactory, +) +from ..models import ( + CcxMembership, + CcxFutureMembership, +) + + +class TestCcxMembership(ModuleStoreTestCase): + """Unit tests for the CcxMembership model + """ + + def setUp(self): + """common setup for all tests""" + super(TestCcxMembership, self).setUp() + self.course = course = CourseFactory.create() + coach = AdminFactory.create() + role = CourseCcxCoachRole(course.id) + role.add_users(coach) + self.ccx = CcxFactory(course_id=course.id, coach=coach) + enrollment = CourseEnrollmentFactory.create(course_id=course.id) + self.enrolled_user = enrollment.user + self.unenrolled_user = UserFactory.create() + + def create_future_enrollment(self, user, auto_enroll=True): + """ + utility method to create future enrollment + """ + pfm = CcxFutureMembershipFactory.create( + ccx=self.ccx, + email=user.email, + auto_enroll=auto_enroll + ) + return pfm + + def has_course_enrollment(self, user): + """ + utility method to create future enrollment + """ + enrollment = CourseEnrollment.objects.filter( + user=user, course_id=self.course.id + ) + return enrollment.exists() + + def has_ccx_membership(self, user): + """ + verify ccx membership + """ + membership = CcxMembership.objects.filter( + student=user, ccx=self.ccx, active=True + ) + return membership.exists() + + def has_ccx_future_membership(self, user): + """ + verify future ccx membership + """ + future_membership = CcxFutureMembership.objects.filter( + email=user.email, ccx=self.ccx + ) + return future_membership.exists() + + def call_mut(self, student, future_membership): + """ + Call the method undser test + """ + CcxMembership.auto_enroll(student, future_membership) + + def test_ccx_auto_enroll_unregistered_user(self): + """verify auto_enroll works when user is not enrolled in the MOOC + + n.b. After auto_enroll, user will have both a MOOC enrollment and a + CCX membership + """ + user = self.unenrolled_user + pfm = self.create_future_enrollment(user) + self.assertTrue(self.has_ccx_future_membership(user)) + self.assertFalse(self.has_course_enrollment(user)) + # auto_enroll user + self.call_mut(user, pfm) + + self.assertTrue(self.has_course_enrollment(user)) + self.assertTrue(self.has_ccx_membership(user)) + self.assertFalse(self.has_ccx_future_membership(user)) + + def test_ccx_auto_enroll_registered_user(self): + """verify auto_enroll works when user is enrolled in the MOOC + """ + user = self.enrolled_user + pfm = self.create_future_enrollment(user) + self.assertTrue(self.has_ccx_future_membership(user)) + self.assertTrue(self.has_course_enrollment(user)) + + self.call_mut(user, pfm) + + self.assertTrue(self.has_course_enrollment(user)) + self.assertTrue(self.has_ccx_membership(user)) + self.assertFalse(self.has_ccx_future_membership(user)) + + def test_future_membership_disallows_auto_enroll(self): + """verify that the CcxFutureMembership can veto auto_enroll + """ + user = self.unenrolled_user + pfm = self.create_future_enrollment(user, auto_enroll=False) + self.assertTrue(self.has_ccx_future_membership(user)) + self.assertFalse(self.has_course_enrollment(user)) + + self.assertRaises(ValueError, self.call_mut, user, pfm) + + self.assertFalse(self.has_course_enrollment(user)) + self.assertFalse(self.has_ccx_membership(user)) + self.assertTrue(self.has_ccx_future_membership(user)) diff --git a/lms/djangoapps/ccx/tests/test_overrides.py b/lms/djangoapps/ccx/tests/test_overrides.py new file mode 100644 index 0000000000..f6edd44a43 --- /dev/null +++ b/lms/djangoapps/ccx/tests/test_overrides.py @@ -0,0 +1,129 @@ +""" +tests for overrides +""" +import datetime +import mock +import pytz + +from courseware.field_overrides import OverrideFieldData # pylint: disable=import-error +from django.test.utils import override_settings +from student.tests.factories import AdminFactory # pylint: disable=import-error +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +from ..models import CustomCourseForEdX +from ..overrides import override_field_for_ccx + +from .test_views import flatten, iter_blocks + + +@override_settings(FIELD_OVERRIDE_PROVIDERS=( + 'ccx.overrides.CustomCoursesForEdxOverrideProvider',)) +class TestFieldOverrides(ModuleStoreTestCase): + """ + Make sure field overrides behave in the expected manner. + """ + def setUp(self): + """ + Set up tests + """ + super(TestFieldOverrides, self).setUp() + self.course = course = CourseFactory.create() + + # Create a course outline + self.mooc_start = start = datetime.datetime( + 2010, 5, 12, 2, 42, tzinfo=pytz.UTC) + self.mooc_due = due = datetime.datetime( + 2010, 7, 7, 0, 0, tzinfo=pytz.UTC) + chapters = [ItemFactory.create(start=start, parent=course) + for _ in xrange(2)] + sequentials = flatten([ + [ItemFactory.create(parent=chapter) for _ in xrange(2)] + for chapter in chapters]) + verticals = flatten([ + [ItemFactory.create(due=due, parent=sequential) for _ in xrange(2)] + for sequential in sequentials]) + blocks = flatten([ # pylint: disable=unused-variable + [ItemFactory.create(parent=vertical) for _ in xrange(2)] + for vertical in verticals]) + + self.ccx = ccx = CustomCourseForEdX( + course_id=course.id, + display_name='Test CCX', + coach=AdminFactory.create()) + ccx.save() + + patch = mock.patch('ccx.overrides.get_current_ccx') + self.get_ccx = get_ccx = patch.start() + get_ccx.return_value = ccx + self.addCleanup(patch.stop) + + # Apparently the test harness doesn't use LmsFieldStorage, and I'm not + # sure if there's a way to poke the test harness to do so. So, we'll + # just inject the override field storage in this brute force manner. + OverrideFieldData.provider_classes = None + for block in iter_blocks(course): + block._field_data = OverrideFieldData.wrap( # pylint: disable=protected-access + AdminFactory.create(), block._field_data) # pylint: disable=protected-access + + def cleanup_provider_classes(): + """ + After everything is done, clean up by un-doing the change to the + OverrideFieldData object that is done during the wrap method. + """ + OverrideFieldData.provider_classes = None + self.addCleanup(cleanup_provider_classes) + + def test_override_start(self): + """ + Test that overriding start date on a chapter works. + """ + ccx_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC) + chapter = self.course.get_children()[0] + override_field_for_ccx(self.ccx, chapter, 'start', ccx_start) + self.assertEquals(chapter.start, ccx_start) + + def test_override_num_queries(self): + """ + Test that overriding and accessing a field produce same number of queries. + """ + ccx_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC) + chapter = self.course.get_children()[0] + with self.assertNumQueries(4): + override_field_for_ccx(self.ccx, chapter, 'start', ccx_start) + dummy = chapter.start + + def test_overriden_field_access_produces_no_extra_queries(self): + """ + Test no extra queries when accessing an overriden field more than once. + """ + ccx_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC) + chapter = self.course.get_children()[0] + with self.assertNumQueries(4): + override_field_for_ccx(self.ccx, chapter, 'start', ccx_start) + dummy1 = chapter.start + dummy2 = chapter.start + dummy3 = chapter.start + + def test_override_is_inherited(self): + """ + Test that sequentials inherit overridden start date from chapter. + """ + ccx_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC) + chapter = self.course.get_children()[0] + override_field_for_ccx(self.ccx, chapter, 'start', ccx_start) + self.assertEquals(chapter.get_children()[0].start, ccx_start) + self.assertEquals(chapter.get_children()[1].start, ccx_start) + + def test_override_is_inherited_even_if_set_in_mooc(self): + """ + Test that a due date set on a chapter is inherited by grandchildren + (verticals) even if a due date is set explicitly on grandchildren in + the mooc. + """ + ccx_due = datetime.datetime(2015, 1, 1, 00, 00, tzinfo=pytz.UTC) + chapter = self.course.get_children()[0] + chapter.display_name = 'itsme!' + override_field_for_ccx(self.ccx, chapter, 'due', ccx_due) + vertical = chapter.get_children()[0].get_children()[0] + self.assertEqual(vertical.due, ccx_due) diff --git a/lms/djangoapps/ccx/tests/test_utils.py b/lms/djangoapps/ccx/tests/test_utils.py new file mode 100644 index 0000000000..cd44efe458 --- /dev/null +++ b/lms/djangoapps/ccx/tests/test_utils.py @@ -0,0 +1,570 @@ +""" +test utils +""" +from ccx.models import ( # pylint: disable=import-error + CcxMembership, + CcxFutureMembership, +) +from ccx.tests.factories import ( # pylint: disable=import-error + CcxFactory, + CcxMembershipFactory, + CcxFutureMembershipFactory, +) +from student.roles import CourseCcxCoachRole # pylint: disable=import-error +from student.tests.factories import ( # pylint: disable=import-error + AdminFactory, + UserFactory, + CourseEnrollmentFactory, + AnonymousUserFactory, +) +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +class TestEmailEnrollmentState(ModuleStoreTestCase): + """unit tests for the EmailEnrollmentState class + """ + + def setUp(self): + """ + Set up tests + """ + super(TestEmailEnrollmentState, self).setUp() + # remove user provided by the parent test case so we can make our own + # when needed. + self.user = None + course = CourseFactory.create() + coach = AdminFactory.create() + role = CourseCcxCoachRole(course.id) + role.add_users(coach) + self.ccx = CcxFactory(course_id=course.id, coach=coach) + + def create_user(self): + """provide a legitimate django user for testing + """ + if getattr(self, 'user', None) is None: + self.user = UserFactory() + + def register_user_in_ccx(self): + """create registration of self.user in self.ccx + + registration will be inactive + """ + self.create_user() + CcxMembershipFactory(ccx=self.ccx, student=self.user) + + def create_one(self, email=None): + """Create a single EmailEnrollmentState object and return it + """ + from ccx.utils import EmailEnrollmentState # pylint: disable=import-error + if email is None: + email = self.user.email + return EmailEnrollmentState(self.ccx, email) + + def test_enrollment_state_for_non_user(self): + """verify behavior for non-user email address + """ + ee_state = self.create_one(email='nobody@nowhere.com') + for attr in ['user', 'member', 'full_name', 'in_ccx']: + value = getattr(ee_state, attr, 'missing attribute') + self.assertFalse(value, "{}: {}".format(value, attr)) + + def test_enrollment_state_for_non_member_user(self): + """verify behavior for email address of user who is not a ccx memeber + """ + self.create_user() + ee_state = self.create_one() + self.assertTrue(ee_state.user) + self.assertFalse(ee_state.in_ccx) + self.assertEqual(ee_state.member, self.user) + self.assertEqual(ee_state.full_name, self.user.profile.name) + + def test_enrollment_state_for_member_user(self): + """verify behavior for email address of user who is a ccx member + """ + self.create_user() + self.register_user_in_ccx() + ee_state = self.create_one() + for attr in ['user', 'in_ccx']: + self.assertTrue( + getattr(ee_state, attr, False), + "attribute {} is missing or False".format(attr) + ) + self.assertEqual(ee_state.member, self.user) + self.assertEqual(ee_state.full_name, self.user.profile.name) + + def test_enrollment_state_to_dict(self): + """verify dict representation of EmailEnrollmentState + """ + self.create_user() + self.register_user_in_ccx() + ee_state = self.create_one() + ee_dict = ee_state.to_dict() + expected = { + 'user': True, + 'member': self.user, + 'in_ccx': True, + } + for expected_key, expected_value in expected.iteritems(): + self.assertTrue(expected_key in ee_dict) + self.assertEqual(expected_value, ee_dict[expected_key]) + + def test_enrollment_state_repr(self): + self.create_user() + self.register_user_in_ccx() + ee_state = self.create_one() + representation = repr(ee_state) + self.assertTrue('user=True' in representation) + self.assertTrue('in_ccx=True' in representation) + member = 'member={}'.format(self.user) + self.assertTrue(member in representation) + + +# TODO: deal with changes in behavior for auto_enroll +class TestGetEmailParams(ModuleStoreTestCase): + """tests for ccx.utils.get_email_params + """ + def setUp(self): + """ + Set up tests + """ + super(TestGetEmailParams, self).setUp() + course = CourseFactory.create() + coach = AdminFactory.create() + role = CourseCcxCoachRole(course.id) + role.add_users(coach) + self.ccx = CcxFactory(course_id=course.id, coach=coach) + self.all_keys = [ + 'site_name', 'course', 'course_url', 'registration_url', + 'course_about_url', 'auto_enroll' + ] + self.url_keys = [k for k in self.all_keys if 'url' in k] + self.course_keys = [k for k in self.url_keys if 'course' in k] + + def call_fut(self, auto_enroll=False, secure=False): + """ + call function under test + """ + from ccx.utils import get_email_params # pylint: disable=import-error + return get_email_params(self.ccx, auto_enroll, secure) + + def test_params_have_expected_keys(self): + params = self.call_fut() + self.assertFalse(set(params.keys()) - set(self.all_keys)) + + def test_ccx_id_in_params(self): + expected_course_id = self.ccx.course_id.to_deprecated_string() + params = self.call_fut() + self.assertEqual(params['course'], self.ccx) + for url_key in self.url_keys: + self.assertTrue('http://' in params[url_key]) + for url_key in self.course_keys: + self.assertTrue(expected_course_id in params[url_key]) + + def test_security_respected(self): + secure = self.call_fut(secure=True) + for url_key in self.url_keys: + self.assertTrue('https://' in secure[url_key]) + insecure = self.call_fut(secure=False) + for url_key in self.url_keys: + self.assertTrue('http://' in insecure[url_key]) + + def test_auto_enroll_passed_correctly(self): + not_auto = self.call_fut(auto_enroll=False) + self.assertFalse(not_auto['auto_enroll']) + auto = self.call_fut(auto_enroll=True) + self.assertTrue(auto['auto_enroll']) + + +# TODO: deal with changes in behavior for auto_enroll +class TestEnrollEmail(ModuleStoreTestCase): + """tests for the enroll_email function from ccx.utils + """ + def setUp(self): + super(TestEnrollEmail, self).setUp() + # unbind the user created by the parent, so we can create our own when + # needed. + self.user = None + course = CourseFactory.create() + coach = AdminFactory.create() + role = CourseCcxCoachRole(course.id) + role.add_users(coach) + self.ccx = CcxFactory(course_id=course.id, coach=coach) + self.outbox = self.get_outbox() + + def create_user(self): + """provide a legitimate django user for testing + """ + if getattr(self, 'user', None) is None: + self.user = UserFactory() + + def register_user_in_ccx(self): + """create registration of self.user in self.ccx + + registration will be inactive + """ + self.create_user() + CcxMembershipFactory(ccx=self.ccx, student=self.user) + + def get_outbox(self): + """Return the django mail outbox""" + from django.core import mail + return mail.outbox + + def check_membership(self, email=None, user=None, future=False): + """Verify tjat an appropriate CCX Membership exists""" + if not email and not user: + self.fail( + "must provide user or email address to check CCX Membership" + ) + if future and email: + membership = CcxFutureMembership.objects.filter( + ccx=self.ccx, email=email + ) + elif not future: + if not user: + user = self.user + membership = CcxMembership.objects.filter( + ccx=self.ccx, student=user + ) + self.assertTrue(membership.exists()) + + def check_enrollment_state(self, state, in_ccx, member, user): + """Verify an enrollment state object against provided arguments + + state.in_ccx will always be a boolean + state.user will always be a boolean + state.member will be a Django user object or None + """ + self.assertEqual(in_ccx, state.in_ccx) + self.assertEqual(member, state.member) + self.assertEqual(user, state.user) + + def call_fut( + self, + student_email=None, + auto_enroll=False, + email_students=False, + email_params=None + ): + """Call function under test""" + from ccx.utils import enroll_email # pylint: disable=import-error + if student_email is None: + student_email = self.user.email + before, after = enroll_email( + self.ccx, student_email, auto_enroll, email_students, email_params + ) + return before, after + + def test_enroll_non_user_sending_email(self): + """enroll a non-user email and send an enrollment email to them + """ + # ensure no emails are in the outbox now + self.assertEqual(self.outbox, []) + test_email = "nobody@nowhere.com" + before, after = self.call_fut( + student_email=test_email, email_students=True + ) + + # there should be a future membership set for this email address now + self.check_membership(email=test_email, future=True) + for state in [before, after]: + self.check_enrollment_state(state, False, None, False) + # mail was sent and to the right person + self.assertEqual(len(self.outbox), 1) + msg = self.outbox[0] + self.assertTrue(test_email in msg.recipients()) + + def test_enroll_non_member_sending_email(self): + """register a non-member and send an enrollment email to them + """ + self.create_user() + # ensure no emails are in the outbox now + self.assertEqual(self.outbox, []) + before, after = self.call_fut(email_students=True) + + # there should be a membership set for this email address now + self.check_membership(email=self.user.email) + self.check_enrollment_state(before, False, self.user, True) + self.check_enrollment_state(after, True, self.user, True) + # mail was sent and to the right person + self.assertEqual(len(self.outbox), 1) + msg = self.outbox[0] + self.assertTrue(self.user.email in msg.recipients()) + + def test_enroll_member_sending_email(self): + """register a member and send an enrollment email to them + """ + self.register_user_in_ccx() + # ensure no emails are in the outbox now + self.assertEqual(self.outbox, []) + before, after = self.call_fut(email_students=True) + + # there should be a membership set for this email address now + self.check_membership(email=self.user.email) + for state in [before, after]: + self.check_enrollment_state(state, True, self.user, True) + # mail was sent and to the right person + self.assertEqual(len(self.outbox), 1) + msg = self.outbox[0] + self.assertTrue(self.user.email in msg.recipients()) + + def test_enroll_non_user_no_email(self): + """register a non-user via email address but send no email + """ + # ensure no emails are in the outbox now + self.assertEqual(self.outbox, []) + test_email = "nobody@nowhere.com" + before, after = self.call_fut( + student_email=test_email, email_students=False + ) + + # there should be a future membership set for this email address now + self.check_membership(email=test_email, future=True) + for state in [before, after]: + self.check_enrollment_state(state, False, None, False) + # ensure there are still no emails in the outbox now + self.assertEqual(self.outbox, []) + + def test_enroll_non_member_no_email(self): + """register a non-member but send no email""" + self.create_user() + # ensure no emails are in the outbox now + self.assertEqual(self.outbox, []) + before, after = self.call_fut(email_students=False) + + # there should be a membership set for this email address now + self.check_membership(email=self.user.email) + self.check_enrollment_state(before, False, self.user, True) + self.check_enrollment_state(after, True, self.user, True) + # ensure there are still no emails in the outbox now + self.assertEqual(self.outbox, []) + + def test_enroll_member_no_email(self): + """enroll a member but send no email + """ + self.register_user_in_ccx() + # ensure no emails are in the outbox now + self.assertEqual(self.outbox, []) + before, after = self.call_fut(email_students=False) + + # there should be a membership set for this email address now + self.check_membership(email=self.user.email) + for state in [before, after]: + self.check_enrollment_state(state, True, self.user, True) + # ensure there are still no emails in the outbox now + self.assertEqual(self.outbox, []) + + +# TODO: deal with changes in behavior for auto_enroll +class TestUnenrollEmail(ModuleStoreTestCase): + """Tests for the unenroll_email function from ccx.utils""" + def setUp(self): + super(TestUnenrollEmail, self).setUp() + # unbind the user created by the parent, so we can create our own when + # needed. + self.user = None + course = CourseFactory.create() + coach = AdminFactory.create() + role = CourseCcxCoachRole(course.id) + role.add_users(coach) + self.ccx = CcxFactory(course_id=course.id, coach=coach) + self.outbox = self.get_outbox() + self.email = "nobody@nowhere.com" + + def get_outbox(self): + """Return the django mail outbox""" + from django.core import mail + return mail.outbox + + def create_user(self): + """provide a legitimate django user for testing + """ + if getattr(self, 'user', None) is None: + self.user = UserFactory() + + def make_ccx_membership(self): + """create registration of self.user in self.ccx + + registration will be inactive + """ + self.create_user() + CcxMembershipFactory.create(ccx=self.ccx, student=self.user) + + def make_ccx_future_membership(self): + """create future registration for email in self.ccx""" + CcxFutureMembershipFactory.create( + ccx=self.ccx, email=self.email + ) + + def check_enrollment_state(self, state, in_ccx, member, user): + """Verify an enrollment state object against provided arguments + + state.in_ccx will always be a boolean + state.user will always be a boolean + state.member will be a Django user object or None + """ + self.assertEqual(in_ccx, state.in_ccx) + self.assertEqual(member, state.member) + self.assertEqual(user, state.user) + + def check_membership(self, future=False): + """ + check membership + """ + if future: + membership = CcxFutureMembership.objects.filter( + ccx=self.ccx, email=self.email + ) + else: + membership = CcxMembership.objects.filter( + ccx=self.ccx, student=self.user + ) + return membership.exists() + + def call_fut(self, email_students=False): + """call function under test""" + from ccx.utils import unenroll_email # pylint: disable=import-error + email = getattr(self, 'user', None) and self.user.email or self.email + return unenroll_email(self.ccx, email, email_students=email_students) + + def test_unenroll_future_member_with_email(self): + """unenroll a future member and send an email + """ + self.make_ccx_future_membership() + # assert that a membership exists and that no emails have been sent + self.assertTrue(self.check_membership(future=True)) + self.assertEqual(self.outbox, []) + # unenroll the student + before, after = self.call_fut(email_students=True) + + # assert that membership is now gone + self.assertFalse(self.check_membership(future=True)) + # validate the before and after enrollment states + for state in [before, after]: + self.check_enrollment_state(state, False, None, False) + # check that mail was sent and to the right person + self.assertEqual(len(self.outbox), 1) + msg = self.outbox[0] + self.assertTrue(self.email in msg.recipients()) + + def test_unenroll_member_with_email(self): + """unenroll a current member and send an email""" + self.make_ccx_membership() + # assert that a membership exists and that no emails have been sent + self.assertTrue(self.check_membership()) + self.assertEqual(self.outbox, []) + # unenroll the student + before, after = self.call_fut(email_students=True) + + # assert that membership is now gone + self.assertFalse(self.check_membership()) + # validate the before and after enrollment state + self.check_enrollment_state(after, False, self.user, True) + self.check_enrollment_state(before, True, self.user, True) + # check that mail was sent and to the right person + self.assertEqual(len(self.outbox), 1) + msg = self.outbox[0] + self.assertTrue(self.user.email in msg.recipients()) + + def test_unenroll_future_member_no_email(self): + """unenroll a future member but send no email + """ + self.make_ccx_future_membership() + # assert that a membership exists and that no emails have been sent + self.assertTrue(self.check_membership(future=True)) + self.assertEqual(self.outbox, []) + # unenroll the student + before, after = self.call_fut() + + # assert that membership is now gone + self.assertFalse(self.check_membership(future=True)) + # validate the before and after enrollment states + for state in [before, after]: + self.check_enrollment_state(state, False, None, False) + # no email was sent to the student + self.assertEqual(self.outbox, []) + + def test_unenroll_member_no_email(self): + """unenroll a current member but send no email + """ + self.make_ccx_membership() + # assert that a membership exists and that no emails have been sent + self.assertTrue(self.check_membership()) + self.assertEqual(self.outbox, []) + # unenroll the student + before, after = self.call_fut() + + # assert that membership is now gone + self.assertFalse(self.check_membership()) + # validate the before and after enrollment state + self.check_enrollment_state(after, False, self.user, True) + self.check_enrollment_state(before, True, self.user, True) + # no email was sent to the student + self.assertEqual(self.outbox, []) + + +class TestUserCCXList(ModuleStoreTestCase): + """Unit tests for ccx.utils.get_all_ccx_for_user""" + + def setUp(self): + """Create required infrastructure for tests""" + super(TestUserCCXList, self).setUp() + self.course = CourseFactory.create() + coach = AdminFactory.create() + role = CourseCcxCoachRole(self.course.id) + role.add_users(coach) + self.ccx = CcxFactory(course_id=self.course.id, coach=coach) + enrollment = CourseEnrollmentFactory.create(course_id=self.course.id) + self.user = enrollment.user + self.anonymous = AnonymousUserFactory.create() + + def register_user_in_ccx(self, active=False): + """create registration of self.user in self.ccx + + registration will be inactive unless active=True + """ + CcxMembershipFactory(ccx=self.ccx, student=self.user, active=active) + + def get_course_title(self): + """Get course title""" + from courseware.courses import get_course_about_section # pylint: disable=import-error + return get_course_about_section(self.course, 'title') + + def call_fut(self, user): + """Call function under test""" + from ccx.utils import get_all_ccx_for_user # pylint: disable=import-error + return get_all_ccx_for_user(user) + + def test_anonymous_sees_no_ccx(self): + memberships = self.call_fut(self.anonymous) + self.assertEqual(memberships, []) + + def test_unenrolled_sees_no_ccx(self): + memberships = self.call_fut(self.user) + self.assertEqual(memberships, []) + + def test_enrolled_inactive_sees_no_ccx(self): + self.register_user_in_ccx() + memberships = self.call_fut(self.user) + self.assertEqual(memberships, []) + + def test_enrolled_sees_a_ccx(self): + self.register_user_in_ccx(active=True) + memberships = self.call_fut(self.user) + self.assertEqual(len(memberships), 1) + + def test_data_structure(self): + self.register_user_in_ccx(active=True) + memberships = self.call_fut(self.user) + this_membership = memberships[0] + self.assertTrue(this_membership) + # structure contains the expected keys + for key in ['ccx_name', 'ccx_url']: + self.assertTrue(key in this_membership.keys()) + url_parts = [self.course.id.to_deprecated_string(), str(self.ccx.id)] + # all parts of the ccx url are present + for part in url_parts: + self.assertTrue(part in this_membership['ccx_url']) + actual_name = self.ccx.display_name + self.assertEqual(actual_name, this_membership['ccx_name']) diff --git a/lms/djangoapps/ccx/tests/test_views.py b/lms/djangoapps/ccx/tests/test_views.py new file mode 100644 index 0000000000..d321183375 --- /dev/null +++ b/lms/djangoapps/ccx/tests/test_views.py @@ -0,0 +1,776 @@ +""" +test views +""" +import datetime +import json +import re +import pytz +from mock import patch + +from capa.tests.response_xml_factory import StringResponseXMLFactory +from courseware.field_overrides import OverrideFieldData # pylint: disable=import-error +from courseware.tests.factories import StudentModuleFactory # pylint: disable=import-error +from courseware.tests.helpers import LoginEnrollmentTestCase # pylint: disable=import-error +from django.core.urlresolvers import reverse +from django.test.utils import override_settings +from edxmako.shortcuts import render_to_response # pylint: disable=import-error +from student.roles import CourseCcxCoachRole # pylint: disable=import-error +from student.tests.factories import ( # pylint: disable=import-error + AdminFactory, + CourseEnrollmentFactory, + UserFactory, +) + +from xmodule.x_module import XModuleMixin +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import ( + CourseFactory, + ItemFactory, +) +from ..models import ( + CustomCourseForEdX, + CcxMembership, + CcxFutureMembership, +) +from ..overrides import get_override_for_ccx, override_field_for_ccx +from .. import ACTIVE_CCX_KEY +from .factories import ( + CcxFactory, + CcxMembershipFactory, + CcxFutureMembershipFactory, +) + + +def intercept_renderer(path, context): + """ + Intercept calls to `render_to_response` and attach the context dict to the + response for examination in unit tests. + """ + # I think Django already does this for you in their TestClient, except + # we're bypassing that by using edxmako. Probably edxmako should be + # integrated better with Django's rendering and event system. + response = render_to_response(path, context) + response.mako_context = context + response.mako_template = path + return response + + +class TestCoachDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Tests for Custom Courses views. + """ + def setUp(self): + """ + Set up tests + """ + super(TestCoachDashboard, self).setUp() + self.course = course = CourseFactory.create() + + # Create instructor account + self.coach = coach = AdminFactory.create() + self.client.login(username=coach.username, password="test") + + # Create a course outline + self.mooc_start = start = datetime.datetime( + 2010, 5, 12, 2, 42, tzinfo=pytz.UTC) + self.mooc_due = due = datetime.datetime( + 2010, 7, 7, 0, 0, tzinfo=pytz.UTC) + chapters = [ItemFactory.create(start=start, parent=course) + for _ in xrange(2)] + sequentials = flatten([ + [ + ItemFactory.create(parent=chapter) for _ in xrange(2) + ] for chapter in chapters + ]) + verticals = flatten([ + [ + ItemFactory.create( + due=due, parent=sequential, graded=True, format='Homework' + ) for _ in xrange(2) + ] for sequential in sequentials + ]) + blocks = flatten([ # pylint: disable=unused-variable + [ + ItemFactory.create(parent=vertical) for _ in xrange(2) + ] for vertical in verticals + ]) + + def make_coach(self): + """ + create coach user + """ + role = CourseCcxCoachRole(self.course.id) + role.add_users(self.coach) + + def make_ccx(self): + """ + create ccx + """ + ccx = CcxFactory(course_id=self.course.id, coach=self.coach) + return ccx + + def get_outbox(self): + """ + get fake outbox + """ + from django.core import mail + return mail.outbox + + def tearDown(self): + """ + Undo patches. + """ + super(TestCoachDashboard, self).tearDown() + patch.stopall() + + def test_not_a_coach(self): + """ + User is not a coach, should get Forbidden response. + """ + url = reverse( + 'ccx_coach_dashboard', + kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_no_ccx_created(self): + """ + No CCX is created, coach should see form to add a CCX. + """ + self.make_coach() + url = reverse( + 'ccx_coach_dashboard', + kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertTrue(re.search( + '
+ ccx_url: + ccx_active: True if this ccx is currently the 'active' one + mooc_name: + mooc_url: + } + """ + if user.is_anonymous(): + return [] + current_active_ccx = get_current_ccx() + memberships = [] + for membership in CcxMembership.memberships_for_user(user): + course = get_course_by_id(membership.ccx.course_id) + ccx = membership.ccx + ccx_title = ccx.display_name + mooc_title = get_course_about_section(course, 'title') + url = reverse( + 'switch_active_ccx', + args=[course.id.to_deprecated_string(), membership.ccx.id] + ) + mooc_url = reverse( + 'switch_active_ccx', + args=[course.id.to_deprecated_string(), ] + ) + memberships.append({ + 'ccx_name': ccx_title, + 'ccx_url': url, + 'active': membership.ccx == current_active_ccx, + 'mooc_name': mooc_title, + 'mooc_url': mooc_url, + }) + return memberships + + +def get_ccx_membership_triplets(user, course_org_filter, org_filter_out_set): + """ + Get the relevant set of (CustomCourseForEdX, CcxMembership, Course) + triplets to be displayed on a student's dashboard. + """ + # only active memberships for now + for membership in CcxMembership.memberships_for_user(user): + ccx = membership.ccx + store = modulestore() + with store.bulk_operations(ccx.course_id): + course = store.get_course(ccx.course_id) + if course and not isinstance(course, ErrorDescriptor): + # if we are in a Microsite, then filter out anything that is not + # attributed (by ORG) to that Microsite + if course_org_filter and course_org_filter != course.location.org: + continue + # Conversely, if we are not in a Microsite, then let's filter out any enrollments + # with courses attributed (by ORG) to Microsites + elif course.location.org in org_filter_out_set: + continue + + yield (ccx, membership, course) + else: + log.error("User {0} enrolled in {2} course {1}".format( # pylint: disable=logging-format-interpolation + user.username, ccx.course_id, "broken" if course else "non-existent" + )) diff --git a/lms/djangoapps/ccx/views.py b/lms/djangoapps/ccx/views.py new file mode 100644 index 0000000000..339b21256f --- /dev/null +++ b/lms/djangoapps/ccx/views.py @@ -0,0 +1,533 @@ +""" +Views related to the Custom Courses feature. +""" +import csv +import datetime +import functools +import json +import logging +import pytz + +from copy import deepcopy +from cStringIO import StringIO + +from django.core.urlresolvers import reverse +from django.http import ( + HttpResponse, + HttpResponseForbidden, + HttpResponseRedirect, +) +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.shortcuts import redirect +from django.utils.translation import ugettext as _ +from django.views.decorators.cache import cache_control +from django_future.csrf import ensure_csrf_cookie # pylint: disable=import-error +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User + +from courseware.courses import get_course_by_id # pylint: disable=import-error + +from courseware.field_overrides import disable_overrides # pylint: disable=import-error +from courseware.grades import iterate_grades_for # pylint: disable=import-error +from courseware.model_data import FieldDataCache # pylint: disable=import-error +from courseware.module_render import get_module_for_descriptor # pylint: disable=import-error +from edxmako.shortcuts import render_to_response # pylint: disable=import-error +from opaque_keys.edx.keys import CourseKey +from student.roles import CourseCcxCoachRole # pylint: disable=import-error + +from instructor.offline_gradecalc import student_grades # pylint: disable=import-error +from instructor.views.api import _split_input_list # pylint: disable=import-error +from instructor.views.tools import get_student_from_identifier # pylint: disable=import-error + +from .models import CustomCourseForEdX, CcxMembership +from .overrides import ( + clear_override_for_ccx, + get_override_for_ccx, + override_field_for_ccx, + ccx_context, +) +from .utils import ( + enroll_email, + unenroll_email, +) +from ccx import ACTIVE_CCX_KEY # pylint: disable=import-error + + +log = logging.getLogger(__name__) +TODAY = datetime.datetime.today # for patching in tests + + +def coach_dashboard(view): + """ + View decorator which enforces that the user have the CCX coach role on the + given course and goes ahead and translates the course_id from the Django + route into a course object. + """ + @functools.wraps(view) + def wrapper(request, course_id): + """ + Wraps the view function, performing access check, loading the course, + and modifying the view's call signature. + """ + course_key = CourseKey.from_string(course_id) + role = CourseCcxCoachRole(course_key) + if not role.has_user(request.user): + return HttpResponseForbidden( + _('You must be a CCX Coach to access this view.')) + course = get_course_by_id(course_key, depth=None) + return view(request, course) + return wrapper + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@coach_dashboard +def dashboard(request, course): + """ + Display the CCX Coach Dashboard. + """ + ccx = get_ccx_for_coach(course, request.user) + context = { + 'course': course, + 'ccx': ccx, + } + + if ccx: + schedule = get_ccx_schedule(course, ccx) + grading_policy = get_override_for_ccx( + ccx, course, 'grading_policy', course.grading_policy) + context['schedule'] = json.dumps(schedule, indent=4) + context['save_url'] = reverse( + 'save_ccx', kwargs={'course_id': course.id}) + context['ccx_members'] = CcxMembership.objects.filter(ccx=ccx) + context['gradebook_url'] = reverse( + 'ccx_gradebook', kwargs={'course_id': course.id}) + context['grades_csv_url'] = reverse( + 'ccx_grades_csv', kwargs={'course_id': course.id}) + context['grading_policy'] = json.dumps(grading_policy, indent=4) + context['grading_policy_url'] = reverse( + 'ccx_set_grading_policy', kwargs={'course_id': course.id}) + else: + context['create_ccx_url'] = reverse( + 'create_ccx', kwargs={'course_id': course.id}) + return render_to_response('ccx/coach_dashboard.html', context) + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@coach_dashboard +def create_ccx(request, course): + """ + Create a new CCX + """ + name = request.POST.get('name') + ccx = CustomCourseForEdX( + course_id=course.id, + coach=request.user, + display_name=name) + ccx.save() + + # Make sure start/due are overridden for entire course + start = TODAY().replace(tzinfo=pytz.UTC) + override_field_for_ccx(ccx, course, 'start', start) + override_field_for_ccx(ccx, course, 'due', None) + + # Hide anything that can show up in the schedule + hidden = 'visible_to_staff_only' + for chapter in course.get_children(): + override_field_for_ccx(ccx, chapter, hidden, True) + for sequential in chapter.get_children(): + override_field_for_ccx(ccx, sequential, hidden, True) + for vertical in sequential.get_children(): + override_field_for_ccx(ccx, vertical, hidden, True) + + url = reverse('ccx_coach_dashboard', kwargs={'course_id': course.id}) + return redirect(url) + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@coach_dashboard +def save_ccx(request, course): + """ + Save changes to CCX. + """ + ccx = get_ccx_for_coach(course, request.user) + + def override_fields(parent, data, graded, earliest=None): + """ + Recursively apply CCX schedule data to CCX by overriding the + `visible_to_staff_only`, `start` and `due` fields for units in the + course. + """ + blocks = { + str(child.location): child + for child in parent.get_children()} + for unit in data: + block = blocks[unit['location']] + override_field_for_ccx( + ccx, block, 'visible_to_staff_only', unit['hidden']) + start = parse_date(unit['start']) + if start: + if not earliest or start < earliest: + earliest = start + override_field_for_ccx(ccx, block, 'start', start) + else: + clear_override_for_ccx(ccx, block, 'start') + due = parse_date(unit['due']) + if due: + override_field_for_ccx(ccx, block, 'due', due) + else: + clear_override_for_ccx(ccx, block, 'due') + + if not unit['hidden'] and block.graded: + graded[block.format] = graded.get(block.format, 0) + 1 + + children = unit.get('children', None) + if children: + override_fields(block, children, graded, earliest) + return earliest + + graded = {} + earliest = override_fields(course, json.loads(request.body), graded) + if earliest: + override_field_for_ccx(ccx, course, 'start', earliest) + + # Attempt to automatically adjust grading policy + changed = False + policy = get_override_for_ccx( + ccx, course, 'grading_policy', course.grading_policy + ) + policy = deepcopy(policy) + grader = policy['GRADER'] + for section in grader: + count = graded.get(section.get('type'), 0) + if count < section['min_count']: + changed = True + section['min_count'] = count + if changed: + override_field_for_ccx(ccx, course, 'grading_policy', policy) + + return HttpResponse( + json.dumps({ + 'schedule': get_ccx_schedule(course, ccx), + 'grading_policy': json.dumps(policy, indent=4)}), + content_type='application/json', + ) + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@coach_dashboard +def set_grading_policy(request, course): + """ + Set grading policy for the CCX. + """ + ccx = get_ccx_for_coach(course, request.user) + override_field_for_ccx( + ccx, course, 'grading_policy', json.loads(request.POST['policy'])) + + url = reverse('ccx_coach_dashboard', kwargs={'course_id': course.id}) + return redirect(url) + + +def validate_date(year, month, day, hour, minute): + """ + avoid corrupting db if bad dates come in + """ + valid = True + if year < 0: + valid = False + if month < 1 or month > 12: + valid = False + if day < 1 or day > 31: + valid = False + if hour < 0 or hour > 23: + valid = False + if minute < 0 or minute > 59: + valid = False + return valid + + +def parse_date(datestring): + """ + Generate a UTC datetime.datetime object from a string of the form + 'YYYY-MM-DD HH:MM'. If string is empty or `None`, returns `None`. + """ + if datestring: + date, time = datestring.split(' ') + year, month, day = map(int, date.split('-')) + hour, minute = map(int, time.split(':')) + if validate_date(year, month, day, hour, minute): + return datetime.datetime( + year, month, day, hour, minute, tzinfo=pytz.UTC) + + return None + + +def get_ccx_for_coach(course, coach): + """ + Looks to see if user is coach of a CCX for this course. Returns the CCX or + None. + """ + try: + return CustomCourseForEdX.objects.get( + course_id=course.id, + coach=coach) + except CustomCourseForEdX.DoesNotExist: + return None + + +def get_ccx_schedule(course, ccx): + """ + Generate a JSON serializable CCX schedule. + """ + def visit(node, depth=1): + """ + Recursive generator function which yields CCX schedule nodes. + We convert dates to string to get them ready for use by the js date + widgets, which use text inputs. + """ + for child in node.get_children(): + start = get_override_for_ccx(ccx, child, 'start', None) + if start: + start = str(start)[:-9] + due = get_override_for_ccx(ccx, child, 'due', None) + if due: + due = str(due)[:-9] + hidden = get_override_for_ccx( + ccx, child, 'visible_to_staff_only', + child.visible_to_staff_only) + visited = { + 'location': str(child.location), + 'display_name': child.display_name, + 'category': child.category, + 'start': start, + 'due': due, + 'hidden': hidden, + } + if depth < 3: + children = tuple(visit(child, depth + 1)) + if children: + visited['children'] = children + yield visited + else: + yield visited + + with disable_overrides(): + return tuple(visit(course)) + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@coach_dashboard +def ccx_schedule(request, course): + """ + get json representation of ccx schedule + """ + ccx = get_ccx_for_coach(course, request.user) + schedule = get_ccx_schedule(course, ccx) + json_schedule = json.dumps(schedule, indent=4) + return HttpResponse(json_schedule, mimetype='application/json') + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@coach_dashboard +def ccx_invite(request, course): + """ + Invite users to new ccx + """ + ccx = get_ccx_for_coach(course, request.user) + action = request.POST.get('enrollment-button') + identifiers_raw = request.POST.get('student-ids') + identifiers = _split_input_list(identifiers_raw) + auto_enroll = True if 'auto-enroll' in request.POST else False + email_students = True if 'email-students' in request.POST else False + for identifier in identifiers: + user = None + email = None + try: + user = get_student_from_identifier(identifier) + except User.DoesNotExist: + email = identifier + else: + email = user.email + try: + validate_email(email) + if action == 'Enroll': + enroll_email( + ccx, + email, + auto_enroll=auto_enroll, + email_students=email_students + ) + if action == "Unenroll": + unenroll_email(ccx, email, email_students=email_students) + except ValidationError: + log.info('Invalid user name or email when trying to invite students: %s', email) + url = reverse('ccx_coach_dashboard', kwargs={'course_id': course.id}) + return redirect(url) + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@coach_dashboard +def ccx_student_management(request, course): + """Manage the enrollment of individual students in a CCX + """ + ccx = get_ccx_for_coach(course, request.user) + action = request.POST.get('student-action', None) + student_id = request.POST.get('student-id', '') + user = email = None + try: + user = get_student_from_identifier(student_id) + except User.DoesNotExist: + email = student_id + else: + email = user.email + + try: + validate_email(email) + if action == 'add': + # by decree, no emails sent to students added this way + # by decree, any students added this way are auto_enrolled + enroll_email(ccx, email, auto_enroll=True, email_students=False) + elif action == 'revoke': + unenroll_email(ccx, email, email_students=False) + except ValidationError: + log.info('Invalid user name or email when trying to enroll student: %s', email) + + url = reverse('ccx_coach_dashboard', kwargs={'course_id': course.id}) + return redirect(url) + + +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@coach_dashboard +def ccx_gradebook(request, course): + """ + Show the gradebook for this CCX. + """ + # Need course module for overrides to function properly + field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + course.id, request.user, course, depth=2) + course = get_module_for_descriptor( + request.user, request, course, field_data_cache, course.id) + + ccx = get_ccx_for_coach(course, request.user) + with ccx_context(ccx): + # The grading policy for the MOOC is probably already cached. We need + # to make sure we have the CCX grading policy loaded. + course._field_data_cache = {} # pylint: disable=protected-access + course.set_grading_policy(course.grading_policy) + + enrolled_students = User.objects.filter( + ccxmembership__ccx=ccx, + ccxmembership__active=1 + ).order_by('username').select_related("profile") + + student_info = [ + { + 'username': student.username, + 'id': student.id, + 'email': student.email, + 'grade_summary': student_grades(student, request, course), + 'realname': student.profile.name, + } + for student in enrolled_students + ] + + return render_to_response('courseware/gradebook.html', { + 'students': student_info, + 'course': course, + 'course_id': course.id, + 'staff_access': request.user.is_staff, + 'ordered_grades': sorted( + course.grade_cutoffs.items(), key=lambda i: i[1], reverse=True), + }) + + +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@coach_dashboard +def ccx_grades_csv(request, course): + """ + Download grades as CSV. + """ + # Need course module for overrides to function properly + field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + course.id, request.user, course, depth=2) + course = get_module_for_descriptor( + request.user, request, course, field_data_cache, course.id) + ccx = get_ccx_for_coach(course, request.user) + with ccx_context(ccx): + # The grading policy for the MOOC is probably already cached. We need + # to make sure we have the CCX grading policy loaded. + course._field_data_cache = {} # pylint: disable=protected-access + course.set_grading_policy(course.grading_policy) + + enrolled_students = User.objects.filter( + ccxmembership__ccx=ccx, + ccxmembership__active=1 + ).order_by('username').select_related("profile") + grades = iterate_grades_for(course, enrolled_students) + + header = None + rows = [] + for student, gradeset, __ in grades: + if gradeset: + # We were able to successfully grade this student for this + # course. + if not header: + # Encode the header row in utf-8 encoding in case there are + # unicode characters + header = [section['label'].encode('utf-8') + for section in gradeset[u'section_breakdown']] + rows.append(["id", "email", "username", "grade"] + header) + + percents = { + section['label']: section.get('percent', 0.0) + for section in gradeset[u'section_breakdown'] + if 'label' in section + } + + row_percents = [percents.get(label, 0.0) for label in header] + rows.append([student.id, student.email, student.username, + gradeset['percent']] + row_percents) + + buf = StringIO() + writer = csv.writer(buf) + for row in rows: + writer.writerow(row) + + return HttpResponse(buf.getvalue(), content_type='text/plain') + + +@login_required +def switch_active_ccx(request, course_id, ccx_id=None): + """set the active CCX for the logged-in user + """ + course_key = CourseKey.from_string(course_id) + # will raise Http404 if course_id is bad + course = get_course_by_id(course_key) + course_url = reverse( + 'course_root', args=[course.id.to_deprecated_string()] + ) + if ccx_id is not None: + try: + requested_ccx = CustomCourseForEdX.objects.get(pk=ccx_id) + assert unicode(requested_ccx.course_id) == course_id + if not CcxMembership.objects.filter( + ccx=requested_ccx, student=request.user, active=True + ).exists(): + ccx_id = None + except CustomCourseForEdX.DoesNotExist: + # what to do here? Log the failure? Do we care? + ccx_id = None + except AssertionError: + # what to do here? Log the failure? Do we care? + ccx_id = None + + request.session[ACTIVE_CCX_KEY] = ccx_id + + return HttpResponseRedirect(course_url) diff --git a/lms/djangoapps/courseware/field_overrides.py b/lms/djangoapps/courseware/field_overrides.py new file mode 100644 index 0000000000..7d58836024 --- /dev/null +++ b/lms/djangoapps/courseware/field_overrides.py @@ -0,0 +1,204 @@ +""" +This module provides a :class:`~xblock.field_data.FieldData` implementation +which wraps an other `FieldData` object and provides overrides based on the +user. The use of providers allows for overrides that are arbitrarily +extensible. One provider is found in `courseware.student_field_overrides` +which allows for fields to be overridden for individual students. One can +envision other providers being written that allow for fields to be overridden +base on membership of a student in a cohort, or similar. The use of an +extensible, modular architecture allows for overrides being done in ways not +envisioned by the authors. + +Currently, this module is used in the `module_render` module in this same +package and is used to wrap the `authored_data` when constructing an +`LmsFieldData`. This means overrides will be in effect for all scopes covered +by `authored_data`, e.g. course content and settings stored in Mongo. +""" +import threading + +from abc import ABCMeta, abstractmethod +from contextlib import contextmanager +from django.conf import settings +from xblock.field_data import FieldData +from xmodule.modulestore.inheritance import InheritanceMixin + + +NOTSET = object() + + +def resolve_dotted(name): + """ + Given the dotted name for a Python object, performs any necessary imports + and returns the object. + """ + names = name.split('.') + path = names.pop(0) + target = __import__(path) + while names: + segment = names.pop(0) + path += '.' + segment + try: + target = getattr(target, segment) + except AttributeError: + __import__(path) + target = getattr(target, segment) + return target + + +class OverrideFieldData(FieldData): + """ + A :class:`~xblock.field_data.FieldData` which wraps another `FieldData` + object and allows for fields handled by the wrapped `FieldData` to be + overriden by arbitrary providers. + + Providers are configured by use of the Django setting, + `FIELD_OVERRIDE_PROVIDERS` which should be a tuple of dotted names of + :class:`FieldOverrideProvider` concrete implementations. Note that order + is important for this setting. Override providers will tried in the order + configured in the setting. The first provider to find an override 'wins' + for a particular field lookup. + """ + provider_classes = None + + @classmethod + def wrap(cls, user, wrapped): + """ + Will return a :class:`OverrideFieldData` which wraps the field data + given in `wrapped` for the given `user`, if override providers are + configred. If no override providers are configured, using the Django + setting, `FIELD_OVERRIDE_PROVIDERS`, returns `wrapped`, eliminating + any performance impact of this feature if no override providers are + configured. + """ + if cls.provider_classes is None: + cls.provider_classes = tuple( + (resolve_dotted(name) for name in + settings.FIELD_OVERRIDE_PROVIDERS)) + + if cls.provider_classes: + return cls(user, wrapped) + + return wrapped + + def __init__(self, user, fallback): + self.fallback = fallback + self.providers = tuple((cls(user) for cls in self.provider_classes)) + + def get_override(self, block, name): + """ + Checks for an override for the field identified by `name` in `block`. + Returns the overridden value or `NOTSET` if no override is found. + """ + if not overrides_disabled(): + for provider in self.providers: + value = provider.get(block, name, NOTSET) + if value is not NOTSET: + return value + return NOTSET + + def get(self, block, name): + value = self.get_override(block, name) + if value is not NOTSET: + return value + return self.fallback.get(block, name) + + def set(self, block, name, value): + self.fallback.set(block, name, value) + + def delete(self, block, name): + self.fallback.delete(block, name) + + def has(self, block, name): + has = self.get_override(block, name) + if has is NOTSET: + # If this is an inheritable field and an override is set above, + # then we want to return False here, so the field_data uses the + # override and not the original value for this block. + inheritable = InheritanceMixin.fields.keys() + if name in inheritable: + for ancestor in _lineage(block): + if self.get_override(ancestor, name) is not NOTSET: + return False + + return has is not NOTSET or self.fallback.has(block, name) + + def set_many(self, block, update_dict): + return self.fallback.set_many(block, update_dict) + + def default(self, block, name): + # The `default` method is overloaded by the field storage system to + # also handle inheritance. + if not overrides_disabled(): + inheritable = InheritanceMixin.fields.keys() + if name in inheritable: + for ancestor in _lineage(block): + value = self.get_override(ancestor, name) + if value is not NOTSET: + return value + return self.fallback.default(block, name) + + +class _OverridesDisabled(threading.local): + """ + A thread local used to manage state of overrides being disabled or not. + """ + disabled = () + + +_OVERRIDES_DISABLED = _OverridesDisabled() + + +@contextmanager +def disable_overrides(): + """ + A context manager which disables field overrides inside the context of a + `with` statement, allowing code to get at the `original` value of a field. + """ + prev = _OVERRIDES_DISABLED.disabled + _OVERRIDES_DISABLED.disabled += (True,) + yield + _OVERRIDES_DISABLED.disabled = prev + + +def overrides_disabled(): + """ + Checks to see whether overrides are disabled in the current context. + Returns a boolean value. See `disable_overrides`. + """ + return bool(_OVERRIDES_DISABLED.disabled) + + +class FieldOverrideProvider(object): + """ + Abstract class which defines the interface that a `FieldOverrideProvider` + must provide. In general, providers should derive from this class, but + it's not strictly necessary as long as they correctly implement this + interface. + + A `FieldOverrideProvider` implementation is only responsible for looking up + field overrides. To set overrides, there will be a domain specific API for + the concrete override implementation being used. + """ + __metaclass__ = ABCMeta + + def __init__(self, user): + self.user = user + + @abstractmethod + def get(self, block, name, default): # pragma no cover + """ + Look for an override value for the field named `name` in `block`. + Returns the overridden value or `default` if no override is found. + """ + raise NotImplementedError + + +def _lineage(block): + """ + Returns an iterator over all ancestors of the given block, starting with + its immediate parent and ending at the root of the block tree. + """ + parent = block.get_parent() + while parent: + yield parent + parent = parent.get_parent() diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 3db3cb8f35..a1444be4c8 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -20,11 +20,11 @@ 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_for_descriptor from submissions import api as sub_api # installed from the edx-submissions repository from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey log = logging.getLogger("edx.courseware") @@ -247,6 +247,8 @@ def _grade(student, request, course, keep_raw_scores): totaled_scores[section_format] = format_scores + # Grading policy might be overriden by a CCX, need to reset it + course.set_grading_policy(course.grading_policy) grade_summary = course.grader.grade(totaled_scores, generate_random_scores=settings.GENERATE_PROFILE_SCORES) # We round the grade here, to make sure that the grade is an whole percentage and @@ -330,6 +332,8 @@ def _progress_summary(student, request, course): # This student must not have access to the course. return None + course_module = getattr(course_module, '_x_module', course_module) + submissions_scores = sub_api.get_scores(course.id.to_deprecated_string(), anonymous_id_for_user(student, course.id)) chapters = [] @@ -373,7 +377,7 @@ def _progress_summary(student, request, course): 'scores': scores, 'section_total': section_total, 'format': module_format, - 'due': get_extended_due_date(section_module), + 'due': section_module.due, 'graded': graded, }) @@ -480,7 +484,7 @@ def manual_transaction(): transaction.commit() -def iterate_grades_for(course_id, students): +def iterate_grades_for(course_or_id, students): """Given a course_id and an iterable of students (User), yield a tuple of: (student, gradeset, err_msg) for every student enrolled in the course. @@ -498,7 +502,10 @@ def iterate_grades_for(course_id, students): make up the final grade. (For display) - raw_scores: contains scores for every graded module """ - course = courses.get_course_by_id(course_id) + if isinstance(course_or_id, (basestring, CourseKey)): + course = courses.get_course_by_id(course_or_id) + else: + course = course_or_id # We make a fake request because grading code expects to be able to look at # the request. We have to attach the correct user to the request before @@ -506,7 +513,7 @@ def iterate_grades_for(course_id, students): request = RequestFactory().get('/') for student in students: - with dog_stats_api.timer('lms.grades.iterate_grades_for', tags=[u'action:{}'.format(course_id)]): + with dog_stats_api.timer('lms.grades.iterate_grades_for', tags=[u'action:{}'.format(course.id)]): try: request.user = student # Grading calls problem rendering, which calls masquerading, @@ -523,7 +530,7 @@ def iterate_grades_for(course_id, students): 'Cannot grade student %s (%s) in course %s because of exception: %s', student.username, student.id, - course_id, + course.id, exc.message ) yield student, {}, exc.message diff --git a/lms/djangoapps/courseware/migrations/0011_add_model_StudentFieldOverride.py b/lms/djangoapps/courseware/migrations/0011_add_model_StudentFieldOverride.py new file mode 100644 index 0000000000..22bc4ae689 --- /dev/null +++ b/lms/djangoapps/courseware/migrations/0011_add_model_StudentFieldOverride.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +# pylint: disable=invalid-name, missing-docstring, unused-argument, unused-import, line-too-long + +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'StudentFieldOverride' + db.create_table('courseware_studentfieldoverride', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)), + ('location', self.gf('xmodule_django.models.LocationKeyField')(max_length=255, db_index=True)), + ('student', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('field', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('value', self.gf('django.db.models.fields.TextField')(default='null')), + )) + db.send_create_signal('courseware', ['StudentFieldOverride']) + + def backwards(self, orm): + # Deleting model 'StudentFieldOverride' + db.delete_table('courseware_studentfieldoverride') + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'courseware.offlinecomputedgrade': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.offlinecomputedgradelog': { + 'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'}) + }, + 'courseware.studentfieldoverride': { + 'Meta': {'unique_together': "(('course_id', 'location', 'student'),)", 'object_name': 'StudentFieldOverride'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'field': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'location': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.studentmodule': { + 'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_state_key': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.studentmodulehistory': { + 'Meta': {'object_name': 'StudentModuleHistory'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student_module': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['courseware.StudentModule']"}), + 'version': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'null': 'True', 'blank': 'True'}) + }, + 'courseware.xmodulestudentinfofield': { + 'Meta': {'unique_together': "(('student', 'field_name'),)", 'object_name': 'XModuleStudentInfoField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmodulestudentprefsfield': { + 'Meta': {'unique_together': "(('student', 'module_type', 'field_name'),)", 'object_name': 'XModuleStudentPrefsField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmoduleuserstatesummaryfield': { + 'Meta': {'unique_together': "(('usage_id', 'field_name'),)", 'object_name': 'XModuleUserStateSummaryField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'usage_id': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + } + } + + complete_apps = ['courseware'] diff --git a/lms/djangoapps/courseware/migrations/0012_auto__del_unique_studentfieldoverride_course_id_location_student__add_.py b/lms/djangoapps/courseware/migrations/0012_auto__del_unique_studentfieldoverride_course_id_location_student__add_.py new file mode 100644 index 0000000000..3809138373 --- /dev/null +++ b/lms/djangoapps/courseware/migrations/0012_auto__del_unique_studentfieldoverride_course_id_location_student__add_.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +# pylint: disable=invalid-name, missing-docstring, unused-argument, unused-import, line-too-long + +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding unique constraint on 'StudentFieldOverride', fields ['course_id', 'field', 'location', 'student'] + db.create_unique('courseware_studentfieldoverride', ['course_id', 'field', 'location', 'student_id']) + + def backwards(self, orm): + # Removing unique constraint on 'StudentFieldOverride', fields ['course_id', 'field', 'location', 'student'] + db.delete_unique('courseware_studentfieldoverride', ['course_id', 'field', 'location', 'student_id']) + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'courseware.offlinecomputedgrade': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.offlinecomputedgradelog': { + 'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'}) + }, + 'courseware.studentfieldoverride': { + 'Meta': {'unique_together': "(('course_id', 'field', 'location', 'student'),)", 'object_name': 'StudentFieldOverride'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'field': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'location': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.studentmodule': { + 'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_state_key': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.studentmodulehistory': { + 'Meta': {'object_name': 'StudentModuleHistory'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student_module': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['courseware.StudentModule']"}), + 'version': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'null': 'True', 'blank': 'True'}) + }, + 'courseware.xmodulestudentinfofield': { + 'Meta': {'unique_together': "(('student', 'field_name'),)", 'object_name': 'XModuleStudentInfoField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmodulestudentprefsfield': { + 'Meta': {'unique_together': "(('student', 'module_type', 'field_name'),)", 'object_name': 'XModuleStudentPrefsField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmoduleuserstatesummaryfield': { + 'Meta': {'unique_together': "(('usage_id', 'field_name'),)", 'object_name': 'XModuleUserStateSummaryField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'usage_id': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + } + } + + complete_apps = ['courseware'] diff --git a/lms/djangoapps/courseware/migrations/0013_auto__add_field_studentfieldoverride_created__add_field_studentfieldov.py b/lms/djangoapps/courseware/migrations/0013_auto__add_field_studentfieldoverride_created__add_field_studentfieldov.py new file mode 100644 index 0000000000..28b82c8425 --- /dev/null +++ b/lms/djangoapps/courseware/migrations/0013_auto__add_field_studentfieldoverride_created__add_field_studentfieldov.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +# pylint: disable=invalid-name, missing-docstring, unused-argument, unused-import, line-too-long + +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'StudentFieldOverride.created' + db.add_column('courseware_studentfieldoverride', 'created', + self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now), + keep_default=False) + + # Adding field 'StudentFieldOverride.modified' + db.add_column('courseware_studentfieldoverride', 'modified', + self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now), + keep_default=False) + + # Adding index on 'StudentFieldOverride', fields ['course_id', 'location', 'student'] + db.create_index('courseware_studentfieldoverride', ['course_id', 'location', 'student_id']) + + def backwards(self, orm): + # Deleting field 'StudentFieldOverride.created' + db.delete_column('courseware_studentfieldoverride', 'created') + + # Deleting field 'StudentFieldOverride.modified' + db.delete_column('courseware_studentfieldoverride', 'modified') + + # Removing index on 'StudentFieldOverride', fields ['course_id', 'location', 'student'] + db.delete_index('courseware_studentfieldoverride', ['course_id', 'location', 'student_id']) + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'courseware.offlinecomputedgrade': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.offlinecomputedgradelog': { + 'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'}) + }, + 'courseware.studentfieldoverride': { + 'Meta': {'unique_together': "(('course_id', 'field', 'location', 'student'),)", 'object_name': 'StudentFieldOverride'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}), + 'field': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'location': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.studentmodule': { + 'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'}, + 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_state_key': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}), + 'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'courseware.studentmodulehistory': { + 'Meta': {'object_name': 'StudentModuleHistory'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}), + 'grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}), + 'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'student_module': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['courseware.StudentModule']"}), + 'version': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'null': 'True', 'blank': 'True'}) + }, + 'courseware.xmodulestudentinfofield': { + 'Meta': {'unique_together': "(('student', 'field_name'),)", 'object_name': 'XModuleStudentInfoField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmodulestudentprefsfield': { + 'Meta': {'unique_together': "(('student', 'module_type', 'field_name'),)", 'object_name': 'XModuleStudentPrefsField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'module_type': ('xmodule_django.models.BlockTypeKeyField', [], {'max_length': '64', 'db_index': 'True'}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'courseware.xmoduleuserstatesummaryfield': { + 'Meta': {'unique_together': "(('usage_id', 'field_name'),)", 'object_name': 'XModuleUserStateSummaryField'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'usage_id': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + } + } + + complete_apps = ['courseware'] diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index f477b10e04..43832bfaa7 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -18,7 +18,9 @@ from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver -from xmodule_django.models import CourseKeyField, LocationKeyField, BlockTypeKeyField +from model_utils.models import TimeStampedModel + +from xmodule_django.models import CourseKeyField, LocationKeyField, BlockTypeKeyField # pylint: disable=import-error class StudentModule(models.Model): @@ -35,8 +37,7 @@ class StudentModule(models.Model): ('course', 'course'), ('chapter', 'Section'), ('sequential', 'Subsection'), - ('library_content', 'Library Content'), - ) + ('library_content', 'Library Content')) ## These three are the key for the object module_type = models.CharField(max_length=32, choices=MODULE_TYPES, default='problem', db_index=True) @@ -86,7 +87,7 @@ class StudentModule(models.Model): return 'StudentModule<%r>' % ({ 'course_id': self.course_id, 'module_type': self.module_type, - 'student': self.student.username, + 'student': self.student.username, # pylint: disable=no-member 'module_state_key': self.module_state_key, 'state': str(self.state)[:20], },) @@ -230,3 +231,20 @@ class OfflineComputedGradeLog(models.Model): def __unicode__(self): return "[OCGLog] %s: %s" % (self.course_id.to_deprecated_string(), self.created) # pylint: disable=no-member + + +class StudentFieldOverride(TimeStampedModel): + """ + Holds the value of a specific field overriden for a student. This is used + by the code in the `courseware.student_field_overrides` module to provide + overrides of xblock fields on a per user basis. + """ + course_id = CourseKeyField(max_length=255, db_index=True) + location = LocationKeyField(max_length=255, db_index=True) + student = models.ForeignKey(User, db_index=True) + + class Meta(object): # pylint: disable=missing-docstring + unique_together = (('course_id', 'field', 'location', 'student'),) + + field = models.CharField(max_length=255) + value = models.TextField(default='null') diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index de1e737455..920d2f1545 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -54,7 +54,6 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey from xmodule.contentstore.django import contentstore from xmodule.modulestore.django import modulestore, ModuleI18nService 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, @@ -72,6 +71,8 @@ from util import milestones_helpers from util.module_utils import yield_dynamic_descriptor_descendents from verify_student.services import ReverificationService +from .field_overrides import OverrideFieldData + log = logging.getLogger(__name__) @@ -171,7 +172,7 @@ def toc_for_course(request, course, active_chapter, active_section, field_data_c 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': get_extended_due_date(section), + 'due': section.due, 'active': active, 'graded': section.graded, }) @@ -497,11 +498,17 @@ def get_module_system_for_user(user, field_data_cache, request_token=request_token ) # rebinds module to a different student. We'll change system, student_data, and scope_ids + authored_data = OverrideFieldData.wrap( + real_user, module.descriptor._field_data # pylint: disable=protected-access + ) module.descriptor.bind_for_student( inner_system, - LmsFieldData(module.descriptor._field_data, inner_student_data), # pylint: disable=protected-access + LmsFieldData(authored_data, inner_student_data), real_user.id, ) + module.descriptor.scope_ids = ( + module.descriptor.scope_ids._replace(user_id=real_user.id) # pylint: disable=protected-access + ) module.scope_ids = module.descriptor.scope_ids # this is needed b/c NamedTuples are immutable # now bind the module to the new ModuleSystem instance and vice-versa module.runtime = inner_system @@ -670,13 +677,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours request_token (str): A unique token for this request, used to isolate xblock rendering """ - # Do not check access when it's a noauth request. - if getattr(user, 'known', True): - # Short circuit--if the user shouldn't have access, bail without doing any work - if not has_access(user, 'load', descriptor, course_id): - return None - - (system, field_data) = get_module_system_for_user( + (system, student_data) = get_module_system_for_user( user=user, field_data_cache=field_data_cache, # These have implicit user bindings, the rest of args are considered not to descriptor=descriptor, @@ -691,7 +692,18 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours request_token=request_token ) - descriptor.bind_for_student(system, field_data, user.id) # pylint: disable=protected-access + authored_data = OverrideFieldData.wrap(user, descriptor._field_data) # pylint: disable=protected-access + descriptor.bind_for_student(system, LmsFieldData(authored_data, student_data), user.id) + descriptor.scope_ids = descriptor.scope_ids._replace(user_id=user.id) # pylint: disable=protected-access + + # Do not check access when it's a noauth request. + # Not that the access check needs to happen after the descriptor is bound + # for the student, since there may be field override data for the student + # that affects xblock visibility. + if getattr(user, 'known', True): + if not has_access(user, 'load', descriptor, course_id): + return None + return descriptor diff --git a/lms/djangoapps/courseware/student_field_overrides.py b/lms/djangoapps/courseware/student_field_overrides.py new file mode 100644 index 0000000000..afbc10ac79 --- /dev/null +++ b/lms/djangoapps/courseware/student_field_overrides.py @@ -0,0 +1,84 @@ +""" +API related to providing field overrides for individual students. This is used +by the individual due dates feature. +""" +import json + +from .field_overrides import FieldOverrideProvider +from .models import StudentFieldOverride + + +class IndividualStudentOverrideProvider(FieldOverrideProvider): + """ + A concrete implementation of + :class:`~courseware.field_overrides.FieldOverrideProvider` which allows for + overrides to be made on a per user basis. + """ + def get(self, block, name, default): + return get_override_for_user(self.user, block, name, default) + + +def get_override_for_user(user, block, name, default=None): + """ + Gets the value of the overridden field for the `user`. `block` and `name` + specify the block and the name of the field. If the field is not + overridden for the given user, returns `default`. + """ + if not hasattr(block, '_student_overrides'): + block._student_overrides = {} # pylint: disable=protected-access + overrides = block._student_overrides.get(user.id) # pylint: disable=protected-access + if overrides is None: + overrides = _get_overrides_for_user(user, block) + block._student_overrides[user.id] = overrides # pylint: disable=protected-access + return overrides.get(name, default) + + +def _get_overrides_for_user(user, block): + """ + Gets all of the individual student overrides for given user and block. + Returns a dictionary of field override values keyed by field name. + """ + query = StudentFieldOverride.objects.filter( + course_id=block.runtime.course_id, + location=block.location, + student_id=user.id, + ) + overrides = {} + for override in query: + field = block.fields[override.field] + value = field.from_json(json.loads(override.value)) + overrides[override.field] = value + return overrides + + +def override_field_for_user(user, block, name, value): + """ + Overrides a field for the `user`. `block` and `name` specify the block + and the name of the field on that block to override. `value` is the + value to set for the given field. + """ + override, _ = StudentFieldOverride.objects.get_or_create( + course_id=block.runtime.course_id, + location=block.location, + student_id=user.id, + field=name) + field = block.fields[name] + override.value = json.dumps(field.to_json(value)) + override.save() + + +def clear_override_for_user(user, block, name): + """ + Clears a previously set field override for the `user`. `block` and `name` + specify the block and the name of the field on that block to clear. + This function is idempotent--if no override is set, nothing action is + performed. + """ + try: + StudentFieldOverride.objects.get( + course_id=block.runtime.course_id, + student_id=user.id, + location=block.location, + field=name).delete() + except StudentFieldOverride.DoesNotExist: + pass diff --git a/lms/djangoapps/courseware/tests/animport.py b/lms/djangoapps/courseware/tests/animport.py new file mode 100644 index 0000000000..268d0bb205 --- /dev/null +++ b/lms/djangoapps/courseware/tests/animport.py @@ -0,0 +1,5 @@ +""" +A class which never gets imported except for in +:meth:`~courseware.tests.test_field_overrides.ResolveDottedTests.test_import_something_that_isnt_already_loaded`. +""" +SOMENAME = 'bar' diff --git a/lms/djangoapps/courseware/tests/test_field_overrides.py b/lms/djangoapps/courseware/tests/test_field_overrides.py new file mode 100644 index 0000000000..1504be6d48 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_field_overrides.py @@ -0,0 +1,123 @@ +""" +Tests for `field_overrides` module. +""" +import unittest + +from django.test import TestCase +from django.test.utils import override_settings +from xblock.field_data import DictFieldData + +from ..field_overrides import ( + disable_overrides, + FieldOverrideProvider, + OverrideFieldData, + resolve_dotted, +) + + +TESTUSER = "testuser" + + +@override_settings(FIELD_OVERRIDE_PROVIDERS=( + 'courseware.tests.test_field_overrides.TestOverrideProvider',)) +class OverrideFieldDataTests(TestCase): + """ + Tests for `OverrideFieldData`. + """ + + def setUp(self): + super(OverrideFieldDataTests, self).setUp() + OverrideFieldData.provider_classes = None + + def tearDown(self): + super(OverrideFieldDataTests, self).tearDown() + OverrideFieldData.provider_classes = None + + def make_one(self): + """ + Factory method. + """ + return OverrideFieldData.wrap(TESTUSER, DictFieldData({ + 'foo': 'bar', + 'bees': 'knees', + })) + + def test_get(self): + data = self.make_one() + self.assertEqual(data.get('block', 'foo'), 'fu') + self.assertEqual(data.get('block', 'bees'), 'knees') + with disable_overrides(): + self.assertEqual(data.get('block', 'foo'), 'bar') + + def test_set(self): + data = self.make_one() + data.set('block', 'foo', 'yowza') + self.assertEqual(data.get('block', 'foo'), 'fu') + with disable_overrides(): + self.assertEqual(data.get('block', 'foo'), 'yowza') + + def test_delete(self): + data = self.make_one() + data.delete('block', 'foo') + self.assertEqual(data.get('block', 'foo'), 'fu') + with disable_overrides(): + # Since field_data is responsible for attribute access, you'd + # expect it to raise AttributeError. In fact, it raises KeyError, + # so we check for that. + with self.assertRaises(KeyError): + data.get('block', 'foo') + + def test_has(self): + data = self.make_one() + self.assertTrue(data.has('block', 'foo')) + self.assertTrue(data.has('block', 'bees')) + self.assertTrue(data.has('block', 'oh')) + with disable_overrides(): + self.assertFalse(data.has('block', 'oh')) + + def test_many(self): + data = self.make_one() + data.set_many('block', {'foo': 'baz', 'ah': 'ic'}) + self.assertEqual(data.get('block', 'foo'), 'fu') + self.assertEqual(data.get('block', 'ah'), 'ic') + with disable_overrides(): + self.assertEqual(data.get('block', 'foo'), 'baz') + + @override_settings(FIELD_OVERRIDE_PROVIDERS=()) + def test_no_overrides_configured(self): + data = self.make_one() + self.assertIsInstance(data, DictFieldData) + + +class ResolveDottedTests(unittest.TestCase): + """ + Tests for `resolve_dotted`. + """ + + def test_bad_sub_import(self): + with self.assertRaises(ImportError): + resolve_dotted('courseware.tests.test_foo') + + def test_bad_import(self): + with self.assertRaises(ImportError): + resolve_dotted('nosuchpackage') + + def test_import_something_that_isnt_already_loaded(self): + self.assertEqual( + resolve_dotted('courseware.tests.animport.SOMENAME'), + 'bar' + ) + + +class TestOverrideProvider(FieldOverrideProvider): + """ + A concrete implementation of `FieldOverrideProvider` for testing. + """ + def get(self, block, name, default): + assert self.user is TESTUSER + assert block == 'block' + if name == 'foo': + return 'fu' + if name == 'oh': + return 'man' + return default diff --git a/lms/djangoapps/instructor/access.py b/lms/djangoapps/instructor/access.py index 168814256e..80678193fe 100644 --- a/lms/djangoapps/instructor/access.py +++ b/lms/djangoapps/instructor/access.py @@ -12,7 +12,13 @@ TO DO sync instructor and staff flags import logging from django_comment_common.models import Role -from student.roles import CourseBetaTesterRole, CourseInstructorRole, CourseStaffRole +from student.roles import ( + CourseBetaTesterRole, + CourseInstructorRole, + CourseCcxCoachRole, + CourseStaffRole, +) + log = logging.getLogger(__name__) @@ -20,6 +26,7 @@ ROLES = { 'beta': CourseBetaTesterRole, 'instructor': CourseInstructorRole, 'staff': CourseStaffRole, + 'ccx_coach': CourseCcxCoachRole, } diff --git a/lms/djangoapps/instructor/offline_gradecalc.py b/lms/djangoapps/instructor/offline_gradecalc.py index 83bb5f72d8..ad0b727acc 100644 --- a/lms/djangoapps/instructor/offline_gradecalc.py +++ b/lms/djangoapps/instructor/offline_gradecalc.py @@ -81,7 +81,6 @@ def student_grades(student, request, course, keep_raw_scores=False, use_offline= This is the main interface to get grades. It has the same parameters as grades.grade, as well as use_offline. If use_offline is True then this will look for an offline computed gradeset in the DB. ''' - if not use_offline: return grades.grade(student, request, course, keep_raw_scores=keep_raw_scores) diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 77624cfdd8..22333c0d15 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -50,6 +50,9 @@ from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory +from xmodule.fields import Date + +from courseware.models import StudentFieldOverride import instructor_task.api import instructor.views.api @@ -61,8 +64,8 @@ from instructor_task.api_helper import AlreadyRunningError from openedx.core.djangoapps.course_groups.cohorts import set_course_cohort_settings from .test_tools import msk_from_problem_urlname -from ..views.tools import get_extended_due +DATE_FIELD = Date() EXPECTED_CSV_HEADER = ( '"code","redeem_code_url","course_id","company_name","created_by","redeemed_by","invoice_id","purchaser",' '"customer_reference_number","internal_reference"' @@ -3114,6 +3117,23 @@ class TestInstructorAPIHelpers(TestCase): msk_from_problem_urlname(*args) +def get_extended_due(course, unit, user): + """ + Gets the overridden due date for the given user on the given unit. Returns + `None` if there is no override set. + """ + try: + override = StudentFieldOverride.objects.get( + course_id=course.id, + student=user, + location=unit.location, + field='due' + ) + return DATE_FIELD.from_json(json.loads(override.value)) + except StudentFieldOverride.DoesNotExist: + return None + + class TestDueDateExtensions(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Test data dumps for reporting. diff --git a/lms/djangoapps/instructor/tests/test_tools.py b/lms/djangoapps/instructor/tests/test_tools.py index 9724eb7bcd..17a08c72ed 100644 --- a/lms/djangoapps/instructor/tests/test_tools.py +++ b/lms/djangoapps/instructor/tests/test_tools.py @@ -3,15 +3,15 @@ Tests for views/tools.py. """ import datetime -import functools import mock import json import unittest from django.utils.timezone import utc +from django.test.utils import override_settings -from courseware.models import StudentModule -from student.tests.factories import UserFactory +from courseware.field_overrides import OverrideFieldData # pylint: disable=import-error +from student.tests.factories import UserFactory # pylint: disable=import-error from xmodule.fields import Date from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory @@ -180,6 +180,10 @@ class TestTitleOrUrl(unittest.TestCase): self.assertEquals(tools.title_or_url(unit), 'test:hello') +@override_settings( + FIELD_OVERRIDE_PROVIDERS=( + 'courseware.student_field_overrides.IndividualStudentOverrideProvider',), +) class TestSetDueDateExtension(ModuleStoreTestCase): """ Test the set_due_date_extensions function. @@ -189,53 +193,60 @@ class TestSetDueDateExtension(ModuleStoreTestCase): Fixtures. """ super(TestSetDueDateExtension, self).setUp() + OverrideFieldData.provider_classes = None - due = datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc) + self.due = due = datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc) course = CourseFactory.create() week1 = ItemFactory.create(due=due, parent=course) week2 = ItemFactory.create(due=due, parent=course) week3 = ItemFactory.create(parent=course) - - homework = ItemFactory.create( - parent=week1, - due=due - ) + homework = ItemFactory.create(parent=week1) + assignment = ItemFactory.create(parent=homework, due=due) user = UserFactory.create() - StudentModule( - state='{}', - student_id=user.id, - course_id=course.id, - module_state_key=week1.location).save() - StudentModule( - state='{}', - student_id=user.id, - course_id=course.id, - module_state_key=homework.location).save() self.course = course self.week1 = week1 self.homework = homework + self.assignment = assignment self.week2 = week2 self.week3 = week3 self.user = user - self.extended_due = functools.partial( - tools.get_extended_due, course, student=user) + # Apparently the test harness doesn't use LmsFieldStorage, and I'm not + # sure if there's a way to poke the test harness to do so. So, we'll + # just inject the override field storage in this brute force manner. + for block in (course, week1, week2, week3, homework, assignment): + block._field_data = OverrideFieldData.wrap( # pylint: disable=protected-access + user, block._field_data) # pylint: disable=protected-access + + def tearDown(self): + super(TestSetDueDateExtension, self).tearDown() + OverrideFieldData.provider_classes = None + + def _clear_field_data_cache(self): + """ + Clear field data cache for xblocks under test. Normally this would be + done by virtue of the fact that xblocks are reloaded on subsequent + requests. + """ + for block in (self.week1, self.week2, self.week3, + self.homework, self.assignment): + block.fields['due']._del_cached_value(block) # pylint: disable=protected-access 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) + self._clear_field_data_cache() + self.assertEqual(self.week1.due, extended) + self.assertEqual(self.homework.due, extended) + self.assertEqual(self.assignment.due, extended) - def test_set_due_date_extension_create_studentmodule(self): + def test_set_due_date_extension_num_queries(self): extended = datetime.datetime(2013, 12, 25, 0, 0, tzinfo=utc) - user = UserFactory.create() # No student modules for this user - tools.set_due_date_extension(self.course, self.week1, user, extended) - extended_due = functools.partial(tools.get_extended_due, self.course, student=user) - self.assertEqual(extended_due(self.week1), extended) - self.assertEqual(extended_due(self.homework), extended) + with self.assertNumQueries(4): + tools.set_due_date_extension(self.course, self.week1, self.user, extended) + self._clear_field_data_cache() def test_set_due_date_extension_invalid_date(self): extended = datetime.datetime(2009, 1, 1, 0, 0, tzinfo=utc) @@ -251,8 +262,7 @@ class TestSetDueDateExtension(ModuleStoreTestCase): extended = datetime.datetime(2013, 12, 25, 0, 0, tzinfo=utc) tools.set_due_date_extension(self.course, self.week1, self.user, extended) tools.set_due_date_extension(self.course, self.week1, self.user, None) - self.assertEqual(self.extended_due(self.week1), None) - self.assertEqual(self.extended_due(self.homework), None) + self.assertEqual(self.week1.due, self.due) class TestDataDumps(ModuleStoreTestCase): @@ -270,7 +280,6 @@ class TestDataDumps(ModuleStoreTestCase): course = CourseFactory.create() week1 = ItemFactory.create(due=due, parent=course) week2 = ItemFactory.create(due=due, parent=course) - week3 = ItemFactory.create(due=due, parent=course) homework = ItemFactory.create( parent=week1, @@ -278,51 +287,7 @@ class TestDataDumps(ModuleStoreTestCase): ) user1 = UserFactory.create() - StudentModule( - state='{}', - student_id=user1.id, - course_id=course.id, - module_state_key=week1.location).save() - StudentModule( - state='{}', - student_id=user1.id, - course_id=course.id, - module_state_key=week2.location).save() - StudentModule( - state='{}', - student_id=user1.id, - course_id=course.id, - module_state_key=week3.location).save() - StudentModule( - state='{}', - student_id=user1.id, - course_id=course.id, - module_state_key=homework.location).save() - user2 = UserFactory.create() - StudentModule( - state='{}', - student_id=user2.id, - course_id=course.id, - module_state_key=week1.location).save() - StudentModule( - state='{}', - student_id=user2.id, - course_id=course.id, - module_state_key=homework.location).save() - - user3 = UserFactory.create() - StudentModule( - state='{}', - student_id=user3.id, - course_id=course.id, - module_state_key=week1.location).save() - StudentModule( - state='{}', - student_id=user3.id, - course_id=course.id, - module_state_key=homework.location).save() - self.course = course self.week1 = week1 self.homework = homework diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 1610a796c1..628ed638ee 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -73,7 +73,7 @@ from instructor.enrollment import ( send_beta_role_email, unenroll_email, ) -from instructor.access import list_with_level, allow_access, revoke_access, update_forum_role +from instructor.access import list_with_level, allow_access, revoke_access, ROLES, update_forum_role from instructor.offline_gradecalc import student_grades import instructor_analytics.basic import instructor_analytics.distributions @@ -679,7 +679,7 @@ def bulk_beta_modify_access(request, course_id): @common_exceptions_400 @require_query_params( unique_student_identifier="email or username of user to change access", - rolename="'instructor', 'staff', or 'beta'", + rolename="'instructor', 'staff', 'beta', or 'ccx_coach'", action="'allow' or 'revoke'" ) def modify_access(request, course_id): @@ -691,7 +691,7 @@ def modify_access(request, course_id): Query parameters: unique_student_identifer is the target user's username or email - rolename is one of ['instructor', 'staff', 'beta'] + rolename is one of ['instructor', 'staff', 'beta', 'ccx_coach'] action is one of ['allow', 'revoke'] """ course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) @@ -720,10 +720,10 @@ def modify_access(request, course_id): rolename = request.GET.get('rolename') action = request.GET.get('action') - if rolename not in ['instructor', 'staff', 'beta']: - return HttpResponseBadRequest(strip_tags( - "unknown rolename '{}'".format(rolename) - )) + if rolename not in ROLES: + error = strip_tags("unknown rolename '{}'".format(rolename)) + log.error(error) + return HttpResponseBadRequest(error) # disallow instructors from removing their own instructor access. if rolename == 'instructor' and user == request.user and action != 'allow': @@ -762,7 +762,7 @@ def list_course_role_members(request, course_id): List instructors and staff. Requires instructor access. - rolename is one of ['instructor', 'staff', 'beta'] + rolename is one of ['instructor', 'staff', 'beta', 'ccx_coach'] Returns JSON of the form { "course_id": "some/course/id", @@ -783,7 +783,7 @@ def list_course_role_members(request, course_id): rolename = request.GET.get('rolename') - if rolename not in ['instructor', 'staff', 'beta']: + if rolename not in ROLES: return HttpResponseBadRequest() def extract_user_info(user): diff --git a/lms/djangoapps/instructor/views/tools.py b/lms/djangoapps/instructor/views/tools.py index 3959e2e2fb..d1bbb66269 100644 --- a/lms/djangoapps/instructor/views/tools.py +++ b/lms/djangoapps/instructor/views/tools.py @@ -10,7 +10,13 @@ from django.http import HttpResponseBadRequest from django.utils.timezone import utc from django.utils.translation import ugettext as _ -from courseware.models import StudentModule +from courseware.models import StudentFieldOverride +from courseware.field_overrides import disable_overrides +from courseware.student_field_overrides import ( + clear_override_for_user, + get_override_for_user, + override_field_for_user, +) from xmodule.fields import Date from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore @@ -175,22 +181,6 @@ def title_or_url(node): return title -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 - ) - - state = json.loads(student_module.state) - extended = state.get('extended_due', None) - if extended: - return DATE_FIELD.from_json(extended) - - def set_due_date_extension(course, unit, student, due_date): """ Sets a due date extension. Raises DashboardError if the unit or extended @@ -198,56 +188,22 @@ def set_due_date_extension(course, unit, student, due_date): """ if due_date: # Check that the new due date is valid: - original_due_date = getattr(unit, 'due', None) + with disable_overrides(): + original_due_date = getattr(unit, 'due', None) if not original_due_date: raise DashboardError(_("Unit {0} has no due date to extend.").format(unit.location)) if due_date < original_due_date: raise DashboardError(_("An extended due date must be later than the original due date.")) + + override_field_for_user(student, unit, 'due', due_date) + else: # We are deleting a due date extension. Check that it exists: - if not get_extended_due(course, unit, student): + if not get_override_for_user(student, unit, 'due'): raise DashboardError(_("No due date extension is set for that student and unit.")) - 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 - ) - state = json.loads(student_module.state) - - except StudentModule.DoesNotExist: - # Normally, a StudentModule is created as a side effect of assigning - # a value to a property in an XModule or XBlock which has a scope - # of 'Scope.user_state'. Here, we want to alter user state but - # can't use the standard XModule/XBlock machinery to do so, because - # it fails to take into account that the state being altered might - # belong to a student other than the one currently logged in. As a - # result, in our work around, we need to detect whether the - # StudentModule has been created for the given student on the given - # unit and create it if it is missing, so we can use it to store - # the extended due date. - student_module = StudentModule.objects.create( - student_id=student.id, - course_id=course.id, - module_state_key=node.location, - module_type=node.category - ) - state = {} - - state['extended_due'] = DATE_FIELD.to_json(due_date) - student_module.state = json.dumps(state) - student_module.save() - - for child in node.get_children(): - set_due_date(child) - - set_due_date(unit) + clear_override_for_user(student, unit, 'due') def dump_module_extensions(course, unit): @@ -257,20 +213,17 @@ def dump_module_extensions(course, unit): """ data = [] header = [_("Username"), _("Full Name"), _("Extended Due Date")] - query = StudentModule.objects.filter( + query = StudentFieldOverride.objects.filter( course_id=course.id, - module_state_key=unit.location) - 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 + location=unit.location, + field='due') + for override in query: + due = DATE_FIELD.from_json(json.loads(override.value)) + due = due.strftime("%Y-%m-%d %H:%M") + fullname = override.student.profile.name data.append(dict(zip( header, - (module.student.username, fullname, extended_due)))) + (override.student.username, fullname, due)))) data.sort(key=lambda x: x[header[0]]) return { "header": header, @@ -288,23 +241,19 @@ def dump_student_extensions(course, student): data = [] header = [_("Unit"), _("Extended Due Date")] units = get_units_with_due_date(course) - units = dict([(u.location, u) for u in units]) - query = StudentModule.objects.filter( + units = {u.location: u for u in units} + query = StudentFieldOverride.objects.filter( course_id=course.id, - student_id=student.id) - for module in query: - state = json.loads(module.state) - # temporary hack: module_state_key is missing the run but units are not. fix module_state_key - module_loc = module.module_state_key.map_into_course(module.course_id) - if module_loc not in units: + student=student, + field='due') + for override in query: + location = override.location.replace(course_key=course.id) + if location 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_loc]) - data.append(dict(zip(header, (title, extended_due)))) + due = DATE_FIELD.from_json(json.loads(override.value)) + due = due.strftime("%Y-%m-%d %H:%M") + title = title_or_url(units[location]) + data.append(dict(zip(header, (title, due)))) return { "header": header, "title": _("Due date extensions for {0} {1} ({2})").format( diff --git a/lms/envs/aws.py b/lms/envs/aws.py index a9221edf25..69ae6cf9d8 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -343,6 +343,10 @@ if FEATURES.get('ENABLE_CORS_HEADERS') or FEATURES.get('ENABLE_CROSS_DOMAIN_CSRF CROSS_DOMAIN_CSRF_COOKIE_DOMAIN = ENV_TOKENS.get('CROSS_DOMAIN_CSRF_COOKIE_DOMAIN') +# Field overrides. To use the IDDE feature, add +# 'courseware.student_field_overrides.IndividualStudentOverrideProvider'. +FIELD_OVERRIDE_PROVIDERS = tuple(ENV_TOKENS.get('FIELD_OVERRIDE_PROVIDERS', [])) + ############################## SECURE AUTH ITEMS ############### # Secret things: passwords, access keys, etc. @@ -572,3 +576,17 @@ ONLOAD_BEACON_SAMPLE_RATE = ENV_TOKENS.get('ONLOAD_BEACON_SAMPLE_RATE', ONLOAD_B ECOMMERCE_API_URL = ENV_TOKENS.get('ECOMMERCE_API_URL', ECOMMERCE_API_URL) ECOMMERCE_API_SIGNING_KEY = AUTH_TOKENS.get('ECOMMERCE_API_SIGNING_KEY', ECOMMERCE_API_SIGNING_KEY) ECOMMERCE_API_TIMEOUT = ENV_TOKENS.get('ECOMMERCE_API_TIMEOUT', ECOMMERCE_API_TIMEOUT) + +##### Custom Courses for EdX ##### +if FEATURES.get('CUSTOM_COURSES_EDX'): + INSTALLED_APPS += ('ccx',) + MIDDLEWARE_CLASSES += ('ccx.overrides.CcxMiddleware',) + FIELD_OVERRIDE_PROVIDERS += ( + 'ccx.overrides.CustomCoursesForEdxOverrideProvider', + ) + +##### Individual Due Date Extensions ##### +if FEATURES.get('INDIVIDUAL_DUE_DATES'): + FIELD_OVERRIDE_PROVIDERS += ( + 'courseware.student_field_overrides.IndividualStudentOverrideProvider', + ) diff --git a/lms/envs/common.py b/lms/envs/common.py index 4e72642093..daef8e9282 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -208,8 +208,15 @@ FEATURES = { 'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True, # Enable instructor to assign individual due dates + # Note: In order for this feature to work, you must also add + # 'courseware.student_field_overrides.IndividualStudentOverrideProvider' to + # the setting FIELD_OVERRIDE_PROVIDERS, in addition to setting this flag to + # True. 'INDIVIDUAL_DUE_DATES': False, + # Enable Custom Courses for EdX + 'CUSTOM_COURSES_EDX': False, + # Enable legacy instructor dashboard 'ENABLE_INSTRUCTOR_LEGACY_DASHBOARD': True, @@ -1196,6 +1203,9 @@ reverify_js = [ 'js/verify_student/incourse_reverify.js', ] +ccx_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/ccx/**/*.js')) + + PIPELINE_CSS = { 'style-vendor': { 'source_filenames': [ @@ -1389,6 +1399,10 @@ PIPELINE_JS = { 'reverify': { 'source_filenames': reverify_js, 'output_filename': 'js/reverify.js' + }, + 'ccx': { + 'source_filenames': ccx_js, + 'output_filename': 'js/ccx.js' } } @@ -2216,3 +2230,9 @@ ECOMMERCE_API_TIMEOUT = 5 # Reverification checkpoint name pattern CHECKPOINT_PATTERN = r'(?P\w+)' + +# For the fields override feature +# If using FEATURES['INDIVIDUAL_DUE_DATES'], you should add +# 'courseware.student_field_overrides.IndividualStudentOverrideProvider' to +# this setting. +FIELD_OVERRIDE_PROVIDERS = () diff --git a/lms/envs/test.py b/lms/envs/test.py index 4204496988..fea9c54e48 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -445,7 +445,6 @@ MONGODB_LOG = { 'db': 'xlog', } - # Enable EdxNotes for tests. FEATURES['ENABLE_EDXNOTES'] = True @@ -469,3 +468,7 @@ FACEBOOK_API_VERSION = "v2.2" # Certificates Views FEATURES['CERTIFICATES_HTML_VIEW'] = True + +######### custom courses ######### +INSTALLED_APPS += ('ccx',) +MIDDLEWARE_CLASSES += ('ccx.overrides.CcxMiddleware',) diff --git a/lms/static/js/ccx/schedule.js b/lms/static/js/ccx/schedule.js new file mode 100644 index 0000000000..ef51f79b63 --- /dev/null +++ b/lms/static/js/ccx/schedule.js @@ -0,0 +1,396 @@ +var edx = edx || {}; + +(function($, _, Backbone, gettext) { + 'use strict'; + + edx.ccx = edx.ccx || {}; + edx.ccx.schedule = edx.ccx.schedule || {}; + + var syncErrorMessage = gettext("The data could not be saved."); + + var self; + + edx.ccx.schedule.reloadPage = function() { + location.reload(); + }; + + edx.ccx.schedule.UnitModel = Backbone.Model.extend({ + defaults: { + location: '', + display_name: '', + start: null, + due: null, + category: '', + hidden: false, + children: [] + }, + + }); + + edx.ccx.schedule.Schedule = Backbone.Collection.extend({ + + model: edx.ccx.schedule.UnitModel, + url: 'ccx_schedule' + + }); + + edx.ccx.schedule.ScheduleView = Backbone.View.extend({ + + initialize: function() { + _.bindAll(this, 'render'); + this.schedule_collection = new edx.ccx.schedule.Schedule(); + this.schedule = {}; + this.schedule_collection.bind('reset', this.render); + this.schedule_collection.fetch({reset: true}); + this.chapter_select = $('form#add-unit select[name="chapter"]'), + this.sequential_select = $('form#add-unit select[name="sequential"]'), + this.vertical_select = $('form#add-unit select[name="vertical"]'); + this.dirty = false; + self = this; + $('#add-all').on('click', function(event) { + event.preventDefault(); + self.schedule_apply(self.schedule, self.show); + self.dirty = true; + self.schedule_collection.set(self.schedule); + self.render(); + }); + + // Add unit handlers + this.chapter_select.on('change', function(event) { + var chapter_location = self.chapter_select.val(); + self.vertical_select.html('').prop('disabled', true); + if (chapter_location !== 'none') { + var chapter = self.find_unit(self.hidden, chapter_location); + self.sequential_select.html('') + .append('') + .append(self.schedule_options(chapter.children)); + self.sequential_select.prop('disabled', false); + $('#add-unit-button').prop('disabled', false); + self.set_datetime('start', chapter.start); + self.set_datetime('due', chapter.due); + } + else { + self.sequential_select.html('').prop('disabled', true); + } + }); + + this.sequential_select.on('change', function(event) { + var sequential_location = self.sequential_select.val(); + if (sequential_location !== 'all') { + var chapter = self.chapter_select.val(), + sequential = self.find_unit(self.hidden, chapter, sequential_location); + self.vertical_select.html('') + .append('') + .append(self.schedule_options(sequential.children)); + self.vertical_select.prop('disabled', false); + self.set_datetime('start', sequential.start); + self.set_datetime('due', sequential.due); + } + else { + self.vertical_select.html('').prop('disabled', true); + } + }); + + this.vertical_select.on('change', function(event) { + var vertical_location = self.vertical_select.val(); + if (vertical_location !== 'all') { + var chapter = self.chapter_select.val(), + sequential = self.sequential_select.val(); + var vertical = self.find_unit( + self.hidden, chapter, sequential, vertical_location); + self.set_datetime('start', vertical.start); + self.set_datetime('due', vertical.due); + } + }); + + // Add unit handler + $('#add-unit-button').on('click', function(event) { + event.preventDefault(); + var chapter = self.chapter_select.val(), + sequential = self.sequential_select.val(), + vertical = self.vertical_select.val(), + units = self.find_lineage(self.schedule, + chapter, + sequential == 'all' ? null : sequential, + vertical == 'all' ? null: vertical), + start = self.get_datetime('start'), + due = self.get_datetime('due'); + units.map(self.show); + var unit = units[units.length - 1] + self.schedule_apply([unit], self.show); + if (unit !== undefined && start) unit.start = start; + if (unit !== undefined && due) unit.due = due; + self.schedule_collection.set(self.schedule); + self.dirty = true; + self.render(); + }); + + // Handle save button + $('#dirty-schedule #save-changes').on('click', function(event) { + event.preventDefault(); + self.save(); + }); + + }, + + render: function() { + self.schedule = this.schedule_collection.toJSON(); + self.hidden = this.pruned(self.schedule, function(node) { + return node.hidden || node.category !== 'vertical'}); + this.showing = this.pruned(self.schedule, function(node) { + return !node.hidden}); + this.$el.html(schedule_template({chapters: this.showing})); + $('table.ccx-schedule .sequential,.vertical').hide(); + $('table.ccx-schedule .toggle-collapse').on('click', this.toggle_collapse); + // + // Hidden hover fields for empty date fields + $('table.ccx-schedule .date a').each(function() { + if (! $(this).text()) { + $(this).text('Set date').addClass('empty'); + } + }); + + // Handle date edit clicks + $('table.ccx-schedule .date a').attr('href', '#enter-date-modal') + .leanModal({closeButton: '.close-modal'}); + $('table.ccx-schedule .due-date a').on('click', this.enterNewDate('due')); + $('table.ccx-schedule .start-date a').on('click', this.enterNewDate('start')); + // Click handler for remove all + $('table.ccx-schedule a#remove-all').on('click', function(event) { + event.preventDefault(); + self.schedule_apply(self.schedule, self.hide); + self.dirty = true; + self.schedule_collection.set(self.schedule); + self.render(); + }); + // Remove unit handler + $('table.ccx-schedule a.remove-unit').on('click', function(event) { + var row = $(this).closest('tr'), + path = row.data('location').split(' '), + unit = self.find_unit(self.schedule, path[0], path[1], path[2]); + self.schedule_apply([unit], self.hide); + self.schedule_collection.set(self.schedule); + self.dirty = true; + self.render(); + }); + + + // Show or hide form + if (this.hidden.length) { + // Populate chapters select, depopulate others + this.chapter_select.html('') + .append('') + .append(self.schedule_options(this.hidden)); + this.sequential_select.html('').prop('disabled', true); + this.vertical_select.html('').prop('disabled', true); + $('form#add-unit').show(); + $('#all-units-added').hide(); + $('#add-unit-button').prop('disabled', true); + } + else { + $('form#add-unit').hide(); + $('#all-units-added').show(); + } + + // Show or hide save button + if (this.dirty) $('#dirty-schedule').show() + else $('#dirty-schedule').hide(); + + $('#ajax-error').hide(); + + return this; + }, + + save: function() { + self.schedule_collection.set(self.schedule); + var button = $('#dirty-schedule #save-changes'); + button.prop('disabled', true).text(gettext("Saving")+'...'); + + $.ajax({ + url: save_url, + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(self.schedule), + success: function(data, textStatus, jqXHR) { + self.dirty = false; + self.render(); + button.prop('disabled', false).text(gettext("Save changes")); + + // Update textarea with grading policy JSON, since grading policy + // may have changed. + $('#grading-policy').text(data.grading_policy); + }, + error: function(jqXHR, textStatus, error) { + console.log(jqXHR.responseText); + $('#ajax-error').show(); + $('#dirty-schedule').hide(); + $('form#add-unit select,input,button').prop('disabled', true); + button.prop('disabled', false).text(gettext("Save changes")); + } + }); + }, + + hide: function(unit) { + if (unit !== undefined) { + unit.hidden = true; + } + }, + + show: function(unit) { + if (unit !== undefined) { + unit.hidden = false; + } + }, + + get_datetime: function(which) { + var date = $('form#add-unit input[name=' + which + '_date]').val(); + var time = $('form#add-unit input[name=' + which + '_time]').val(); + if (date && time) + return date + ' ' + time; + return null; + }, + + set_datetime: function(which, value) { + var parts = value ? value.split(' ') : ['', ''], + date = parts[0], + time = parts[1]; + $('form#add-unit input[name=' + which + '_date]').val(date); + $('form#add-unit input[name=' + which + '_time]').val(time); + }, + + schedule_options: function(nodes) { + return nodes.map(function(node) { + return $('