Architecture for arbitrary field overrides, field overrides for
individual students, and a reimplementation of the individual due date feature. This work introduces an architecture, used with the 'authored_data' portion of LmsFieldData, which allows arbitrary field overrides to be made for fields that are part of the course content or settings (Mongo data). The basic architecture is extensible by means of writing and configuring arbitrary field override providers. One concrete implementation of a field override provider is provided which allows for overrides to be for individual students. This provider is then used as a basis for reimplementing the individual due date extensions feature as a proof of concept for the design. One can imagine writing override providers that provide overrides based on a student's membership in a cohort or other similar idea. This work is being done, in fact, to pave the way for the Personal Online Courses feature being developed by MIT, which will use an override provider very much long those lines.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 Ⱡ'σя#"
|
||||
|
||||
205
lms/djangoapps/courseware/field_overrides.py
Normal file
205
lms/djangoapps/courseware/field_overrides.py
Normal file
@@ -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)
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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']
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
71
lms/djangoapps/courseware/student_field_overrides.py
Normal file
71
lms/djangoapps/courseware/student_field_overrides.py
Normal file
@@ -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
|
||||
5
lms/djangoapps/courseware/tests/animport.py
Normal file
5
lms/djangoapps/courseware/tests/animport.py
Normal file
@@ -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'
|
||||
121
lms/djangoapps/courseware/tests/test_field_overrides.py
Normal file
121
lms/djangoapps/courseware/tests/test_field_overrides.py
Normal file
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 = ()
|
||||
|
||||
Reference in New Issue
Block a user