diff --git a/common/lib/xmodule/xmodule/capa_base.py b/common/lib/xmodule/xmodule/capa_base.py index 3b8ce984c229be89690b2041eede6d50b6c8c0b0..3082933fe89119a1992674d19d3617bf5a100114 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 03b1f217d56301d9215ddf0241e210c048cb6761..7e3d1fe8ccf9a522fca940b997b36de9faa362b3 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/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py index 666d247bfd6e1c6b73df5bb08f5b1dd18fb32507..de5957db94189ff8c2ec2a6564d44f52750ca566 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 2727e2fa9caa3b9e1d653adeb54ce2c311cfc096..d6293647c7185fc8744d9310b9f2576610816547 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 8e49027203f6fa629f80d53ca6591c795a74e4f5..d99884f392e1afc43528c06066727bbf0aeece38 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 086580f06e06273b405d4a4bac5c60efa6ebc917..6e0da07a8fdb137297bcccacaf2b475d77efb5c3 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 1bdc289440ec39973ed3efbd840f2947ade98d9b..137b7dd31a4563d3f7cc7ea96295ac4a742d2878 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/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index d1309fded4578ee4c0807dba88e423ef8f72ce29..2c666743aeb7c986f9571f5fbf917bf57b478437 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 = """\ <problem display_name="Multiple Choice Questions"> <p>What color is the open ocean on a sunny day?</p> <optionresponse> @@ -1767,7 +1760,11 @@ class TestProblemCheckTracking(unittest.TestCase): </checkboxgroup> </choiceresponse> </problem> - """) + """ + + # 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 9114ffed402d243a36753422fa805acb68886067..eb351c00af481264e583001c370258bdf767ca52 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/courseware/field_overrides.py b/lms/djangoapps/courseware/field_overrides.py new file mode 100644 index 0000000000000000000000000000000000000000..2901d819538275dc6acf4d058988a891ca6bb7d8 --- /dev/null +++ b/lms/djangoapps/courseware/field_overrides.py @@ -0,0 +1,205 @@ +""" +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.django import modulestore +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. + """ + location = modulestore().get_parent_location(block.location) + while location: + yield modulestore().get_item(location) + location = modulestore().get_parent_location(location) diff --git a/lms/djangoapps/courseware/grades.py b/lms/djangoapps/courseware/grades.py index 3db3cb8f359b516fed97c717e9cb8cb6ea98fba0..1abfe2c092aefd5b8c1d21915202e8b02ce4a129 100644 --- a/lms/djangoapps/courseware/grades.py +++ b/lms/djangoapps/courseware/grades.py @@ -20,7 +20,6 @@ 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 @@ -373,7 +372,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, }) 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 0000000000000000000000000000000000000000..d8f5caadd6285912c521079f3361d9a09e9359af --- /dev/null +++ b/lms/djangoapps/courseware/migrations/0011_add_model_StudentFieldOverride.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +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'] \ No newline at end of file diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index f477b10e0435a33806f3ffcd8edb00663ad0aa69..75a7afffa3d9c1859086a29c5c058640649917d7 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -230,3 +230,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(models.Model): + """ + 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: # pylint: disable=missing-docstring + unique_together = (('course_id', '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 799c71eafc8359427737b436a46a6c1e0edcd9cc..923827e84d58826f72d16f7a064eab9aeefbcefb 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, @@ -71,6 +70,8 @@ from util.sandboxing import can_execute_unsafe_code, get_python_lib_zip from util import milestones_helpers from util.module_utils import yield_dynamic_descriptor_descendents +from .field_overrides import OverrideFieldData + log = logging.getLogger(__name__) @@ -170,7 +171,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, }) @@ -496,11 +497,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 @@ -689,7 +696,9 @@ 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, field_data), user.id) + descriptor.scope_ids = descriptor.scope_ids._replace(user_id=user.id) # pylint: disable=protected-access 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 0000000000000000000000000000000000000000..1e22efa6bc6a3e17e815d167c6c199422b544498 --- /dev/null +++ b/lms/djangoapps/courseware/student_field_overrides.py @@ -0,0 +1,71 @@ +""" +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`. + """ + try: + override = StudentFieldOverride.objects.get( + course_id=block.runtime.course_id, + location=block.location, + student_id=user.id, + field=name + ) + field = block.fields[name] + return field.from_json(json.loads(override.value)) + except StudentFieldOverride.DoesNotExist: + pass + return default + + +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 0000000000000000000000000000000000000000..268d0bb205a91bd90404d86a971ce19822284001 --- /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 0000000000000000000000000000000000000000..77ea374f4c71eca28f3d80ecab9381e39da8ddc2 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_field_overrides.py @@ -0,0 +1,121 @@ +""" +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 = object() + + +@override_settings(FIELD_OVERRIDE_PROVIDERS=( + 'courseware.tests.test_field_overrides.TestOverrideProvider',)) +class OverrideFieldDataTests(TestCase): + """ + Tests for `OverrideFieldData`. + """ + + def setUp(self): + OverrideFieldData.provider_classes = None + + def tearDown(self): + 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/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 77624cfdd85c852695b41589a9f9188f6ab27cd1..d9a399e0bf5b1cb4344a6caf0660e677ff6f84ed 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,24 @@ 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 + + +@override_settings(MODULESTORE=TEST_DATA_MOCK_MODULESTORE) 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 9724eb7bcdf20784b4174b6ba19a399a112ba435..1f6e596b635abce408ef53b99c2dc38c8862351d 100644 --- a/lms/djangoapps/instructor/tests/test_tools.py +++ b/lms/djangoapps/instructor/tests/test_tools.py @@ -3,14 +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 courseware.field_overrides import OverrideFieldData from student.tests.factories import UserFactory from xmodule.fields import Date from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -180,6 +181,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 +194,53 @@ 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): + 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) - - def test_set_due_date_extension_create_studentmodule(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) + 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_invalid_date(self): extended = datetime.datetime(2009, 1, 1, 0, 0, tzinfo=utc) @@ -251,8 +256,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): @@ -278,51 +282,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/tools.py b/lms/djangoapps/instructor/views/tools.py index 3959e2e2fba16315d1f2250319ad179921119e51..d1bbb662693aa9b8342c7d63a0e8e8f423cfeeab 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: - continue - extended_due = state.get("extended_due") - if not extended_due: + student=student, + field='due') + for override in query: + location = override.location.replace(course_key=course.id) + if location not in units: 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 a9221edf25a3f096b2e427abf099ae283e584e59..b3892c62a76b8ef7df548b543a008395215cba07 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. diff --git a/lms/envs/common.py b/lms/envs/common.py index 60283e969328d6c125a1adbf7f6da56a9f369b49..080e40baed2300a6a8670ad663945f0e1e12fde4 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -208,6 +208,10 @@ 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 legacy instructor dashboard @@ -2225,3 +2229,9 @@ ECOMMERCE_API_TIMEOUT = 5 # Reverification checkpoint name pattern CHECKPOINT_PATTERN = r'(?P<checkpoint_name>\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 = ()