Merge pull request #6636 from jazkarta/feature-pocs
MIT CCx (was Personal Online Courses)
This commit is contained in:
@@ -267,6 +267,14 @@ class LibraryUserRole(CourseRole):
|
||||
super(LibraryUserRole, self).__init__(self.ROLE, *args, **kwargs)
|
||||
|
||||
|
||||
class CourseCcxCoachRole(CourseRole):
|
||||
"""A CCX Coach"""
|
||||
ROLE = 'ccx_coach'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(CourseCcxCoachRole, self).__init__(self.ROLE, *args, **kwargs)
|
||||
|
||||
|
||||
class OrgStaffRole(OrgRole):
|
||||
"""An organization staff member"""
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -662,6 +662,17 @@ def dashboard(request):
|
||||
if course.pre_requisite_courses)
|
||||
courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites)
|
||||
|
||||
ccx_membership_triplets = []
|
||||
if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
|
||||
from ccx import ACTIVE_CCX_KEY
|
||||
from ccx.utils import get_ccx_membership_triplets
|
||||
ccx_membership_triplets = get_ccx_membership_triplets(
|
||||
user, course_org_filter, org_filter_out_set
|
||||
)
|
||||
# should we deselect any active CCX at this time so that we don't have
|
||||
# to change the URL for viewing a course? I think so.
|
||||
request.session[ACTIVE_CCX_KEY] = None
|
||||
|
||||
context = {
|
||||
'enrollment_message': enrollment_message,
|
||||
'course_enrollment_pairs': course_enrollment_pairs,
|
||||
@@ -693,6 +704,7 @@ def dashboard(request):
|
||||
'provider_states': [],
|
||||
'order_history_list': order_history_list,
|
||||
'courses_requirements_not_met': courses_requirements_not_met,
|
||||
'ccx_membership_triplets': ccx_membership_triplets,
|
||||
}
|
||||
|
||||
if third_party_auth.is_enabled():
|
||||
@@ -1810,6 +1822,16 @@ def activate_account(request, key):
|
||||
if cea.auto_enroll:
|
||||
CourseEnrollment.enroll(student[0], cea.course_id)
|
||||
|
||||
# enroll student in any pending CCXs he/she may have if auto_enroll flag is set
|
||||
if settings.FEATURES.get('CUSTOM_COURSES_EDX'):
|
||||
from ccx.models import CcxMembership, CcxFutureMembership
|
||||
ccxfms = CcxFutureMembership.objects.filter(
|
||||
email=student[0].email
|
||||
)
|
||||
for ccxfm in ccxfms:
|
||||
if ccxfm.auto_enroll:
|
||||
CcxMembership.auto_enroll(student[0], ccxfm)
|
||||
|
||||
resp = render_to_response(
|
||||
"registration/activation_complete.html",
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,7 +11,7 @@ from datetime import datetime
|
||||
import dateutil.parser
|
||||
from lazy import lazy
|
||||
|
||||
|
||||
from xmodule.exceptions import UndefinedContext
|
||||
from xmodule.seq_module import SequenceDescriptor, SequenceModule
|
||||
from xmodule.graders import grader_from_conf
|
||||
from xmodule.tabs import CourseTabList
|
||||
@@ -835,8 +835,16 @@ class CourseFields(object):
|
||||
)
|
||||
|
||||
|
||||
class CourseModule(CourseFields, SequenceModule): # pylint: disable=abstract-method
|
||||
"""
|
||||
The CourseDescriptor needs its module_class to be a SequenceModule, but some code that
|
||||
expects a CourseDescriptor to have all its fields can fail if it gets a SequenceModule instead.
|
||||
This class is to make sure that all the fields are present in all cases.
|
||||
"""
|
||||
|
||||
|
||||
class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
module_class = SequenceModule
|
||||
module_class = CourseModule
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
@@ -1213,6 +1221,14 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
|
||||
|
||||
|
||||
"""
|
||||
# If this descriptor has been bound to a student, return the corresponding
|
||||
# XModule. If not, just use the descriptor itself
|
||||
try:
|
||||
module = getattr(self, '_xmodule', None)
|
||||
if not module:
|
||||
module = self
|
||||
except UndefinedContext:
|
||||
module = self
|
||||
|
||||
all_descriptors = []
|
||||
graded_sections = {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -193,6 +193,7 @@ class CourseTab(object):
|
||||
'edxnotes': EdxNotesTab,
|
||||
'syllabus': SyllabusTab,
|
||||
'instructor': InstructorTab, # not persisted
|
||||
'ccx_coach': CcxCoachTab, # not persisted
|
||||
}
|
||||
|
||||
tab_type = tab_dict.get('type')
|
||||
@@ -375,6 +376,10 @@ class DiscussionTab(EnrolledOrStaffTab):
|
||||
)
|
||||
|
||||
def can_display(self, course, settings, is_user_authenticated, is_user_staff, is_user_enrolled):
|
||||
if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
|
||||
from ccx.overrides import get_current_ccx # pylint: disable=import-error
|
||||
if get_current_ccx():
|
||||
return False
|
||||
super_can_display = super(DiscussionTab, self).can_display(
|
||||
course, settings, is_user_authenticated, is_user_staff, is_user_enrolled
|
||||
)
|
||||
@@ -733,6 +738,42 @@ class InstructorTab(StaffTab):
|
||||
)
|
||||
|
||||
|
||||
class CcxCoachTab(CourseTab):
|
||||
"""
|
||||
A tab for the custom course coaches.
|
||||
"""
|
||||
type = 'ccx_coach'
|
||||
|
||||
def __init__(self, tab_dict=None): # pylint: disable=unused-argument
|
||||
super(CcxCoachTab, self).__init__(
|
||||
name=_('CCX Coach'),
|
||||
tab_id=self.type,
|
||||
link_func=link_reverse_func('ccx_coach_dashboard'),
|
||||
)
|
||||
|
||||
def can_display(self, course, settings, *args, **kw):
|
||||
"""
|
||||
Since we don't get the user here, we use a thread local defined in the ccx
|
||||
overrides to get it, then use the course to get the coach role and find out if
|
||||
the user is one.
|
||||
"""
|
||||
user_is_coach = False
|
||||
if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
|
||||
from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from student.roles import CourseCcxCoachRole # pylint: disable=import-error
|
||||
from ccx.overrides import get_current_request # pylint: disable=import-error
|
||||
course_id = course.id.to_deprecated_string()
|
||||
course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
|
||||
role = CourseCcxCoachRole(course_key)
|
||||
request = get_current_request()
|
||||
if request is not None:
|
||||
user_is_coach = role.has_user(request.user)
|
||||
super_can_display = super(CcxCoachTab, self).can_display(
|
||||
course, settings, *args, **kw
|
||||
)
|
||||
return user_is_coach and super_can_display
|
||||
|
||||
|
||||
class CourseTabList(List):
|
||||
"""
|
||||
An XBlock field class that encapsulates a collection of Tabs in a course.
|
||||
@@ -833,6 +874,9 @@ class CourseTabList(List):
|
||||
instructor_tab = InstructorTab()
|
||||
if instructor_tab.can_display(course, settings, is_user_authenticated, is_user_staff, is_user_enrolled):
|
||||
yield instructor_tab
|
||||
ccx_coach_tab = CcxCoachTab()
|
||||
if ccx_coach_tab.can_display(course, settings, is_user_authenticated, is_user_staff, is_user_enrolled):
|
||||
yield ccx_coach_tab
|
||||
|
||||
@staticmethod
|
||||
def iterate_displayable_cms(
|
||||
|
||||
@@ -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 Ⱡ'σя#"
|
||||
|
||||
4
lms/djangoapps/ccx/__init__.py
Normal file
4
lms/djangoapps/ccx/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
we use this to mark the active ccx, for use by ccx middleware and some views
|
||||
"""
|
||||
ACTIVE_CCX_KEY = '_ccx_id'
|
||||
136
lms/djangoapps/ccx/migrations/0001_initial.py
Normal file
136
lms/djangoapps/ccx/migrations/0001_initial.py
Normal file
@@ -0,0 +1,136 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# pylint: disable=invalid-name, missing-docstring, unused-argument, unused-import, line-too-long
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'CustomCourseForEdX'
|
||||
db.create_table('ccx_customcourseforedx', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)),
|
||||
('display_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
|
||||
('coach', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
|
||||
))
|
||||
db.send_create_signal('ccx', ['CustomCourseForEdX'])
|
||||
|
||||
# Adding model 'CcxMembership'
|
||||
db.create_table('ccx_ccxmembership', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('ccx', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['ccx.CustomCourseForEdX'])),
|
||||
('student', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
|
||||
('active', self.gf('django.db.models.fields.BooleanField')(default=False)),
|
||||
))
|
||||
db.send_create_signal('ccx', ['CcxMembership'])
|
||||
|
||||
# Adding model 'CcxFutureMembership'
|
||||
db.create_table('ccx_ccxfuturemembership', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('ccx', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['ccx.CustomCourseForEdX'])),
|
||||
('email', self.gf('django.db.models.fields.CharField')(max_length=255)),
|
||||
('auto_enroll', self.gf('django.db.models.fields.BooleanField')(default=False)),
|
||||
))
|
||||
db.send_create_signal('ccx', ['CcxFutureMembership'])
|
||||
|
||||
# Adding model 'CcxFieldOverride'
|
||||
db.create_table('ccx_ccxfieldoverride', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('ccx', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['ccx.CustomCourseForEdX'])),
|
||||
('location', self.gf('xmodule_django.models.LocationKeyField')(max_length=255, db_index=True)),
|
||||
('field', self.gf('django.db.models.fields.CharField')(max_length=255)),
|
||||
('value', self.gf('django.db.models.fields.TextField')(default='null')),
|
||||
))
|
||||
db.send_create_signal('ccx', ['CcxFieldOverride'])
|
||||
|
||||
# Adding unique constraint on 'CcxFieldOverride', fields ['ccx', 'location', 'field']
|
||||
db.create_unique('ccx_ccxfieldoverride', ['ccx_id', 'location', 'field'])
|
||||
|
||||
def backwards(self, orm):
|
||||
# Removing unique constraint on 'CcxFieldOverride', fields ['ccx', 'location', 'field']
|
||||
db.delete_unique('ccx_ccxfieldoverride', ['ccx_id', 'location', 'field'])
|
||||
|
||||
# Deleting model 'CustomCourseForEdX'
|
||||
db.delete_table('ccx_customcourseforedx')
|
||||
|
||||
# Deleting model 'CcxMembership'
|
||||
db.delete_table('ccx_ccxmembership')
|
||||
|
||||
# Deleting model 'CcxFutureMembership'
|
||||
db.delete_table('ccx_ccxfuturemembership')
|
||||
|
||||
# Deleting model 'CcxFieldOverride'
|
||||
db.delete_table('ccx_ccxfieldoverride')
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'ccx.ccxfieldoverride': {
|
||||
'Meta': {'unique_together': "(('ccx', 'location', 'field'),)", 'object_name': 'CcxFieldOverride'},
|
||||
'ccx': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ccx.CustomCourseForEdX']"}),
|
||||
'field': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'location': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
|
||||
},
|
||||
'ccx.ccxfuturemembership': {
|
||||
'Meta': {'object_name': 'CcxFutureMembership'},
|
||||
'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'ccx': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ccx.CustomCourseForEdX']"}),
|
||||
'email': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
|
||||
},
|
||||
'ccx.ccxmembership': {
|
||||
'Meta': {'object_name': 'CcxMembership'},
|
||||
'active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'ccx': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ccx.CustomCourseForEdX']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'ccx.customcourseforedx': {
|
||||
'Meta': {'object_name': 'CustomCourseForEdX'},
|
||||
'coach': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['ccx']
|
||||
0
lms/djangoapps/ccx/migrations/__init__.py
Normal file
0
lms/djangoapps/ccx/migrations/__init__.py
Normal file
77
lms/djangoapps/ccx/models.py
Normal file
77
lms/djangoapps/ccx/models.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
Models for the custom course feature
|
||||
"""
|
||||
from django.contrib.auth.models import User
|
||||
from django.db import models
|
||||
|
||||
from student.models import CourseEnrollment, AlreadyEnrolledError # pylint: disable=import-error
|
||||
from xmodule_django.models import CourseKeyField, LocationKeyField # pylint: disable=import-error
|
||||
|
||||
|
||||
class CustomCourseForEdX(models.Model):
|
||||
"""
|
||||
A Custom Course.
|
||||
"""
|
||||
course_id = CourseKeyField(max_length=255, db_index=True)
|
||||
display_name = models.CharField(max_length=255)
|
||||
coach = models.ForeignKey(User, db_index=True)
|
||||
|
||||
|
||||
class CcxMembership(models.Model):
|
||||
"""
|
||||
Which students are in a CCX?
|
||||
"""
|
||||
ccx = models.ForeignKey(CustomCourseForEdX, db_index=True)
|
||||
student = models.ForeignKey(User, db_index=True)
|
||||
active = models.BooleanField(default=False)
|
||||
|
||||
@classmethod
|
||||
def auto_enroll(cls, student, future_membership):
|
||||
"""convert future_membership to an active membership
|
||||
"""
|
||||
if not future_membership.auto_enroll:
|
||||
msg = "auto enrollment not allowed for {}"
|
||||
raise ValueError(msg.format(future_membership))
|
||||
membership = cls(
|
||||
ccx=future_membership.ccx, student=student, active=True
|
||||
)
|
||||
try:
|
||||
CourseEnrollment.enroll(
|
||||
student, future_membership.ccx.course_id, check_access=True
|
||||
)
|
||||
except AlreadyEnrolledError:
|
||||
# if the user is already enrolled in the course, great!
|
||||
pass
|
||||
|
||||
membership.save()
|
||||
future_membership.delete()
|
||||
|
||||
@classmethod
|
||||
def memberships_for_user(cls, user, active=True):
|
||||
"""
|
||||
active memberships for a user
|
||||
"""
|
||||
return cls.objects.filter(student=user, active__exact=active)
|
||||
|
||||
|
||||
class CcxFutureMembership(models.Model):
|
||||
"""
|
||||
Which emails for non-users are waiting to be added to CCX on registration
|
||||
"""
|
||||
ccx = models.ForeignKey(CustomCourseForEdX, db_index=True)
|
||||
email = models.CharField(max_length=255)
|
||||
auto_enroll = models.BooleanField(default=0)
|
||||
|
||||
|
||||
class CcxFieldOverride(models.Model):
|
||||
"""
|
||||
Field overrides for custom courses.
|
||||
"""
|
||||
ccx = models.ForeignKey(CustomCourseForEdX, db_index=True)
|
||||
location = LocationKeyField(max_length=255, db_index=True)
|
||||
field = models.CharField(max_length=255)
|
||||
|
||||
class Meta: # pylint: disable=missing-docstring,old-style-class
|
||||
unique_together = (('ccx', 'location', 'field'),)
|
||||
|
||||
value = models.TextField(default='null')
|
||||
184
lms/djangoapps/ccx/overrides.py
Normal file
184
lms/djangoapps/ccx/overrides.py
Normal file
@@ -0,0 +1,184 @@
|
||||
"""
|
||||
API related to providing field overrides for individual students. This is used
|
||||
by the individual custom courses feature.
|
||||
"""
|
||||
import json
|
||||
import threading
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from django.db import transaction, IntegrityError
|
||||
|
||||
from courseware.field_overrides import FieldOverrideProvider # pylint: disable=import-error
|
||||
from ccx import ACTIVE_CCX_KEY # pylint: disable=import-error
|
||||
|
||||
from .models import CcxMembership, CcxFieldOverride
|
||||
|
||||
|
||||
class CustomCoursesForEdxOverrideProvider(FieldOverrideProvider):
|
||||
"""
|
||||
A concrete implementation of
|
||||
:class:`~courseware.field_overrides.FieldOverrideProvider` which allows for
|
||||
overrides to be made on a per user basis.
|
||||
"""
|
||||
def get(self, block, name, default):
|
||||
"""
|
||||
Just call the get_override_for_ccx method if there is a ccx
|
||||
"""
|
||||
ccx = get_current_ccx()
|
||||
if ccx:
|
||||
return get_override_for_ccx(ccx, block, name, default)
|
||||
return default
|
||||
|
||||
|
||||
class _CcxContext(threading.local):
|
||||
"""
|
||||
A threading local used to implement the `with_ccx` context manager, that
|
||||
keeps track of the CCX currently set as the context.
|
||||
"""
|
||||
ccx = None
|
||||
request = None
|
||||
|
||||
|
||||
_CCX_CONTEXT = _CcxContext()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def ccx_context(ccx):
|
||||
"""
|
||||
A context manager which can be used to explicitly set the CCX that is in
|
||||
play for field overrides. This mechanism overrides the standard mechanism
|
||||
of looking in the user's session to see if they are enrolled in a CCX and
|
||||
viewing that CCX.
|
||||
"""
|
||||
prev = _CCX_CONTEXT.ccx
|
||||
_CCX_CONTEXT.ccx = ccx
|
||||
yield
|
||||
_CCX_CONTEXT.ccx = prev
|
||||
|
||||
|
||||
def get_current_ccx():
|
||||
"""
|
||||
Return the ccx that is active for this request.
|
||||
"""
|
||||
return _CCX_CONTEXT.ccx
|
||||
|
||||
|
||||
def get_current_request():
|
||||
"""
|
||||
Return the active request, so that we can get context information in places
|
||||
where it is limited, like in the tabs.
|
||||
"""
|
||||
request = _CCX_CONTEXT.request
|
||||
return request
|
||||
|
||||
|
||||
def get_override_for_ccx(ccx, block, name, default=None):
|
||||
"""
|
||||
Gets the value of the overridden field for the `ccx`. `block` and `name`
|
||||
specify the block and the name of the field. If the field is not
|
||||
overridden for the given ccx, returns `default`.
|
||||
"""
|
||||
if not hasattr(block, '_ccx_overrides'):
|
||||
block._ccx_overrides = {} # pylint: disable=protected-access
|
||||
overrides = block._ccx_overrides.get(ccx.id) # pylint: disable=protected-access
|
||||
if overrides is None:
|
||||
overrides = _get_overrides_for_ccx(ccx, block)
|
||||
block._ccx_overrides[ccx.id] = overrides # pylint: disable=protected-access
|
||||
return overrides.get(name, default)
|
||||
|
||||
|
||||
def _get_overrides_for_ccx(ccx, block):
|
||||
"""
|
||||
Returns a dictionary mapping field name to overriden value for any
|
||||
overrides set on this block for this CCX.
|
||||
"""
|
||||
overrides = {}
|
||||
query = CcxFieldOverride.objects.filter(
|
||||
ccx=ccx,
|
||||
location=block.location
|
||||
)
|
||||
for override in query:
|
||||
field = block.fields[override.field]
|
||||
value = field.from_json(json.loads(override.value))
|
||||
overrides[override.field] = value
|
||||
return overrides
|
||||
|
||||
|
||||
@transaction.commit_on_success
|
||||
def override_field_for_ccx(ccx, block, name, value):
|
||||
"""
|
||||
Overrides a field for the `ccx`. `block` and `name` specify the block
|
||||
and the name of the field on that block to override. `value` is the
|
||||
value to set for the given field.
|
||||
"""
|
||||
field = block.fields[name]
|
||||
value = json.dumps(field.to_json(value))
|
||||
try:
|
||||
override = CcxFieldOverride.objects.create(
|
||||
ccx=ccx,
|
||||
location=block.location,
|
||||
field=name,
|
||||
value=value)
|
||||
except IntegrityError:
|
||||
transaction.commit()
|
||||
override = CcxFieldOverride.objects.get(
|
||||
ccx=ccx,
|
||||
location=block.location,
|
||||
field=name)
|
||||
override.value = value
|
||||
override.save()
|
||||
if hasattr(block, '_ccx_overrides'):
|
||||
del block._ccx_overrides[ccx.id] # pylint: disable=protected-access
|
||||
|
||||
|
||||
def clear_override_for_ccx(ccx, block, name):
|
||||
"""
|
||||
Clears a previously set field override for the `ccx`. `block` and `name`
|
||||
specify the block and the name of the field on that block to clear.
|
||||
This function is idempotent--if no override is set, nothing action is
|
||||
performed.
|
||||
"""
|
||||
try:
|
||||
CcxFieldOverride.objects.get(
|
||||
ccx=ccx,
|
||||
location=block.location,
|
||||
field=name).delete()
|
||||
|
||||
if hasattr(block, '_ccx_overrides'):
|
||||
del block._ccx_overrides[ccx.id] # pylint: disable=protected-access
|
||||
|
||||
except CcxFieldOverride.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class CcxMiddleware(object):
|
||||
"""
|
||||
Checks to see if current session is examining a CCX and sets the CCX as
|
||||
the current CCX for the override machinery if so.
|
||||
"""
|
||||
def process_request(self, request):
|
||||
"""
|
||||
Do the check.
|
||||
"""
|
||||
ccx_id = request.session.get(ACTIVE_CCX_KEY, None)
|
||||
if ccx_id is not None:
|
||||
try:
|
||||
membership = CcxMembership.objects.get(
|
||||
student=request.user, active=True, ccx__id__exact=ccx_id
|
||||
)
|
||||
_CCX_CONTEXT.ccx = membership.ccx
|
||||
except CcxMembership.DoesNotExist:
|
||||
# if there is no membership, be sure to unset the active ccx
|
||||
_CCX_CONTEXT.ccx = None
|
||||
request.session.pop(ACTIVE_CCX_KEY)
|
||||
|
||||
_CCX_CONTEXT.request = request
|
||||
|
||||
def process_response(self, request, response): # pylint: disable=unused-argument
|
||||
"""
|
||||
Clean up afterwards.
|
||||
"""
|
||||
_CCX_CONTEXT.ccx = None
|
||||
_CCX_CONTEXT.request = None
|
||||
return response
|
||||
0
lms/djangoapps/ccx/tests/__init__.py
Normal file
0
lms/djangoapps/ccx/tests/__init__.py
Normal file
22
lms/djangoapps/ccx/tests/factories.py
Normal file
22
lms/djangoapps/ccx/tests/factories.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
Dummy factories for tests
|
||||
"""
|
||||
from factory.django import DjangoModelFactory
|
||||
from ccx.models import CustomCourseForEdX # pylint: disable=import-error
|
||||
from ccx.models import CcxMembership # pylint: disable=import-error
|
||||
from ccx.models import CcxFutureMembership # pylint: disable=import-error
|
||||
|
||||
|
||||
class CcxFactory(DjangoModelFactory): # pylint: disable=missing-docstring
|
||||
FACTORY_FOR = CustomCourseForEdX
|
||||
display_name = "Test CCX"
|
||||
id = None # pylint: disable=redefined-builtin, invalid-name
|
||||
|
||||
|
||||
class CcxMembershipFactory(DjangoModelFactory): # pylint: disable=missing-docstring
|
||||
FACTORY_FOR = CcxMembership
|
||||
active = False
|
||||
|
||||
|
||||
class CcxFutureMembershipFactory(DjangoModelFactory): # pylint: disable=missing-docstring
|
||||
FACTORY_FOR = CcxFutureMembership
|
||||
127
lms/djangoapps/ccx/tests/test_models.py
Normal file
127
lms/djangoapps/ccx/tests/test_models.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
tests for the models
|
||||
"""
|
||||
from student.models import CourseEnrollment # pylint: disable=import-error
|
||||
from student.roles import CourseCcxCoachRole # pylint: disable=import-error
|
||||
from student.tests.factories import ( # pylint: disable=import-error
|
||||
AdminFactory,
|
||||
CourseEnrollmentFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from .factories import (
|
||||
CcxFactory,
|
||||
CcxFutureMembershipFactory,
|
||||
)
|
||||
from ..models import (
|
||||
CcxMembership,
|
||||
CcxFutureMembership,
|
||||
)
|
||||
|
||||
|
||||
class TestCcxMembership(ModuleStoreTestCase):
|
||||
"""Unit tests for the CcxMembership model
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""common setup for all tests"""
|
||||
super(TestCcxMembership, self).setUp()
|
||||
self.course = course = CourseFactory.create()
|
||||
coach = AdminFactory.create()
|
||||
role = CourseCcxCoachRole(course.id)
|
||||
role.add_users(coach)
|
||||
self.ccx = CcxFactory(course_id=course.id, coach=coach)
|
||||
enrollment = CourseEnrollmentFactory.create(course_id=course.id)
|
||||
self.enrolled_user = enrollment.user
|
||||
self.unenrolled_user = UserFactory.create()
|
||||
|
||||
def create_future_enrollment(self, user, auto_enroll=True):
|
||||
"""
|
||||
utility method to create future enrollment
|
||||
"""
|
||||
pfm = CcxFutureMembershipFactory.create(
|
||||
ccx=self.ccx,
|
||||
email=user.email,
|
||||
auto_enroll=auto_enroll
|
||||
)
|
||||
return pfm
|
||||
|
||||
def has_course_enrollment(self, user):
|
||||
"""
|
||||
utility method to create future enrollment
|
||||
"""
|
||||
enrollment = CourseEnrollment.objects.filter(
|
||||
user=user, course_id=self.course.id
|
||||
)
|
||||
return enrollment.exists()
|
||||
|
||||
def has_ccx_membership(self, user):
|
||||
"""
|
||||
verify ccx membership
|
||||
"""
|
||||
membership = CcxMembership.objects.filter(
|
||||
student=user, ccx=self.ccx, active=True
|
||||
)
|
||||
return membership.exists()
|
||||
|
||||
def has_ccx_future_membership(self, user):
|
||||
"""
|
||||
verify future ccx membership
|
||||
"""
|
||||
future_membership = CcxFutureMembership.objects.filter(
|
||||
email=user.email, ccx=self.ccx
|
||||
)
|
||||
return future_membership.exists()
|
||||
|
||||
def call_mut(self, student, future_membership):
|
||||
"""
|
||||
Call the method undser test
|
||||
"""
|
||||
CcxMembership.auto_enroll(student, future_membership)
|
||||
|
||||
def test_ccx_auto_enroll_unregistered_user(self):
|
||||
"""verify auto_enroll works when user is not enrolled in the MOOC
|
||||
|
||||
n.b. After auto_enroll, user will have both a MOOC enrollment and a
|
||||
CCX membership
|
||||
"""
|
||||
user = self.unenrolled_user
|
||||
pfm = self.create_future_enrollment(user)
|
||||
self.assertTrue(self.has_ccx_future_membership(user))
|
||||
self.assertFalse(self.has_course_enrollment(user))
|
||||
# auto_enroll user
|
||||
self.call_mut(user, pfm)
|
||||
|
||||
self.assertTrue(self.has_course_enrollment(user))
|
||||
self.assertTrue(self.has_ccx_membership(user))
|
||||
self.assertFalse(self.has_ccx_future_membership(user))
|
||||
|
||||
def test_ccx_auto_enroll_registered_user(self):
|
||||
"""verify auto_enroll works when user is enrolled in the MOOC
|
||||
"""
|
||||
user = self.enrolled_user
|
||||
pfm = self.create_future_enrollment(user)
|
||||
self.assertTrue(self.has_ccx_future_membership(user))
|
||||
self.assertTrue(self.has_course_enrollment(user))
|
||||
|
||||
self.call_mut(user, pfm)
|
||||
|
||||
self.assertTrue(self.has_course_enrollment(user))
|
||||
self.assertTrue(self.has_ccx_membership(user))
|
||||
self.assertFalse(self.has_ccx_future_membership(user))
|
||||
|
||||
def test_future_membership_disallows_auto_enroll(self):
|
||||
"""verify that the CcxFutureMembership can veto auto_enroll
|
||||
"""
|
||||
user = self.unenrolled_user
|
||||
pfm = self.create_future_enrollment(user, auto_enroll=False)
|
||||
self.assertTrue(self.has_ccx_future_membership(user))
|
||||
self.assertFalse(self.has_course_enrollment(user))
|
||||
|
||||
self.assertRaises(ValueError, self.call_mut, user, pfm)
|
||||
|
||||
self.assertFalse(self.has_course_enrollment(user))
|
||||
self.assertFalse(self.has_ccx_membership(user))
|
||||
self.assertTrue(self.has_ccx_future_membership(user))
|
||||
129
lms/djangoapps/ccx/tests/test_overrides.py
Normal file
129
lms/djangoapps/ccx/tests/test_overrides.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
tests for overrides
|
||||
"""
|
||||
import datetime
|
||||
import mock
|
||||
import pytz
|
||||
|
||||
from courseware.field_overrides import OverrideFieldData # pylint: disable=import-error
|
||||
from django.test.utils import override_settings
|
||||
from student.tests.factories import AdminFactory # pylint: disable=import-error
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
|
||||
from ..models import CustomCourseForEdX
|
||||
from ..overrides import override_field_for_ccx
|
||||
|
||||
from .test_views import flatten, iter_blocks
|
||||
|
||||
|
||||
@override_settings(FIELD_OVERRIDE_PROVIDERS=(
|
||||
'ccx.overrides.CustomCoursesForEdxOverrideProvider',))
|
||||
class TestFieldOverrides(ModuleStoreTestCase):
|
||||
"""
|
||||
Make sure field overrides behave in the expected manner.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Set up tests
|
||||
"""
|
||||
super(TestFieldOverrides, self).setUp()
|
||||
self.course = course = CourseFactory.create()
|
||||
|
||||
# Create a course outline
|
||||
self.mooc_start = start = datetime.datetime(
|
||||
2010, 5, 12, 2, 42, tzinfo=pytz.UTC)
|
||||
self.mooc_due = due = datetime.datetime(
|
||||
2010, 7, 7, 0, 0, tzinfo=pytz.UTC)
|
||||
chapters = [ItemFactory.create(start=start, parent=course)
|
||||
for _ in xrange(2)]
|
||||
sequentials = flatten([
|
||||
[ItemFactory.create(parent=chapter) for _ in xrange(2)]
|
||||
for chapter in chapters])
|
||||
verticals = flatten([
|
||||
[ItemFactory.create(due=due, parent=sequential) for _ in xrange(2)]
|
||||
for sequential in sequentials])
|
||||
blocks = flatten([ # pylint: disable=unused-variable
|
||||
[ItemFactory.create(parent=vertical) for _ in xrange(2)]
|
||||
for vertical in verticals])
|
||||
|
||||
self.ccx = ccx = CustomCourseForEdX(
|
||||
course_id=course.id,
|
||||
display_name='Test CCX',
|
||||
coach=AdminFactory.create())
|
||||
ccx.save()
|
||||
|
||||
patch = mock.patch('ccx.overrides.get_current_ccx')
|
||||
self.get_ccx = get_ccx = patch.start()
|
||||
get_ccx.return_value = ccx
|
||||
self.addCleanup(patch.stop)
|
||||
|
||||
# Apparently the test harness doesn't use LmsFieldStorage, and I'm not
|
||||
# sure if there's a way to poke the test harness to do so. So, we'll
|
||||
# just inject the override field storage in this brute force manner.
|
||||
OverrideFieldData.provider_classes = None
|
||||
for block in iter_blocks(course):
|
||||
block._field_data = OverrideFieldData.wrap( # pylint: disable=protected-access
|
||||
AdminFactory.create(), block._field_data) # pylint: disable=protected-access
|
||||
|
||||
def cleanup_provider_classes():
|
||||
"""
|
||||
After everything is done, clean up by un-doing the change to the
|
||||
OverrideFieldData object that is done during the wrap method.
|
||||
"""
|
||||
OverrideFieldData.provider_classes = None
|
||||
self.addCleanup(cleanup_provider_classes)
|
||||
|
||||
def test_override_start(self):
|
||||
"""
|
||||
Test that overriding start date on a chapter works.
|
||||
"""
|
||||
ccx_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC)
|
||||
chapter = self.course.get_children()[0]
|
||||
override_field_for_ccx(self.ccx, chapter, 'start', ccx_start)
|
||||
self.assertEquals(chapter.start, ccx_start)
|
||||
|
||||
def test_override_num_queries(self):
|
||||
"""
|
||||
Test that overriding and accessing a field produce same number of queries.
|
||||
"""
|
||||
ccx_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC)
|
||||
chapter = self.course.get_children()[0]
|
||||
with self.assertNumQueries(4):
|
||||
override_field_for_ccx(self.ccx, chapter, 'start', ccx_start)
|
||||
dummy = chapter.start
|
||||
|
||||
def test_overriden_field_access_produces_no_extra_queries(self):
|
||||
"""
|
||||
Test no extra queries when accessing an overriden field more than once.
|
||||
"""
|
||||
ccx_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC)
|
||||
chapter = self.course.get_children()[0]
|
||||
with self.assertNumQueries(4):
|
||||
override_field_for_ccx(self.ccx, chapter, 'start', ccx_start)
|
||||
dummy1 = chapter.start
|
||||
dummy2 = chapter.start
|
||||
dummy3 = chapter.start
|
||||
|
||||
def test_override_is_inherited(self):
|
||||
"""
|
||||
Test that sequentials inherit overridden start date from chapter.
|
||||
"""
|
||||
ccx_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC)
|
||||
chapter = self.course.get_children()[0]
|
||||
override_field_for_ccx(self.ccx, chapter, 'start', ccx_start)
|
||||
self.assertEquals(chapter.get_children()[0].start, ccx_start)
|
||||
self.assertEquals(chapter.get_children()[1].start, ccx_start)
|
||||
|
||||
def test_override_is_inherited_even_if_set_in_mooc(self):
|
||||
"""
|
||||
Test that a due date set on a chapter is inherited by grandchildren
|
||||
(verticals) even if a due date is set explicitly on grandchildren in
|
||||
the mooc.
|
||||
"""
|
||||
ccx_due = datetime.datetime(2015, 1, 1, 00, 00, tzinfo=pytz.UTC)
|
||||
chapter = self.course.get_children()[0]
|
||||
chapter.display_name = 'itsme!'
|
||||
override_field_for_ccx(self.ccx, chapter, 'due', ccx_due)
|
||||
vertical = chapter.get_children()[0].get_children()[0]
|
||||
self.assertEqual(vertical.due, ccx_due)
|
||||
570
lms/djangoapps/ccx/tests/test_utils.py
Normal file
570
lms/djangoapps/ccx/tests/test_utils.py
Normal file
@@ -0,0 +1,570 @@
|
||||
"""
|
||||
test utils
|
||||
"""
|
||||
from ccx.models import ( # pylint: disable=import-error
|
||||
CcxMembership,
|
||||
CcxFutureMembership,
|
||||
)
|
||||
from ccx.tests.factories import ( # pylint: disable=import-error
|
||||
CcxFactory,
|
||||
CcxMembershipFactory,
|
||||
CcxFutureMembershipFactory,
|
||||
)
|
||||
from student.roles import CourseCcxCoachRole # pylint: disable=import-error
|
||||
from student.tests.factories import ( # pylint: disable=import-error
|
||||
AdminFactory,
|
||||
UserFactory,
|
||||
CourseEnrollmentFactory,
|
||||
AnonymousUserFactory,
|
||||
)
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
|
||||
class TestEmailEnrollmentState(ModuleStoreTestCase):
|
||||
"""unit tests for the EmailEnrollmentState class
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Set up tests
|
||||
"""
|
||||
super(TestEmailEnrollmentState, self).setUp()
|
||||
# remove user provided by the parent test case so we can make our own
|
||||
# when needed.
|
||||
self.user = None
|
||||
course = CourseFactory.create()
|
||||
coach = AdminFactory.create()
|
||||
role = CourseCcxCoachRole(course.id)
|
||||
role.add_users(coach)
|
||||
self.ccx = CcxFactory(course_id=course.id, coach=coach)
|
||||
|
||||
def create_user(self):
|
||||
"""provide a legitimate django user for testing
|
||||
"""
|
||||
if getattr(self, 'user', None) is None:
|
||||
self.user = UserFactory()
|
||||
|
||||
def register_user_in_ccx(self):
|
||||
"""create registration of self.user in self.ccx
|
||||
|
||||
registration will be inactive
|
||||
"""
|
||||
self.create_user()
|
||||
CcxMembershipFactory(ccx=self.ccx, student=self.user)
|
||||
|
||||
def create_one(self, email=None):
|
||||
"""Create a single EmailEnrollmentState object and return it
|
||||
"""
|
||||
from ccx.utils import EmailEnrollmentState # pylint: disable=import-error
|
||||
if email is None:
|
||||
email = self.user.email
|
||||
return EmailEnrollmentState(self.ccx, email)
|
||||
|
||||
def test_enrollment_state_for_non_user(self):
|
||||
"""verify behavior for non-user email address
|
||||
"""
|
||||
ee_state = self.create_one(email='nobody@nowhere.com')
|
||||
for attr in ['user', 'member', 'full_name', 'in_ccx']:
|
||||
value = getattr(ee_state, attr, 'missing attribute')
|
||||
self.assertFalse(value, "{}: {}".format(value, attr))
|
||||
|
||||
def test_enrollment_state_for_non_member_user(self):
|
||||
"""verify behavior for email address of user who is not a ccx memeber
|
||||
"""
|
||||
self.create_user()
|
||||
ee_state = self.create_one()
|
||||
self.assertTrue(ee_state.user)
|
||||
self.assertFalse(ee_state.in_ccx)
|
||||
self.assertEqual(ee_state.member, self.user)
|
||||
self.assertEqual(ee_state.full_name, self.user.profile.name)
|
||||
|
||||
def test_enrollment_state_for_member_user(self):
|
||||
"""verify behavior for email address of user who is a ccx member
|
||||
"""
|
||||
self.create_user()
|
||||
self.register_user_in_ccx()
|
||||
ee_state = self.create_one()
|
||||
for attr in ['user', 'in_ccx']:
|
||||
self.assertTrue(
|
||||
getattr(ee_state, attr, False),
|
||||
"attribute {} is missing or False".format(attr)
|
||||
)
|
||||
self.assertEqual(ee_state.member, self.user)
|
||||
self.assertEqual(ee_state.full_name, self.user.profile.name)
|
||||
|
||||
def test_enrollment_state_to_dict(self):
|
||||
"""verify dict representation of EmailEnrollmentState
|
||||
"""
|
||||
self.create_user()
|
||||
self.register_user_in_ccx()
|
||||
ee_state = self.create_one()
|
||||
ee_dict = ee_state.to_dict()
|
||||
expected = {
|
||||
'user': True,
|
||||
'member': self.user,
|
||||
'in_ccx': True,
|
||||
}
|
||||
for expected_key, expected_value in expected.iteritems():
|
||||
self.assertTrue(expected_key in ee_dict)
|
||||
self.assertEqual(expected_value, ee_dict[expected_key])
|
||||
|
||||
def test_enrollment_state_repr(self):
|
||||
self.create_user()
|
||||
self.register_user_in_ccx()
|
||||
ee_state = self.create_one()
|
||||
representation = repr(ee_state)
|
||||
self.assertTrue('user=True' in representation)
|
||||
self.assertTrue('in_ccx=True' in representation)
|
||||
member = 'member={}'.format(self.user)
|
||||
self.assertTrue(member in representation)
|
||||
|
||||
|
||||
# TODO: deal with changes in behavior for auto_enroll
|
||||
class TestGetEmailParams(ModuleStoreTestCase):
|
||||
"""tests for ccx.utils.get_email_params
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Set up tests
|
||||
"""
|
||||
super(TestGetEmailParams, self).setUp()
|
||||
course = CourseFactory.create()
|
||||
coach = AdminFactory.create()
|
||||
role = CourseCcxCoachRole(course.id)
|
||||
role.add_users(coach)
|
||||
self.ccx = CcxFactory(course_id=course.id, coach=coach)
|
||||
self.all_keys = [
|
||||
'site_name', 'course', 'course_url', 'registration_url',
|
||||
'course_about_url', 'auto_enroll'
|
||||
]
|
||||
self.url_keys = [k for k in self.all_keys if 'url' in k]
|
||||
self.course_keys = [k for k in self.url_keys if 'course' in k]
|
||||
|
||||
def call_fut(self, auto_enroll=False, secure=False):
|
||||
"""
|
||||
call function under test
|
||||
"""
|
||||
from ccx.utils import get_email_params # pylint: disable=import-error
|
||||
return get_email_params(self.ccx, auto_enroll, secure)
|
||||
|
||||
def test_params_have_expected_keys(self):
|
||||
params = self.call_fut()
|
||||
self.assertFalse(set(params.keys()) - set(self.all_keys))
|
||||
|
||||
def test_ccx_id_in_params(self):
|
||||
expected_course_id = self.ccx.course_id.to_deprecated_string()
|
||||
params = self.call_fut()
|
||||
self.assertEqual(params['course'], self.ccx)
|
||||
for url_key in self.url_keys:
|
||||
self.assertTrue('http://' in params[url_key])
|
||||
for url_key in self.course_keys:
|
||||
self.assertTrue(expected_course_id in params[url_key])
|
||||
|
||||
def test_security_respected(self):
|
||||
secure = self.call_fut(secure=True)
|
||||
for url_key in self.url_keys:
|
||||
self.assertTrue('https://' in secure[url_key])
|
||||
insecure = self.call_fut(secure=False)
|
||||
for url_key in self.url_keys:
|
||||
self.assertTrue('http://' in insecure[url_key])
|
||||
|
||||
def test_auto_enroll_passed_correctly(self):
|
||||
not_auto = self.call_fut(auto_enroll=False)
|
||||
self.assertFalse(not_auto['auto_enroll'])
|
||||
auto = self.call_fut(auto_enroll=True)
|
||||
self.assertTrue(auto['auto_enroll'])
|
||||
|
||||
|
||||
# TODO: deal with changes in behavior for auto_enroll
|
||||
class TestEnrollEmail(ModuleStoreTestCase):
|
||||
"""tests for the enroll_email function from ccx.utils
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestEnrollEmail, self).setUp()
|
||||
# unbind the user created by the parent, so we can create our own when
|
||||
# needed.
|
||||
self.user = None
|
||||
course = CourseFactory.create()
|
||||
coach = AdminFactory.create()
|
||||
role = CourseCcxCoachRole(course.id)
|
||||
role.add_users(coach)
|
||||
self.ccx = CcxFactory(course_id=course.id, coach=coach)
|
||||
self.outbox = self.get_outbox()
|
||||
|
||||
def create_user(self):
|
||||
"""provide a legitimate django user for testing
|
||||
"""
|
||||
if getattr(self, 'user', None) is None:
|
||||
self.user = UserFactory()
|
||||
|
||||
def register_user_in_ccx(self):
|
||||
"""create registration of self.user in self.ccx
|
||||
|
||||
registration will be inactive
|
||||
"""
|
||||
self.create_user()
|
||||
CcxMembershipFactory(ccx=self.ccx, student=self.user)
|
||||
|
||||
def get_outbox(self):
|
||||
"""Return the django mail outbox"""
|
||||
from django.core import mail
|
||||
return mail.outbox
|
||||
|
||||
def check_membership(self, email=None, user=None, future=False):
|
||||
"""Verify tjat an appropriate CCX Membership exists"""
|
||||
if not email and not user:
|
||||
self.fail(
|
||||
"must provide user or email address to check CCX Membership"
|
||||
)
|
||||
if future and email:
|
||||
membership = CcxFutureMembership.objects.filter(
|
||||
ccx=self.ccx, email=email
|
||||
)
|
||||
elif not future:
|
||||
if not user:
|
||||
user = self.user
|
||||
membership = CcxMembership.objects.filter(
|
||||
ccx=self.ccx, student=user
|
||||
)
|
||||
self.assertTrue(membership.exists())
|
||||
|
||||
def check_enrollment_state(self, state, in_ccx, member, user):
|
||||
"""Verify an enrollment state object against provided arguments
|
||||
|
||||
state.in_ccx will always be a boolean
|
||||
state.user will always be a boolean
|
||||
state.member will be a Django user object or None
|
||||
"""
|
||||
self.assertEqual(in_ccx, state.in_ccx)
|
||||
self.assertEqual(member, state.member)
|
||||
self.assertEqual(user, state.user)
|
||||
|
||||
def call_fut(
|
||||
self,
|
||||
student_email=None,
|
||||
auto_enroll=False,
|
||||
email_students=False,
|
||||
email_params=None
|
||||
):
|
||||
"""Call function under test"""
|
||||
from ccx.utils import enroll_email # pylint: disable=import-error
|
||||
if student_email is None:
|
||||
student_email = self.user.email
|
||||
before, after = enroll_email(
|
||||
self.ccx, student_email, auto_enroll, email_students, email_params
|
||||
)
|
||||
return before, after
|
||||
|
||||
def test_enroll_non_user_sending_email(self):
|
||||
"""enroll a non-user email and send an enrollment email to them
|
||||
"""
|
||||
# ensure no emails are in the outbox now
|
||||
self.assertEqual(self.outbox, [])
|
||||
test_email = "nobody@nowhere.com"
|
||||
before, after = self.call_fut(
|
||||
student_email=test_email, email_students=True
|
||||
)
|
||||
|
||||
# there should be a future membership set for this email address now
|
||||
self.check_membership(email=test_email, future=True)
|
||||
for state in [before, after]:
|
||||
self.check_enrollment_state(state, False, None, False)
|
||||
# mail was sent and to the right person
|
||||
self.assertEqual(len(self.outbox), 1)
|
||||
msg = self.outbox[0]
|
||||
self.assertTrue(test_email in msg.recipients())
|
||||
|
||||
def test_enroll_non_member_sending_email(self):
|
||||
"""register a non-member and send an enrollment email to them
|
||||
"""
|
||||
self.create_user()
|
||||
# ensure no emails are in the outbox now
|
||||
self.assertEqual(self.outbox, [])
|
||||
before, after = self.call_fut(email_students=True)
|
||||
|
||||
# there should be a membership set for this email address now
|
||||
self.check_membership(email=self.user.email)
|
||||
self.check_enrollment_state(before, False, self.user, True)
|
||||
self.check_enrollment_state(after, True, self.user, True)
|
||||
# mail was sent and to the right person
|
||||
self.assertEqual(len(self.outbox), 1)
|
||||
msg = self.outbox[0]
|
||||
self.assertTrue(self.user.email in msg.recipients())
|
||||
|
||||
def test_enroll_member_sending_email(self):
|
||||
"""register a member and send an enrollment email to them
|
||||
"""
|
||||
self.register_user_in_ccx()
|
||||
# ensure no emails are in the outbox now
|
||||
self.assertEqual(self.outbox, [])
|
||||
before, after = self.call_fut(email_students=True)
|
||||
|
||||
# there should be a membership set for this email address now
|
||||
self.check_membership(email=self.user.email)
|
||||
for state in [before, after]:
|
||||
self.check_enrollment_state(state, True, self.user, True)
|
||||
# mail was sent and to the right person
|
||||
self.assertEqual(len(self.outbox), 1)
|
||||
msg = self.outbox[0]
|
||||
self.assertTrue(self.user.email in msg.recipients())
|
||||
|
||||
def test_enroll_non_user_no_email(self):
|
||||
"""register a non-user via email address but send no email
|
||||
"""
|
||||
# ensure no emails are in the outbox now
|
||||
self.assertEqual(self.outbox, [])
|
||||
test_email = "nobody@nowhere.com"
|
||||
before, after = self.call_fut(
|
||||
student_email=test_email, email_students=False
|
||||
)
|
||||
|
||||
# there should be a future membership set for this email address now
|
||||
self.check_membership(email=test_email, future=True)
|
||||
for state in [before, after]:
|
||||
self.check_enrollment_state(state, False, None, False)
|
||||
# ensure there are still no emails in the outbox now
|
||||
self.assertEqual(self.outbox, [])
|
||||
|
||||
def test_enroll_non_member_no_email(self):
|
||||
"""register a non-member but send no email"""
|
||||
self.create_user()
|
||||
# ensure no emails are in the outbox now
|
||||
self.assertEqual(self.outbox, [])
|
||||
before, after = self.call_fut(email_students=False)
|
||||
|
||||
# there should be a membership set for this email address now
|
||||
self.check_membership(email=self.user.email)
|
||||
self.check_enrollment_state(before, False, self.user, True)
|
||||
self.check_enrollment_state(after, True, self.user, True)
|
||||
# ensure there are still no emails in the outbox now
|
||||
self.assertEqual(self.outbox, [])
|
||||
|
||||
def test_enroll_member_no_email(self):
|
||||
"""enroll a member but send no email
|
||||
"""
|
||||
self.register_user_in_ccx()
|
||||
# ensure no emails are in the outbox now
|
||||
self.assertEqual(self.outbox, [])
|
||||
before, after = self.call_fut(email_students=False)
|
||||
|
||||
# there should be a membership set for this email address now
|
||||
self.check_membership(email=self.user.email)
|
||||
for state in [before, after]:
|
||||
self.check_enrollment_state(state, True, self.user, True)
|
||||
# ensure there are still no emails in the outbox now
|
||||
self.assertEqual(self.outbox, [])
|
||||
|
||||
|
||||
# TODO: deal with changes in behavior for auto_enroll
|
||||
class TestUnenrollEmail(ModuleStoreTestCase):
|
||||
"""Tests for the unenroll_email function from ccx.utils"""
|
||||
def setUp(self):
|
||||
super(TestUnenrollEmail, self).setUp()
|
||||
# unbind the user created by the parent, so we can create our own when
|
||||
# needed.
|
||||
self.user = None
|
||||
course = CourseFactory.create()
|
||||
coach = AdminFactory.create()
|
||||
role = CourseCcxCoachRole(course.id)
|
||||
role.add_users(coach)
|
||||
self.ccx = CcxFactory(course_id=course.id, coach=coach)
|
||||
self.outbox = self.get_outbox()
|
||||
self.email = "nobody@nowhere.com"
|
||||
|
||||
def get_outbox(self):
|
||||
"""Return the django mail outbox"""
|
||||
from django.core import mail
|
||||
return mail.outbox
|
||||
|
||||
def create_user(self):
|
||||
"""provide a legitimate django user for testing
|
||||
"""
|
||||
if getattr(self, 'user', None) is None:
|
||||
self.user = UserFactory()
|
||||
|
||||
def make_ccx_membership(self):
|
||||
"""create registration of self.user in self.ccx
|
||||
|
||||
registration will be inactive
|
||||
"""
|
||||
self.create_user()
|
||||
CcxMembershipFactory.create(ccx=self.ccx, student=self.user)
|
||||
|
||||
def make_ccx_future_membership(self):
|
||||
"""create future registration for email in self.ccx"""
|
||||
CcxFutureMembershipFactory.create(
|
||||
ccx=self.ccx, email=self.email
|
||||
)
|
||||
|
||||
def check_enrollment_state(self, state, in_ccx, member, user):
|
||||
"""Verify an enrollment state object against provided arguments
|
||||
|
||||
state.in_ccx will always be a boolean
|
||||
state.user will always be a boolean
|
||||
state.member will be a Django user object or None
|
||||
"""
|
||||
self.assertEqual(in_ccx, state.in_ccx)
|
||||
self.assertEqual(member, state.member)
|
||||
self.assertEqual(user, state.user)
|
||||
|
||||
def check_membership(self, future=False):
|
||||
"""
|
||||
check membership
|
||||
"""
|
||||
if future:
|
||||
membership = CcxFutureMembership.objects.filter(
|
||||
ccx=self.ccx, email=self.email
|
||||
)
|
||||
else:
|
||||
membership = CcxMembership.objects.filter(
|
||||
ccx=self.ccx, student=self.user
|
||||
)
|
||||
return membership.exists()
|
||||
|
||||
def call_fut(self, email_students=False):
|
||||
"""call function under test"""
|
||||
from ccx.utils import unenroll_email # pylint: disable=import-error
|
||||
email = getattr(self, 'user', None) and self.user.email or self.email
|
||||
return unenroll_email(self.ccx, email, email_students=email_students)
|
||||
|
||||
def test_unenroll_future_member_with_email(self):
|
||||
"""unenroll a future member and send an email
|
||||
"""
|
||||
self.make_ccx_future_membership()
|
||||
# assert that a membership exists and that no emails have been sent
|
||||
self.assertTrue(self.check_membership(future=True))
|
||||
self.assertEqual(self.outbox, [])
|
||||
# unenroll the student
|
||||
before, after = self.call_fut(email_students=True)
|
||||
|
||||
# assert that membership is now gone
|
||||
self.assertFalse(self.check_membership(future=True))
|
||||
# validate the before and after enrollment states
|
||||
for state in [before, after]:
|
||||
self.check_enrollment_state(state, False, None, False)
|
||||
# check that mail was sent and to the right person
|
||||
self.assertEqual(len(self.outbox), 1)
|
||||
msg = self.outbox[0]
|
||||
self.assertTrue(self.email in msg.recipients())
|
||||
|
||||
def test_unenroll_member_with_email(self):
|
||||
"""unenroll a current member and send an email"""
|
||||
self.make_ccx_membership()
|
||||
# assert that a membership exists and that no emails have been sent
|
||||
self.assertTrue(self.check_membership())
|
||||
self.assertEqual(self.outbox, [])
|
||||
# unenroll the student
|
||||
before, after = self.call_fut(email_students=True)
|
||||
|
||||
# assert that membership is now gone
|
||||
self.assertFalse(self.check_membership())
|
||||
# validate the before and after enrollment state
|
||||
self.check_enrollment_state(after, False, self.user, True)
|
||||
self.check_enrollment_state(before, True, self.user, True)
|
||||
# check that mail was sent and to the right person
|
||||
self.assertEqual(len(self.outbox), 1)
|
||||
msg = self.outbox[0]
|
||||
self.assertTrue(self.user.email in msg.recipients())
|
||||
|
||||
def test_unenroll_future_member_no_email(self):
|
||||
"""unenroll a future member but send no email
|
||||
"""
|
||||
self.make_ccx_future_membership()
|
||||
# assert that a membership exists and that no emails have been sent
|
||||
self.assertTrue(self.check_membership(future=True))
|
||||
self.assertEqual(self.outbox, [])
|
||||
# unenroll the student
|
||||
before, after = self.call_fut()
|
||||
|
||||
# assert that membership is now gone
|
||||
self.assertFalse(self.check_membership(future=True))
|
||||
# validate the before and after enrollment states
|
||||
for state in [before, after]:
|
||||
self.check_enrollment_state(state, False, None, False)
|
||||
# no email was sent to the student
|
||||
self.assertEqual(self.outbox, [])
|
||||
|
||||
def test_unenroll_member_no_email(self):
|
||||
"""unenroll a current member but send no email
|
||||
"""
|
||||
self.make_ccx_membership()
|
||||
# assert that a membership exists and that no emails have been sent
|
||||
self.assertTrue(self.check_membership())
|
||||
self.assertEqual(self.outbox, [])
|
||||
# unenroll the student
|
||||
before, after = self.call_fut()
|
||||
|
||||
# assert that membership is now gone
|
||||
self.assertFalse(self.check_membership())
|
||||
# validate the before and after enrollment state
|
||||
self.check_enrollment_state(after, False, self.user, True)
|
||||
self.check_enrollment_state(before, True, self.user, True)
|
||||
# no email was sent to the student
|
||||
self.assertEqual(self.outbox, [])
|
||||
|
||||
|
||||
class TestUserCCXList(ModuleStoreTestCase):
|
||||
"""Unit tests for ccx.utils.get_all_ccx_for_user"""
|
||||
|
||||
def setUp(self):
|
||||
"""Create required infrastructure for tests"""
|
||||
super(TestUserCCXList, self).setUp()
|
||||
self.course = CourseFactory.create()
|
||||
coach = AdminFactory.create()
|
||||
role = CourseCcxCoachRole(self.course.id)
|
||||
role.add_users(coach)
|
||||
self.ccx = CcxFactory(course_id=self.course.id, coach=coach)
|
||||
enrollment = CourseEnrollmentFactory.create(course_id=self.course.id)
|
||||
self.user = enrollment.user
|
||||
self.anonymous = AnonymousUserFactory.create()
|
||||
|
||||
def register_user_in_ccx(self, active=False):
|
||||
"""create registration of self.user in self.ccx
|
||||
|
||||
registration will be inactive unless active=True
|
||||
"""
|
||||
CcxMembershipFactory(ccx=self.ccx, student=self.user, active=active)
|
||||
|
||||
def get_course_title(self):
|
||||
"""Get course title"""
|
||||
from courseware.courses import get_course_about_section # pylint: disable=import-error
|
||||
return get_course_about_section(self.course, 'title')
|
||||
|
||||
def call_fut(self, user):
|
||||
"""Call function under test"""
|
||||
from ccx.utils import get_all_ccx_for_user # pylint: disable=import-error
|
||||
return get_all_ccx_for_user(user)
|
||||
|
||||
def test_anonymous_sees_no_ccx(self):
|
||||
memberships = self.call_fut(self.anonymous)
|
||||
self.assertEqual(memberships, [])
|
||||
|
||||
def test_unenrolled_sees_no_ccx(self):
|
||||
memberships = self.call_fut(self.user)
|
||||
self.assertEqual(memberships, [])
|
||||
|
||||
def test_enrolled_inactive_sees_no_ccx(self):
|
||||
self.register_user_in_ccx()
|
||||
memberships = self.call_fut(self.user)
|
||||
self.assertEqual(memberships, [])
|
||||
|
||||
def test_enrolled_sees_a_ccx(self):
|
||||
self.register_user_in_ccx(active=True)
|
||||
memberships = self.call_fut(self.user)
|
||||
self.assertEqual(len(memberships), 1)
|
||||
|
||||
def test_data_structure(self):
|
||||
self.register_user_in_ccx(active=True)
|
||||
memberships = self.call_fut(self.user)
|
||||
this_membership = memberships[0]
|
||||
self.assertTrue(this_membership)
|
||||
# structure contains the expected keys
|
||||
for key in ['ccx_name', 'ccx_url']:
|
||||
self.assertTrue(key in this_membership.keys())
|
||||
url_parts = [self.course.id.to_deprecated_string(), str(self.ccx.id)]
|
||||
# all parts of the ccx url are present
|
||||
for part in url_parts:
|
||||
self.assertTrue(part in this_membership['ccx_url'])
|
||||
actual_name = self.ccx.display_name
|
||||
self.assertEqual(actual_name, this_membership['ccx_name'])
|
||||
776
lms/djangoapps/ccx/tests/test_views.py
Normal file
776
lms/djangoapps/ccx/tests/test_views.py
Normal file
@@ -0,0 +1,776 @@
|
||||
"""
|
||||
test views
|
||||
"""
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
import pytz
|
||||
from mock import patch
|
||||
|
||||
from capa.tests.response_xml_factory import StringResponseXMLFactory
|
||||
from courseware.field_overrides import OverrideFieldData # pylint: disable=import-error
|
||||
from courseware.tests.factories import StudentModuleFactory # pylint: disable=import-error
|
||||
from courseware.tests.helpers import LoginEnrollmentTestCase # pylint: disable=import-error
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.test.utils import override_settings
|
||||
from edxmako.shortcuts import render_to_response # pylint: disable=import-error
|
||||
from student.roles import CourseCcxCoachRole # pylint: disable=import-error
|
||||
from student.tests.factories import ( # pylint: disable=import-error
|
||||
AdminFactory,
|
||||
CourseEnrollmentFactory,
|
||||
UserFactory,
|
||||
)
|
||||
|
||||
from xmodule.x_module import XModuleMixin
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import (
|
||||
CourseFactory,
|
||||
ItemFactory,
|
||||
)
|
||||
from ..models import (
|
||||
CustomCourseForEdX,
|
||||
CcxMembership,
|
||||
CcxFutureMembership,
|
||||
)
|
||||
from ..overrides import get_override_for_ccx, override_field_for_ccx
|
||||
from .. import ACTIVE_CCX_KEY
|
||||
from .factories import (
|
||||
CcxFactory,
|
||||
CcxMembershipFactory,
|
||||
CcxFutureMembershipFactory,
|
||||
)
|
||||
|
||||
|
||||
def intercept_renderer(path, context):
|
||||
"""
|
||||
Intercept calls to `render_to_response` and attach the context dict to the
|
||||
response for examination in unit tests.
|
||||
"""
|
||||
# I think Django already does this for you in their TestClient, except
|
||||
# we're bypassing that by using edxmako. Probably edxmako should be
|
||||
# integrated better with Django's rendering and event system.
|
||||
response = render_to_response(path, context)
|
||||
response.mako_context = context
|
||||
response.mako_template = path
|
||||
return response
|
||||
|
||||
|
||||
class TestCoachDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase):
|
||||
"""
|
||||
Tests for Custom Courses views.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Set up tests
|
||||
"""
|
||||
super(TestCoachDashboard, self).setUp()
|
||||
self.course = course = CourseFactory.create()
|
||||
|
||||
# Create instructor account
|
||||
self.coach = coach = AdminFactory.create()
|
||||
self.client.login(username=coach.username, password="test")
|
||||
|
||||
# Create a course outline
|
||||
self.mooc_start = start = datetime.datetime(
|
||||
2010, 5, 12, 2, 42, tzinfo=pytz.UTC)
|
||||
self.mooc_due = due = datetime.datetime(
|
||||
2010, 7, 7, 0, 0, tzinfo=pytz.UTC)
|
||||
chapters = [ItemFactory.create(start=start, parent=course)
|
||||
for _ in xrange(2)]
|
||||
sequentials = flatten([
|
||||
[
|
||||
ItemFactory.create(parent=chapter) for _ in xrange(2)
|
||||
] for chapter in chapters
|
||||
])
|
||||
verticals = flatten([
|
||||
[
|
||||
ItemFactory.create(
|
||||
due=due, parent=sequential, graded=True, format='Homework'
|
||||
) for _ in xrange(2)
|
||||
] for sequential in sequentials
|
||||
])
|
||||
blocks = flatten([ # pylint: disable=unused-variable
|
||||
[
|
||||
ItemFactory.create(parent=vertical) for _ in xrange(2)
|
||||
] for vertical in verticals
|
||||
])
|
||||
|
||||
def make_coach(self):
|
||||
"""
|
||||
create coach user
|
||||
"""
|
||||
role = CourseCcxCoachRole(self.course.id)
|
||||
role.add_users(self.coach)
|
||||
|
||||
def make_ccx(self):
|
||||
"""
|
||||
create ccx
|
||||
"""
|
||||
ccx = CcxFactory(course_id=self.course.id, coach=self.coach)
|
||||
return ccx
|
||||
|
||||
def get_outbox(self):
|
||||
"""
|
||||
get fake outbox
|
||||
"""
|
||||
from django.core import mail
|
||||
return mail.outbox
|
||||
|
||||
def tearDown(self):
|
||||
"""
|
||||
Undo patches.
|
||||
"""
|
||||
super(TestCoachDashboard, self).tearDown()
|
||||
patch.stopall()
|
||||
|
||||
def test_not_a_coach(self):
|
||||
"""
|
||||
User is not a coach, should get Forbidden response.
|
||||
"""
|
||||
url = reverse(
|
||||
'ccx_coach_dashboard',
|
||||
kwargs={'course_id': self.course.id.to_deprecated_string()})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_no_ccx_created(self):
|
||||
"""
|
||||
No CCX is created, coach should see form to add a CCX.
|
||||
"""
|
||||
self.make_coach()
|
||||
url = reverse(
|
||||
'ccx_coach_dashboard',
|
||||
kwargs={'course_id': self.course.id.to_deprecated_string()})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(re.search(
|
||||
'<form action=".+create_ccx"',
|
||||
response.content))
|
||||
|
||||
def test_create_ccx(self):
|
||||
"""
|
||||
Create CCX. Follow redirect to coach dashboard, confirm we see
|
||||
the coach dashboard for the new CCX.
|
||||
"""
|
||||
self.make_coach()
|
||||
url = reverse(
|
||||
'create_ccx',
|
||||
kwargs={'course_id': self.course.id.to_deprecated_string()})
|
||||
response = self.client.post(url, {'name': 'New CCX'})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
url = response.get('location') # pylint: disable=no-member
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(re.search('id="ccx-schedule"', response.content))
|
||||
|
||||
@patch('ccx.views.render_to_response', intercept_renderer)
|
||||
@patch('ccx.views.TODAY')
|
||||
def test_edit_schedule(self, today):
|
||||
"""
|
||||
Get CCX schedule, modify it, save it.
|
||||
"""
|
||||
today.return_value = datetime.datetime(2014, 11, 25, tzinfo=pytz.UTC)
|
||||
self.test_create_ccx()
|
||||
url = reverse(
|
||||
'ccx_coach_dashboard',
|
||||
kwargs={'course_id': self.course.id.to_deprecated_string()})
|
||||
response = self.client.get(url)
|
||||
schedule = json.loads(response.mako_context['schedule']) # pylint: disable=no-member
|
||||
self.assertEqual(len(schedule), 2)
|
||||
self.assertEqual(schedule[0]['hidden'], True)
|
||||
self.assertEqual(schedule[0]['start'], None)
|
||||
self.assertEqual(schedule[0]['children'][0]['start'], None)
|
||||
self.assertEqual(schedule[0]['due'], None)
|
||||
self.assertEqual(schedule[0]['children'][0]['due'], None)
|
||||
self.assertEqual(
|
||||
schedule[0]['children'][0]['children'][0]['due'], None
|
||||
)
|
||||
|
||||
url = reverse(
|
||||
'save_ccx',
|
||||
kwargs={'course_id': self.course.id.to_deprecated_string()})
|
||||
|
||||
def unhide(unit):
|
||||
"""
|
||||
Recursively unhide a unit and all of its children in the CCX
|
||||
schedule.
|
||||
"""
|
||||
unit['hidden'] = False
|
||||
for child in unit.get('children', ()):
|
||||
unhide(child)
|
||||
|
||||
unhide(schedule[0])
|
||||
schedule[0]['start'] = u'2014-11-20 00:00'
|
||||
schedule[0]['children'][0]['due'] = u'2014-12-25 00:00' # what a jerk!
|
||||
response = self.client.post(
|
||||
url, json.dumps(schedule), content_type='application/json'
|
||||
)
|
||||
|
||||
schedule = json.loads(response.content)['schedule']
|
||||
self.assertEqual(schedule[0]['hidden'], False)
|
||||
self.assertEqual(schedule[0]['start'], u'2014-11-20 00:00')
|
||||
self.assertEqual(
|
||||
schedule[0]['children'][0]['due'], u'2014-12-25 00:00'
|
||||
)
|
||||
|
||||
# Make sure start date set on course, follows start date of earliest
|
||||
# scheduled chapter
|
||||
ccx = CustomCourseForEdX.objects.get()
|
||||
course_start = get_override_for_ccx(ccx, self.course, 'start')
|
||||
self.assertEqual(str(course_start)[:-9], u'2014-11-20 00:00')
|
||||
|
||||
# Make sure grading policy adjusted
|
||||
policy = get_override_for_ccx(ccx, self.course, 'grading_policy',
|
||||
self.course.grading_policy)
|
||||
self.assertEqual(policy['GRADER'][0]['type'], 'Homework')
|
||||
self.assertEqual(policy['GRADER'][0]['min_count'], 4)
|
||||
self.assertEqual(policy['GRADER'][1]['type'], 'Lab')
|
||||
self.assertEqual(policy['GRADER'][1]['min_count'], 0)
|
||||
self.assertEqual(policy['GRADER'][2]['type'], 'Midterm Exam')
|
||||
self.assertEqual(policy['GRADER'][2]['min_count'], 0)
|
||||
self.assertEqual(policy['GRADER'][3]['type'], 'Final Exam')
|
||||
self.assertEqual(policy['GRADER'][3]['min_count'], 0)
|
||||
|
||||
def test_enroll_member_student(self):
|
||||
"""enroll a list of students who are members of the class
|
||||
"""
|
||||
self.make_coach()
|
||||
ccx = self.make_ccx()
|
||||
enrollment = CourseEnrollmentFactory(course_id=self.course.id)
|
||||
student = enrollment.user
|
||||
outbox = self.get_outbox()
|
||||
self.assertEqual(outbox, [])
|
||||
|
||||
url = reverse(
|
||||
'ccx_invite',
|
||||
kwargs={'course_id': self.course.id.to_deprecated_string()}
|
||||
)
|
||||
data = {
|
||||
'enrollment-button': 'Enroll',
|
||||
'student-ids': u','.join([student.email, ]), # pylint: disable=no-member
|
||||
'email-students': 'Notify-students-by-email',
|
||||
}
|
||||
response = self.client.post(url, data=data, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# we were redirected to our current location
|
||||
self.assertEqual(len(response.redirect_chain), 1)
|
||||
self.assertTrue(302 in response.redirect_chain[0])
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue(student.email in outbox[0].recipients()) # pylint: disable=no-member
|
||||
# a CcxMembership exists for this student
|
||||
self.assertTrue(
|
||||
CcxMembership.objects.filter(ccx=ccx, student=student).exists()
|
||||
)
|
||||
|
||||
def test_unenroll_member_student(self):
|
||||
"""unenroll a list of students who are members of the class
|
||||
"""
|
||||
self.make_coach()
|
||||
ccx = self.make_ccx()
|
||||
enrollment = CourseEnrollmentFactory(course_id=self.course.id)
|
||||
student = enrollment.user
|
||||
outbox = self.get_outbox()
|
||||
self.assertEqual(outbox, [])
|
||||
# student is member of CCX:
|
||||
CcxMembershipFactory(ccx=ccx, student=student)
|
||||
|
||||
url = reverse(
|
||||
'ccx_invite',
|
||||
kwargs={'course_id': self.course.id.to_deprecated_string()}
|
||||
)
|
||||
data = {
|
||||
'enrollment-button': 'Unenroll',
|
||||
'student-ids': u','.join([student.email, ]), # pylint: disable=no-member
|
||||
'email-students': 'Notify-students-by-email',
|
||||
}
|
||||
response = self.client.post(url, data=data, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# we were redirected to our current location
|
||||
self.assertEqual(len(response.redirect_chain), 1)
|
||||
self.assertTrue(302 in response.redirect_chain[0])
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue(student.email in outbox[0].recipients()) # pylint: disable=no-member
|
||||
# the membership for this student is gone
|
||||
self.assertFalse(
|
||||
CcxMembership.objects.filter(ccx=ccx, student=student).exists()
|
||||
)
|
||||
|
||||
def test_enroll_non_user_student(self):
|
||||
"""enroll a list of students who are not users yet
|
||||
"""
|
||||
test_email = "nobody@nowhere.com"
|
||||
self.make_coach()
|
||||
ccx = self.make_ccx()
|
||||
outbox = self.get_outbox()
|
||||
self.assertEqual(outbox, [])
|
||||
|
||||
url = reverse(
|
||||
'ccx_invite',
|
||||
kwargs={'course_id': self.course.id.to_deprecated_string()}
|
||||
)
|
||||
data = {
|
||||
'enrollment-button': 'Enroll',
|
||||
'student-ids': u','.join([test_email, ]),
|
||||
'email-students': 'Notify-students-by-email',
|
||||
}
|
||||
response = self.client.post(url, data=data, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# we were redirected to our current location
|
||||
self.assertEqual(len(response.redirect_chain), 1)
|
||||
self.assertTrue(302 in response.redirect_chain[0])
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue(test_email in outbox[0].recipients())
|
||||
self.assertTrue(
|
||||
CcxFutureMembership.objects.filter(
|
||||
ccx=ccx, email=test_email
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_unenroll_non_user_student(self):
|
||||
"""unenroll a list of students who are not users yet
|
||||
"""
|
||||
test_email = "nobody@nowhere.com"
|
||||
self.make_coach()
|
||||
ccx = self.make_ccx()
|
||||
outbox = self.get_outbox()
|
||||
CcxFutureMembershipFactory(ccx=ccx, email=test_email)
|
||||
self.assertEqual(outbox, [])
|
||||
|
||||
url = reverse(
|
||||
'ccx_invite',
|
||||
kwargs={'course_id': self.course.id.to_deprecated_string()}
|
||||
)
|
||||
data = {
|
||||
'enrollment-button': 'Unenroll',
|
||||
'student-ids': u','.join([test_email, ]),
|
||||
'email-students': 'Notify-students-by-email',
|
||||
}
|
||||
response = self.client.post(url, data=data, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# we were redirected to our current location
|
||||
self.assertEqual(len(response.redirect_chain), 1)
|
||||
self.assertTrue(302 in response.redirect_chain[0])
|
||||
self.assertEqual(len(outbox), 1)
|
||||
self.assertTrue(test_email in outbox[0].recipients())
|
||||
self.assertFalse(
|
||||
CcxFutureMembership.objects.filter(
|
||||
ccx=ccx, email=test_email
|
||||
).exists()
|
||||
)
|
||||
|
||||
def test_manage_add_single_student(self):
|
||||
"""enroll a single student who is a member of the class already
|
||||
"""
|
||||
self.make_coach()
|
||||
ccx = self.make_ccx()
|
||||
enrollment = CourseEnrollmentFactory(course_id=self.course.id)
|
||||
student = enrollment.user
|
||||
# no emails have been sent so far
|
||||
outbox = self.get_outbox()
|
||||
self.assertEqual(outbox, [])
|
||||
|
||||
url = reverse(
|
||||
'ccx_manage_student',
|
||||
kwargs={'course_id': self.course.id.to_deprecated_string()}
|
||||
)
|
||||
data = {
|
||||
'student-action': 'add',
|
||||
'student-id': u','.join([student.email, ]), # pylint: disable=no-member
|
||||
}
|
||||
response = self.client.post(url, data=data, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# we were redirected to our current location
|
||||
self.assertEqual(len(response.redirect_chain), 1)
|
||||
self.assertTrue(302 in response.redirect_chain[0])
|
||||
self.assertEqual(outbox, [])
|
||||
# a CcxMembership exists for this student
|
||||
self.assertTrue(
|
||||
CcxMembership.objects.filter(ccx=ccx, student=student).exists()
|
||||
)
|
||||
|
||||
def test_manage_remove_single_student(self):
|
||||
"""unenroll a single student who is a member of the class already
|
||||
"""
|
||||
self.make_coach()
|
||||
ccx = self.make_ccx()
|
||||
enrollment = CourseEnrollmentFactory(course_id=self.course.id)
|
||||
student = enrollment.user
|
||||
CcxMembershipFactory(ccx=ccx, student=student)
|
||||
# no emails have been sent so far
|
||||
outbox = self.get_outbox()
|
||||
self.assertEqual(outbox, [])
|
||||
|
||||
url = reverse(
|
||||
'ccx_manage_student',
|
||||
kwargs={'course_id': self.course.id.to_deprecated_string()}
|
||||
)
|
||||
data = {
|
||||
'student-action': 'revoke',
|
||||
'student-id': u','.join([student.email, ]), # pylint: disable=no-member
|
||||
}
|
||||
response = self.client.post(url, data=data, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# we were redirected to our current location
|
||||
self.assertEqual(len(response.redirect_chain), 1)
|
||||
self.assertTrue(302 in response.redirect_chain[0])
|
||||
self.assertEqual(outbox, [])
|
||||
# a CcxMembership exists for this student
|
||||
self.assertFalse(
|
||||
CcxMembership.objects.filter(ccx=ccx, student=student).exists()
|
||||
)
|
||||
|
||||
|
||||
GET_CHILDREN = XModuleMixin.get_children
|
||||
|
||||
|
||||
def patched_get_children(self, usage_key_filter=None): # pylint: disable=missing-docstring
|
||||
def iter_children(): # pylint: disable=missing-docstring
|
||||
print self.__dict__
|
||||
for child in GET_CHILDREN(self, usage_key_filter=usage_key_filter):
|
||||
child._field_data_cache = {} # pylint: disable=protected-access
|
||||
if not child.visible_to_staff_only:
|
||||
yield child
|
||||
return list(iter_children())
|
||||
|
||||
|
||||
@override_settings(FIELD_OVERRIDE_PROVIDERS=(
|
||||
'ccx.overrides.CustomCoursesForEdxOverrideProvider',))
|
||||
@patch('xmodule.x_module.XModuleMixin.get_children', patched_get_children, spec=True)
|
||||
class TestCCXGrades(ModuleStoreTestCase, LoginEnrollmentTestCase):
|
||||
"""
|
||||
Tests for Custom Courses views.
|
||||
"""
|
||||
def setUp(self):
|
||||
"""
|
||||
Set up tests
|
||||
"""
|
||||
super(TestCCXGrades, self).setUp()
|
||||
self.course = course = CourseFactory.create()
|
||||
|
||||
# Create instructor account
|
||||
self.coach = coach = AdminFactory.create()
|
||||
self.client.login(username=coach.username, password="test")
|
||||
|
||||
# Create a course outline
|
||||
self.mooc_start = start = datetime.datetime(
|
||||
2010, 5, 12, 2, 42, tzinfo=pytz.UTC)
|
||||
chapter = ItemFactory.create(
|
||||
start=start, parent=course, category='sequential')
|
||||
sections = [
|
||||
ItemFactory.create(
|
||||
parent=chapter,
|
||||
category="sequential",
|
||||
metadata={'graded': True, 'format': 'Homework'})
|
||||
for _ in xrange(4)]
|
||||
|
||||
role = CourseCcxCoachRole(self.course.id)
|
||||
role.add_users(coach)
|
||||
self.ccx = ccx = CcxFactory(course_id=self.course.id, coach=self.coach)
|
||||
|
||||
self.student = student = UserFactory.create()
|
||||
CourseEnrollmentFactory.create(user=student, course_id=self.course.id)
|
||||
CcxMembershipFactory(ccx=ccx, student=student, active=True)
|
||||
|
||||
for i, section in enumerate(sections):
|
||||
for j in xrange(4):
|
||||
item = ItemFactory.create(
|
||||
parent=section,
|
||||
category="problem",
|
||||
data=StringResponseXMLFactory().build_xml(answer='foo'),
|
||||
metadata={'rerandomize': 'always'}
|
||||
)
|
||||
|
||||
StudentModuleFactory.create(
|
||||
grade=1 if i < j else 0,
|
||||
max_grade=1,
|
||||
student=student,
|
||||
course_id=self.course.id,
|
||||
module_state_key=item.location
|
||||
)
|
||||
|
||||
# Apparently the test harness doesn't use LmsFieldStorage, and I'm not
|
||||
# sure if there's a way to poke the test harness to do so. So, we'll
|
||||
# just inject the override field storage in this brute force manner.
|
||||
OverrideFieldData.provider_classes = None
|
||||
for block in iter_blocks(course):
|
||||
block._field_data = OverrideFieldData.wrap( # pylint: disable=protected-access
|
||||
coach, block._field_data) # pylint: disable=protected-access
|
||||
block._field_data_cache = {'tabs': [], 'discussion_topics': []} # pylint: disable=protected-access
|
||||
|
||||
def cleanup_provider_classes():
|
||||
"""
|
||||
After everything is done, clean up by un-doing the change to the
|
||||
OverrideFieldData object that is done during the wrap method.
|
||||
"""
|
||||
OverrideFieldData.provider_classes = None
|
||||
self.addCleanup(cleanup_provider_classes)
|
||||
|
||||
patch_context = patch('ccx.views.get_course_by_id')
|
||||
get_course = patch_context.start()
|
||||
get_course.return_value = course
|
||||
self.addCleanup(patch_context.stop)
|
||||
|
||||
override_field_for_ccx(ccx, course, 'grading_policy', {
|
||||
'GRADER': [
|
||||
{'drop_count': 0,
|
||||
'min_count': 2,
|
||||
'short_label': 'HW',
|
||||
'type': 'Homework',
|
||||
'weight': 1}
|
||||
],
|
||||
'GRADE_CUTOFFS': {'Pass': 0.75},
|
||||
})
|
||||
override_field_for_ccx(
|
||||
ccx, sections[-1], 'visible_to_staff_only', True)
|
||||
|
||||
@patch('ccx.views.render_to_response', intercept_renderer)
|
||||
def test_gradebook(self):
|
||||
url = reverse(
|
||||
'ccx_gradebook',
|
||||
kwargs={'course_id': self.course.id.to_deprecated_string()}
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
student_info = response.mako_context['students'][0] # pylint: disable=no-member
|
||||
self.assertEqual(student_info['grade_summary']['percent'], 0.5)
|
||||
self.assertEqual(
|
||||
student_info['grade_summary']['grade_breakdown'][0]['percent'],
|
||||
0.5)
|
||||
self.assertEqual(
|
||||
len(student_info['grade_summary']['section_breakdown']), 4)
|
||||
|
||||
def test_grades_csv(self):
|
||||
url = reverse(
|
||||
'ccx_grades_csv',
|
||||
kwargs={'course_id': self.course.id.to_deprecated_string()}
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
headers, row = (
|
||||
row.strip().split(',') for row in
|
||||
response.content.strip().split('\n')
|
||||
)
|
||||
data = dict(zip(headers, row))
|
||||
self.assertEqual(data['HW 01'], '0.75')
|
||||
self.assertEqual(data['HW 02'], '0.5')
|
||||
self.assertEqual(data['HW 03'], '0.25')
|
||||
self.assertEqual(data['HW Avg'], '0.5')
|
||||
self.assertTrue('HW 04' not in data)
|
||||
|
||||
@patch('courseware.views.render_to_response', intercept_renderer)
|
||||
def test_student_progress(self):
|
||||
patch_context = patch('courseware.views.get_course_with_access')
|
||||
get_course = patch_context.start()
|
||||
get_course.return_value = self.course
|
||||
self.addCleanup(patch_context.stop)
|
||||
|
||||
self.client.login(username=self.student.username, password="test")
|
||||
session = self.client.session
|
||||
session[ACTIVE_CCX_KEY] = self.ccx.id # pylint: disable=no-member
|
||||
session.save()
|
||||
self.client.session.get(ACTIVE_CCX_KEY)
|
||||
url = reverse(
|
||||
'progress',
|
||||
kwargs={'course_id': self.course.id.to_deprecated_string()}
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
grades = response.mako_context['grade_summary'] # pylint: disable=no-member
|
||||
self.assertEqual(grades['percent'], 0.5)
|
||||
self.assertEqual(grades['grade_breakdown'][0]['percent'], 0.5)
|
||||
self.assertEqual(len(grades['section_breakdown']), 4)
|
||||
|
||||
|
||||
class TestSwitchActiveCCX(ModuleStoreTestCase, LoginEnrollmentTestCase):
|
||||
"""Verify the view for switching which CCX is active, if any
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestSwitchActiveCCX, self).setUp()
|
||||
self.course = course = CourseFactory.create()
|
||||
coach = AdminFactory.create()
|
||||
role = CourseCcxCoachRole(course.id)
|
||||
role.add_users(coach)
|
||||
self.ccx = CcxFactory(course_id=course.id, coach=coach)
|
||||
enrollment = CourseEnrollmentFactory.create(course_id=course.id)
|
||||
self.user = enrollment.user
|
||||
self.target_url = reverse(
|
||||
'course_root', args=[course.id.to_deprecated_string()]
|
||||
)
|
||||
|
||||
def register_user_in_ccx(self, active=False):
|
||||
"""create registration of self.user in self.ccx
|
||||
|
||||
registration will be inactive unless active=True
|
||||
"""
|
||||
CcxMembershipFactory(ccx=self.ccx, student=self.user, active=active)
|
||||
|
||||
def revoke_ccx_registration(self):
|
||||
"""
|
||||
delete membership
|
||||
"""
|
||||
membership = CcxMembership.objects.filter(
|
||||
ccx=self.ccx, student=self.user
|
||||
)
|
||||
membership.delete()
|
||||
|
||||
def verify_active_ccx(self, request, id=None): # pylint: disable=redefined-builtin, invalid-name
|
||||
"""verify that we have the correct active ccx"""
|
||||
if id:
|
||||
id = str(id)
|
||||
self.assertEqual(id, request.session.get(ACTIVE_CCX_KEY, None))
|
||||
|
||||
def test_unauthorized_cannot_switch_to_ccx(self):
|
||||
switch_url = reverse(
|
||||
'switch_active_ccx',
|
||||
args=[self.course.id.to_deprecated_string(), self.ccx.id]
|
||||
)
|
||||
response = self.client.get(switch_url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_unauthorized_cannot_switch_to_mooc(self):
|
||||
switch_url = reverse(
|
||||
'switch_active_ccx',
|
||||
args=[self.course.id.to_deprecated_string()]
|
||||
)
|
||||
response = self.client.get(switch_url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_enrolled_inactive_user_cannot_select_ccx(self):
|
||||
self.register_user_in_ccx(active=False)
|
||||
self.client.login(username=self.user.username, password="test")
|
||||
switch_url = reverse(
|
||||
'switch_active_ccx',
|
||||
args=[self.course.id.to_deprecated_string(), self.ccx.id]
|
||||
)
|
||||
response = self.client.get(switch_url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response.get('Location', '').endswith(self.target_url)) # pylint: disable=no-member
|
||||
# if the ccx were active, we'd need to pass the ID of the ccx here.
|
||||
self.verify_active_ccx(self.client)
|
||||
|
||||
def test_enrolled_user_can_select_ccx(self):
|
||||
self.register_user_in_ccx(active=True)
|
||||
self.client.login(username=self.user.username, password="test")
|
||||
switch_url = reverse(
|
||||
'switch_active_ccx',
|
||||
args=[self.course.id.to_deprecated_string(), self.ccx.id]
|
||||
)
|
||||
response = self.client.get(switch_url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response.get('Location', '').endswith(self.target_url)) # pylint: disable=no-member
|
||||
self.verify_active_ccx(self.client, self.ccx.id)
|
||||
|
||||
def test_enrolled_user_can_select_mooc(self):
|
||||
self.register_user_in_ccx(active=True)
|
||||
self.client.login(username=self.user.username, password="test")
|
||||
# pre-seed the session with the ccx id
|
||||
session = self.client.session
|
||||
session[ACTIVE_CCX_KEY] = str(self.ccx.id)
|
||||
session.save()
|
||||
switch_url = reverse(
|
||||
'switch_active_ccx',
|
||||
args=[self.course.id.to_deprecated_string()]
|
||||
)
|
||||
response = self.client.get(switch_url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response.get('Location', '').endswith(self.target_url)) # pylint: disable=no-member
|
||||
self.verify_active_ccx(self.client)
|
||||
|
||||
def test_unenrolled_user_cannot_select_ccx(self):
|
||||
self.client.login(username=self.user.username, password="test")
|
||||
switch_url = reverse(
|
||||
'switch_active_ccx',
|
||||
args=[self.course.id.to_deprecated_string(), self.ccx.id]
|
||||
)
|
||||
response = self.client.get(switch_url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response.get('Location', '').endswith(self.target_url)) # pylint: disable=no-member
|
||||
# if the ccx were active, we'd need to pass the ID of the ccx here.
|
||||
self.verify_active_ccx(self.client)
|
||||
|
||||
def test_unenrolled_user_switched_to_mooc(self):
|
||||
self.client.login(username=self.user.username, password="test")
|
||||
# pre-seed the session with the ccx id
|
||||
session = self.client.session
|
||||
session[ACTIVE_CCX_KEY] = str(self.ccx.id)
|
||||
session.save()
|
||||
switch_url = reverse(
|
||||
'switch_active_ccx',
|
||||
args=[self.course.id.to_deprecated_string(), self.ccx.id]
|
||||
)
|
||||
response = self.client.get(switch_url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response.get('Location', '').endswith(self.target_url)) # pylint: disable=no-member
|
||||
# we tried to select the ccx but are not registered, so we are switched
|
||||
# back to the mooc view
|
||||
self.verify_active_ccx(self.client)
|
||||
|
||||
def test_unassociated_course_and_ccx_not_selected(self):
|
||||
new_course = CourseFactory.create()
|
||||
self.client.login(username=self.user.username, password="test")
|
||||
expected_url = reverse(
|
||||
'course_root', args=[new_course.id.to_deprecated_string()]
|
||||
)
|
||||
# the ccx and the course are not related.
|
||||
switch_url = reverse(
|
||||
'switch_active_ccx',
|
||||
args=[new_course.id.to_deprecated_string(), self.ccx.id]
|
||||
)
|
||||
response = self.client.get(switch_url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response.get('Location', '').endswith(expected_url)) # pylint: disable=no-member
|
||||
# the mooc should be active
|
||||
self.verify_active_ccx(self.client)
|
||||
|
||||
def test_missing_ccx_cannot_be_selected(self):
|
||||
self.register_user_in_ccx()
|
||||
self.client.login(username=self.user.username, password="test")
|
||||
switch_url = reverse(
|
||||
'switch_active_ccx',
|
||||
args=[self.course.id.to_deprecated_string(), self.ccx.id]
|
||||
)
|
||||
# delete the ccx
|
||||
self.ccx.delete() # pylint: disable=no-member
|
||||
|
||||
response = self.client.get(switch_url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response.get('Location', '').endswith(self.target_url)) # pylint: disable=no-member
|
||||
# we tried to select the ccx it doesn't exist anymore, so we are
|
||||
# switched back to the mooc view
|
||||
self.verify_active_ccx(self.client)
|
||||
|
||||
def test_revoking_ccx_membership_revokes_active_ccx(self):
|
||||
self.register_user_in_ccx(active=True)
|
||||
self.client.login(username=self.user.username, password="test")
|
||||
# ensure ccx is active in the request session
|
||||
switch_url = reverse(
|
||||
'switch_active_ccx',
|
||||
args=[self.course.id.to_deprecated_string(), self.ccx.id]
|
||||
)
|
||||
self.client.get(switch_url)
|
||||
self.verify_active_ccx(self.client, self.ccx.id)
|
||||
# unenroll the user from the ccx
|
||||
self.revoke_ccx_registration()
|
||||
# request the course root and verify that the ccx is not active
|
||||
self.client.get(self.target_url)
|
||||
self.verify_active_ccx(self.client)
|
||||
|
||||
|
||||
def flatten(seq):
|
||||
"""
|
||||
For [[1, 2], [3, 4]] returns [1, 2, 3, 4]. Does not recurse.
|
||||
"""
|
||||
return [x for sub in seq for x in sub]
|
||||
|
||||
|
||||
def iter_blocks(course):
|
||||
"""
|
||||
Returns an iterator over all of the blocks in a course.
|
||||
"""
|
||||
def visit(block):
|
||||
""" get child blocks """
|
||||
yield block
|
||||
for child in block.get_children():
|
||||
for descendant in visit(child): # wish they'd backport yield from
|
||||
yield descendant
|
||||
return visit(course)
|
||||
307
lms/djangoapps/ccx/utils.py
Normal file
307
lms/djangoapps/ccx/utils.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
CCX Enrollment operations for use by Coach APIs.
|
||||
|
||||
Does not include any access control, be sure to check access before calling.
|
||||
"""
|
||||
import logging
|
||||
from courseware.courses import get_course_about_section # pylint: disable=import-error
|
||||
from courseware.courses import get_course_by_id # pylint: disable=import-error
|
||||
from django.contrib.auth.models import User
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.mail import send_mail
|
||||
from edxmako.shortcuts import render_to_string # pylint: disable=import-error
|
||||
from microsite_configuration import microsite # pylint: disable=import-error
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.error_module import ErrorDescriptor
|
||||
|
||||
from .models import (
|
||||
CcxMembership,
|
||||
CcxFutureMembership,
|
||||
)
|
||||
from .overrides import get_current_ccx
|
||||
|
||||
|
||||
log = logging.getLogger("edx.ccx")
|
||||
|
||||
|
||||
class EmailEnrollmentState(object):
|
||||
""" Store the complete enrollment state of an email in a class """
|
||||
def __init__(self, ccx, email):
|
||||
exists_user = User.objects.filter(email=email).exists()
|
||||
if exists_user:
|
||||
user = User.objects.get(email=email)
|
||||
ccx_member = CcxMembership.objects.filter(ccx=ccx, student=user)
|
||||
in_ccx = ccx_member.exists()
|
||||
full_name = user.profile.name
|
||||
else:
|
||||
user = None
|
||||
in_ccx = False
|
||||
full_name = None
|
||||
self.user = exists_user
|
||||
self.member = user
|
||||
self.full_name = full_name
|
||||
self.in_ccx = in_ccx
|
||||
|
||||
def __repr__(self):
|
||||
return "{}(user={}, member={}, in_ccx={})".format(
|
||||
self.__class__.__name__,
|
||||
self.user,
|
||||
self.member,
|
||||
self.in_ccx,
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
""" return dict with membership and ccx info """
|
||||
return {
|
||||
'user': self.user,
|
||||
'member': self.member,
|
||||
'in_ccx': self.in_ccx,
|
||||
}
|
||||
|
||||
|
||||
def enroll_email(ccx, student_email, auto_enroll=False, email_students=False, email_params=None):
|
||||
"""
|
||||
Send email to newly enrolled student
|
||||
"""
|
||||
if email_params is None:
|
||||
email_params = get_email_params(ccx, True)
|
||||
previous_state = EmailEnrollmentState(ccx, student_email)
|
||||
|
||||
if previous_state.user:
|
||||
user = User.objects.get(email=student_email)
|
||||
if not previous_state.in_ccx:
|
||||
membership = CcxMembership(
|
||||
ccx=ccx, student=user, active=True
|
||||
)
|
||||
membership.save()
|
||||
elif auto_enroll:
|
||||
# activate existing memberships
|
||||
membership = CcxMembership.objects.get(student=user, ccx=ccx)
|
||||
membership.active = True
|
||||
membership.save()
|
||||
if email_students:
|
||||
email_params['message'] = 'enrolled_enroll'
|
||||
email_params['email_address'] = student_email
|
||||
email_params['full_name'] = previous_state.full_name
|
||||
send_mail_to_student(student_email, email_params)
|
||||
else:
|
||||
membership = CcxFutureMembership(
|
||||
ccx=ccx, auto_enroll=auto_enroll, email=student_email
|
||||
)
|
||||
membership.save()
|
||||
if email_students:
|
||||
email_params['message'] = 'allowed_enroll'
|
||||
email_params['email_address'] = student_email
|
||||
send_mail_to_student(student_email, email_params)
|
||||
|
||||
after_state = EmailEnrollmentState(ccx, student_email)
|
||||
|
||||
return previous_state, after_state
|
||||
|
||||
|
||||
def unenroll_email(ccx, student_email, email_students=False, email_params=None):
|
||||
"""
|
||||
send email to unenrolled students
|
||||
"""
|
||||
if email_params is None:
|
||||
email_params = get_email_params(ccx, True)
|
||||
previous_state = EmailEnrollmentState(ccx, student_email)
|
||||
|
||||
if previous_state.in_ccx:
|
||||
CcxMembership.objects.get(
|
||||
ccx=ccx, student=previous_state.member
|
||||
).delete()
|
||||
if email_students:
|
||||
email_params['message'] = 'enrolled_unenroll'
|
||||
email_params['email_address'] = student_email
|
||||
email_params['full_name'] = previous_state.full_name
|
||||
send_mail_to_student(student_email, email_params)
|
||||
else:
|
||||
if CcxFutureMembership.objects.filter(
|
||||
ccx=ccx, email=student_email).exists():
|
||||
CcxFutureMembership.objects.get(
|
||||
ccx=ccx, email=student_email
|
||||
).delete()
|
||||
if email_students:
|
||||
email_params['message'] = 'allowed_unenroll'
|
||||
email_params['email_address'] = student_email
|
||||
send_mail_to_student(student_email, email_params)
|
||||
|
||||
after_state = EmailEnrollmentState(ccx, student_email)
|
||||
|
||||
return previous_state, after_state
|
||||
|
||||
|
||||
def get_email_params(ccx, auto_enroll, secure=True):
|
||||
"""
|
||||
get parameters for enrollment emails
|
||||
"""
|
||||
protocol = 'https' if secure else 'http'
|
||||
course_id = ccx.course_id
|
||||
|
||||
stripped_site_name = microsite.get_value(
|
||||
'SITE_NAME',
|
||||
settings.SITE_NAME
|
||||
)
|
||||
registration_url = u'{proto}://{site}{path}'.format(
|
||||
proto=protocol,
|
||||
site=stripped_site_name,
|
||||
path=reverse('register_user')
|
||||
)
|
||||
course_url = u'{proto}://{site}{path}'.format(
|
||||
proto=protocol,
|
||||
site=stripped_site_name,
|
||||
path=reverse(
|
||||
'course_root',
|
||||
kwargs={'course_id': course_id.to_deprecated_string()}
|
||||
)
|
||||
)
|
||||
|
||||
course_about_url = None
|
||||
if not settings.FEATURES.get('ENABLE_MKTG_SITE', False):
|
||||
course_about_url = u'{proto}://{site}{path}'.format(
|
||||
proto=protocol,
|
||||
site=stripped_site_name,
|
||||
path=reverse(
|
||||
'about_course',
|
||||
kwargs={'course_id': course_id.to_deprecated_string()}
|
||||
)
|
||||
)
|
||||
|
||||
email_params = {
|
||||
'site_name': stripped_site_name,
|
||||
'registration_url': registration_url,
|
||||
'course': ccx,
|
||||
'auto_enroll': auto_enroll,
|
||||
'course_url': course_url,
|
||||
'course_about_url': course_about_url,
|
||||
}
|
||||
return email_params
|
||||
|
||||
|
||||
def send_mail_to_student(student, param_dict):
|
||||
"""
|
||||
Check parameters, set text template and send email to student
|
||||
"""
|
||||
if 'course' in param_dict:
|
||||
param_dict['course_name'] = param_dict['course'].display_name
|
||||
|
||||
param_dict['site_name'] = microsite.get_value(
|
||||
'SITE_NAME',
|
||||
param_dict['site_name']
|
||||
)
|
||||
|
||||
subject = None
|
||||
message = None
|
||||
|
||||
message_type = param_dict['message']
|
||||
|
||||
email_template_dict = {
|
||||
'allowed_enroll': (
|
||||
'ccx/enroll_email_allowedsubject.txt',
|
||||
'ccx/enroll_email_allowedmessage.txt'
|
||||
),
|
||||
'enrolled_enroll': (
|
||||
'ccx/enroll_email_enrolledsubject.txt',
|
||||
'ccx/enroll_email_enrolledmessage.txt'
|
||||
),
|
||||
'allowed_unenroll': (
|
||||
'ccx/unenroll_email_subject.txt',
|
||||
'ccx/unenroll_email_allowedmessage.txt'
|
||||
),
|
||||
'enrolled_unenroll': (
|
||||
'ccx/unenroll_email_subject.txt',
|
||||
'ccx/unenroll_email_enrolledmessage.txt'
|
||||
),
|
||||
}
|
||||
|
||||
subject_template, message_template = email_template_dict.get(
|
||||
message_type, (None, None)
|
||||
)
|
||||
if subject_template is not None and message_template is not None:
|
||||
subject = render_to_string(subject_template, param_dict)
|
||||
message = render_to_string(message_template, param_dict)
|
||||
|
||||
if subject and message:
|
||||
message = message.strip()
|
||||
|
||||
subject = ''.join(subject.splitlines())
|
||||
from_address = microsite.get_value(
|
||||
'email_from_address',
|
||||
settings.DEFAULT_FROM_EMAIL
|
||||
)
|
||||
|
||||
send_mail(
|
||||
subject,
|
||||
message,
|
||||
from_address,
|
||||
[student],
|
||||
fail_silently=False
|
||||
)
|
||||
|
||||
|
||||
def get_all_ccx_for_user(user):
|
||||
"""return all CCXS to which the user is registered
|
||||
|
||||
Returns a list of dicts: {
|
||||
ccx_name: <formatted title of CCX course>
|
||||
ccx_url: <url to view this CCX>
|
||||
ccx_active: True if this ccx is currently the 'active' one
|
||||
mooc_name: <formatted title of the MOOC course for this CCX>
|
||||
mooc_url: <url to view this MOOC>
|
||||
}
|
||||
"""
|
||||
if user.is_anonymous():
|
||||
return []
|
||||
current_active_ccx = get_current_ccx()
|
||||
memberships = []
|
||||
for membership in CcxMembership.memberships_for_user(user):
|
||||
course = get_course_by_id(membership.ccx.course_id)
|
||||
ccx = membership.ccx
|
||||
ccx_title = ccx.display_name
|
||||
mooc_title = get_course_about_section(course, 'title')
|
||||
url = reverse(
|
||||
'switch_active_ccx',
|
||||
args=[course.id.to_deprecated_string(), membership.ccx.id]
|
||||
)
|
||||
mooc_url = reverse(
|
||||
'switch_active_ccx',
|
||||
args=[course.id.to_deprecated_string(), ]
|
||||
)
|
||||
memberships.append({
|
||||
'ccx_name': ccx_title,
|
||||
'ccx_url': url,
|
||||
'active': membership.ccx == current_active_ccx,
|
||||
'mooc_name': mooc_title,
|
||||
'mooc_url': mooc_url,
|
||||
})
|
||||
return memberships
|
||||
|
||||
|
||||
def get_ccx_membership_triplets(user, course_org_filter, org_filter_out_set):
|
||||
"""
|
||||
Get the relevant set of (CustomCourseForEdX, CcxMembership, Course)
|
||||
triplets to be displayed on a student's dashboard.
|
||||
"""
|
||||
# only active memberships for now
|
||||
for membership in CcxMembership.memberships_for_user(user):
|
||||
ccx = membership.ccx
|
||||
store = modulestore()
|
||||
with store.bulk_operations(ccx.course_id):
|
||||
course = store.get_course(ccx.course_id)
|
||||
if course and not isinstance(course, ErrorDescriptor):
|
||||
# if we are in a Microsite, then filter out anything that is not
|
||||
# attributed (by ORG) to that Microsite
|
||||
if course_org_filter and course_org_filter != course.location.org:
|
||||
continue
|
||||
# Conversely, if we are not in a Microsite, then let's filter out any enrollments
|
||||
# with courses attributed (by ORG) to Microsites
|
||||
elif course.location.org in org_filter_out_set:
|
||||
continue
|
||||
|
||||
yield (ccx, membership, course)
|
||||
else:
|
||||
log.error("User {0} enrolled in {2} course {1}".format( # pylint: disable=logging-format-interpolation
|
||||
user.username, ccx.course_id, "broken" if course else "non-existent"
|
||||
))
|
||||
533
lms/djangoapps/ccx/views.py
Normal file
533
lms/djangoapps/ccx/views.py
Normal file
@@ -0,0 +1,533 @@
|
||||
"""
|
||||
Views related to the Custom Courses feature.
|
||||
"""
|
||||
import csv
|
||||
import datetime
|
||||
import functools
|
||||
import json
|
||||
import logging
|
||||
import pytz
|
||||
|
||||
from copy import deepcopy
|
||||
from cStringIO import StringIO
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.http import (
|
||||
HttpResponse,
|
||||
HttpResponseForbidden,
|
||||
HttpResponseRedirect,
|
||||
)
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.cache import cache_control
|
||||
from django_future.csrf import ensure_csrf_cookie # pylint: disable=import-error
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from courseware.courses import get_course_by_id # pylint: disable=import-error
|
||||
|
||||
from courseware.field_overrides import disable_overrides # pylint: disable=import-error
|
||||
from courseware.grades import iterate_grades_for # pylint: disable=import-error
|
||||
from courseware.model_data import FieldDataCache # pylint: disable=import-error
|
||||
from courseware.module_render import get_module_for_descriptor # pylint: disable=import-error
|
||||
from edxmako.shortcuts import render_to_response # pylint: disable=import-error
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from student.roles import CourseCcxCoachRole # pylint: disable=import-error
|
||||
|
||||
from instructor.offline_gradecalc import student_grades # pylint: disable=import-error
|
||||
from instructor.views.api import _split_input_list # pylint: disable=import-error
|
||||
from instructor.views.tools import get_student_from_identifier # pylint: disable=import-error
|
||||
|
||||
from .models import CustomCourseForEdX, CcxMembership
|
||||
from .overrides import (
|
||||
clear_override_for_ccx,
|
||||
get_override_for_ccx,
|
||||
override_field_for_ccx,
|
||||
ccx_context,
|
||||
)
|
||||
from .utils import (
|
||||
enroll_email,
|
||||
unenroll_email,
|
||||
)
|
||||
from ccx import ACTIVE_CCX_KEY # pylint: disable=import-error
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
TODAY = datetime.datetime.today # for patching in tests
|
||||
|
||||
|
||||
def coach_dashboard(view):
|
||||
"""
|
||||
View decorator which enforces that the user have the CCX coach role on the
|
||||
given course and goes ahead and translates the course_id from the Django
|
||||
route into a course object.
|
||||
"""
|
||||
@functools.wraps(view)
|
||||
def wrapper(request, course_id):
|
||||
"""
|
||||
Wraps the view function, performing access check, loading the course,
|
||||
and modifying the view's call signature.
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
role = CourseCcxCoachRole(course_key)
|
||||
if not role.has_user(request.user):
|
||||
return HttpResponseForbidden(
|
||||
_('You must be a CCX Coach to access this view.'))
|
||||
course = get_course_by_id(course_key, depth=None)
|
||||
return view(request, course)
|
||||
return wrapper
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@coach_dashboard
|
||||
def dashboard(request, course):
|
||||
"""
|
||||
Display the CCX Coach Dashboard.
|
||||
"""
|
||||
ccx = get_ccx_for_coach(course, request.user)
|
||||
context = {
|
||||
'course': course,
|
||||
'ccx': ccx,
|
||||
}
|
||||
|
||||
if ccx:
|
||||
schedule = get_ccx_schedule(course, ccx)
|
||||
grading_policy = get_override_for_ccx(
|
||||
ccx, course, 'grading_policy', course.grading_policy)
|
||||
context['schedule'] = json.dumps(schedule, indent=4)
|
||||
context['save_url'] = reverse(
|
||||
'save_ccx', kwargs={'course_id': course.id})
|
||||
context['ccx_members'] = CcxMembership.objects.filter(ccx=ccx)
|
||||
context['gradebook_url'] = reverse(
|
||||
'ccx_gradebook', kwargs={'course_id': course.id})
|
||||
context['grades_csv_url'] = reverse(
|
||||
'ccx_grades_csv', kwargs={'course_id': course.id})
|
||||
context['grading_policy'] = json.dumps(grading_policy, indent=4)
|
||||
context['grading_policy_url'] = reverse(
|
||||
'ccx_set_grading_policy', kwargs={'course_id': course.id})
|
||||
else:
|
||||
context['create_ccx_url'] = reverse(
|
||||
'create_ccx', kwargs={'course_id': course.id})
|
||||
return render_to_response('ccx/coach_dashboard.html', context)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@coach_dashboard
|
||||
def create_ccx(request, course):
|
||||
"""
|
||||
Create a new CCX
|
||||
"""
|
||||
name = request.POST.get('name')
|
||||
ccx = CustomCourseForEdX(
|
||||
course_id=course.id,
|
||||
coach=request.user,
|
||||
display_name=name)
|
||||
ccx.save()
|
||||
|
||||
# Make sure start/due are overridden for entire course
|
||||
start = TODAY().replace(tzinfo=pytz.UTC)
|
||||
override_field_for_ccx(ccx, course, 'start', start)
|
||||
override_field_for_ccx(ccx, course, 'due', None)
|
||||
|
||||
# Hide anything that can show up in the schedule
|
||||
hidden = 'visible_to_staff_only'
|
||||
for chapter in course.get_children():
|
||||
override_field_for_ccx(ccx, chapter, hidden, True)
|
||||
for sequential in chapter.get_children():
|
||||
override_field_for_ccx(ccx, sequential, hidden, True)
|
||||
for vertical in sequential.get_children():
|
||||
override_field_for_ccx(ccx, vertical, hidden, True)
|
||||
|
||||
url = reverse('ccx_coach_dashboard', kwargs={'course_id': course.id})
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@coach_dashboard
|
||||
def save_ccx(request, course):
|
||||
"""
|
||||
Save changes to CCX.
|
||||
"""
|
||||
ccx = get_ccx_for_coach(course, request.user)
|
||||
|
||||
def override_fields(parent, data, graded, earliest=None):
|
||||
"""
|
||||
Recursively apply CCX schedule data to CCX by overriding the
|
||||
`visible_to_staff_only`, `start` and `due` fields for units in the
|
||||
course.
|
||||
"""
|
||||
blocks = {
|
||||
str(child.location): child
|
||||
for child in parent.get_children()}
|
||||
for unit in data:
|
||||
block = blocks[unit['location']]
|
||||
override_field_for_ccx(
|
||||
ccx, block, 'visible_to_staff_only', unit['hidden'])
|
||||
start = parse_date(unit['start'])
|
||||
if start:
|
||||
if not earliest or start < earliest:
|
||||
earliest = start
|
||||
override_field_for_ccx(ccx, block, 'start', start)
|
||||
else:
|
||||
clear_override_for_ccx(ccx, block, 'start')
|
||||
due = parse_date(unit['due'])
|
||||
if due:
|
||||
override_field_for_ccx(ccx, block, 'due', due)
|
||||
else:
|
||||
clear_override_for_ccx(ccx, block, 'due')
|
||||
|
||||
if not unit['hidden'] and block.graded:
|
||||
graded[block.format] = graded.get(block.format, 0) + 1
|
||||
|
||||
children = unit.get('children', None)
|
||||
if children:
|
||||
override_fields(block, children, graded, earliest)
|
||||
return earliest
|
||||
|
||||
graded = {}
|
||||
earliest = override_fields(course, json.loads(request.body), graded)
|
||||
if earliest:
|
||||
override_field_for_ccx(ccx, course, 'start', earliest)
|
||||
|
||||
# Attempt to automatically adjust grading policy
|
||||
changed = False
|
||||
policy = get_override_for_ccx(
|
||||
ccx, course, 'grading_policy', course.grading_policy
|
||||
)
|
||||
policy = deepcopy(policy)
|
||||
grader = policy['GRADER']
|
||||
for section in grader:
|
||||
count = graded.get(section.get('type'), 0)
|
||||
if count < section['min_count']:
|
||||
changed = True
|
||||
section['min_count'] = count
|
||||
if changed:
|
||||
override_field_for_ccx(ccx, course, 'grading_policy', policy)
|
||||
|
||||
return HttpResponse(
|
||||
json.dumps({
|
||||
'schedule': get_ccx_schedule(course, ccx),
|
||||
'grading_policy': json.dumps(policy, indent=4)}),
|
||||
content_type='application/json',
|
||||
)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@coach_dashboard
|
||||
def set_grading_policy(request, course):
|
||||
"""
|
||||
Set grading policy for the CCX.
|
||||
"""
|
||||
ccx = get_ccx_for_coach(course, request.user)
|
||||
override_field_for_ccx(
|
||||
ccx, course, 'grading_policy', json.loads(request.POST['policy']))
|
||||
|
||||
url = reverse('ccx_coach_dashboard', kwargs={'course_id': course.id})
|
||||
return redirect(url)
|
||||
|
||||
|
||||
def validate_date(year, month, day, hour, minute):
|
||||
"""
|
||||
avoid corrupting db if bad dates come in
|
||||
"""
|
||||
valid = True
|
||||
if year < 0:
|
||||
valid = False
|
||||
if month < 1 or month > 12:
|
||||
valid = False
|
||||
if day < 1 or day > 31:
|
||||
valid = False
|
||||
if hour < 0 or hour > 23:
|
||||
valid = False
|
||||
if minute < 0 or minute > 59:
|
||||
valid = False
|
||||
return valid
|
||||
|
||||
|
||||
def parse_date(datestring):
|
||||
"""
|
||||
Generate a UTC datetime.datetime object from a string of the form
|
||||
'YYYY-MM-DD HH:MM'. If string is empty or `None`, returns `None`.
|
||||
"""
|
||||
if datestring:
|
||||
date, time = datestring.split(' ')
|
||||
year, month, day = map(int, date.split('-'))
|
||||
hour, minute = map(int, time.split(':'))
|
||||
if validate_date(year, month, day, hour, minute):
|
||||
return datetime.datetime(
|
||||
year, month, day, hour, minute, tzinfo=pytz.UTC)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_ccx_for_coach(course, coach):
|
||||
"""
|
||||
Looks to see if user is coach of a CCX for this course. Returns the CCX or
|
||||
None.
|
||||
"""
|
||||
try:
|
||||
return CustomCourseForEdX.objects.get(
|
||||
course_id=course.id,
|
||||
coach=coach)
|
||||
except CustomCourseForEdX.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
def get_ccx_schedule(course, ccx):
|
||||
"""
|
||||
Generate a JSON serializable CCX schedule.
|
||||
"""
|
||||
def visit(node, depth=1):
|
||||
"""
|
||||
Recursive generator function which yields CCX schedule nodes.
|
||||
We convert dates to string to get them ready for use by the js date
|
||||
widgets, which use text inputs.
|
||||
"""
|
||||
for child in node.get_children():
|
||||
start = get_override_for_ccx(ccx, child, 'start', None)
|
||||
if start:
|
||||
start = str(start)[:-9]
|
||||
due = get_override_for_ccx(ccx, child, 'due', None)
|
||||
if due:
|
||||
due = str(due)[:-9]
|
||||
hidden = get_override_for_ccx(
|
||||
ccx, child, 'visible_to_staff_only',
|
||||
child.visible_to_staff_only)
|
||||
visited = {
|
||||
'location': str(child.location),
|
||||
'display_name': child.display_name,
|
||||
'category': child.category,
|
||||
'start': start,
|
||||
'due': due,
|
||||
'hidden': hidden,
|
||||
}
|
||||
if depth < 3:
|
||||
children = tuple(visit(child, depth + 1))
|
||||
if children:
|
||||
visited['children'] = children
|
||||
yield visited
|
||||
else:
|
||||
yield visited
|
||||
|
||||
with disable_overrides():
|
||||
return tuple(visit(course))
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@coach_dashboard
|
||||
def ccx_schedule(request, course):
|
||||
"""
|
||||
get json representation of ccx schedule
|
||||
"""
|
||||
ccx = get_ccx_for_coach(course, request.user)
|
||||
schedule = get_ccx_schedule(course, ccx)
|
||||
json_schedule = json.dumps(schedule, indent=4)
|
||||
return HttpResponse(json_schedule, mimetype='application/json')
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@coach_dashboard
|
||||
def ccx_invite(request, course):
|
||||
"""
|
||||
Invite users to new ccx
|
||||
"""
|
||||
ccx = get_ccx_for_coach(course, request.user)
|
||||
action = request.POST.get('enrollment-button')
|
||||
identifiers_raw = request.POST.get('student-ids')
|
||||
identifiers = _split_input_list(identifiers_raw)
|
||||
auto_enroll = True if 'auto-enroll' in request.POST else False
|
||||
email_students = True if 'email-students' in request.POST else False
|
||||
for identifier in identifiers:
|
||||
user = None
|
||||
email = None
|
||||
try:
|
||||
user = get_student_from_identifier(identifier)
|
||||
except User.DoesNotExist:
|
||||
email = identifier
|
||||
else:
|
||||
email = user.email
|
||||
try:
|
||||
validate_email(email)
|
||||
if action == 'Enroll':
|
||||
enroll_email(
|
||||
ccx,
|
||||
email,
|
||||
auto_enroll=auto_enroll,
|
||||
email_students=email_students
|
||||
)
|
||||
if action == "Unenroll":
|
||||
unenroll_email(ccx, email, email_students=email_students)
|
||||
except ValidationError:
|
||||
log.info('Invalid user name or email when trying to invite students: %s', email)
|
||||
url = reverse('ccx_coach_dashboard', kwargs={'course_id': course.id})
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@ensure_csrf_cookie
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@coach_dashboard
|
||||
def ccx_student_management(request, course):
|
||||
"""Manage the enrollment of individual students in a CCX
|
||||
"""
|
||||
ccx = get_ccx_for_coach(course, request.user)
|
||||
action = request.POST.get('student-action', None)
|
||||
student_id = request.POST.get('student-id', '')
|
||||
user = email = None
|
||||
try:
|
||||
user = get_student_from_identifier(student_id)
|
||||
except User.DoesNotExist:
|
||||
email = student_id
|
||||
else:
|
||||
email = user.email
|
||||
|
||||
try:
|
||||
validate_email(email)
|
||||
if action == 'add':
|
||||
# by decree, no emails sent to students added this way
|
||||
# by decree, any students added this way are auto_enrolled
|
||||
enroll_email(ccx, email, auto_enroll=True, email_students=False)
|
||||
elif action == 'revoke':
|
||||
unenroll_email(ccx, email, email_students=False)
|
||||
except ValidationError:
|
||||
log.info('Invalid user name or email when trying to enroll student: %s', email)
|
||||
|
||||
url = reverse('ccx_coach_dashboard', kwargs={'course_id': course.id})
|
||||
return redirect(url)
|
||||
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@coach_dashboard
|
||||
def ccx_gradebook(request, course):
|
||||
"""
|
||||
Show the gradebook for this CCX.
|
||||
"""
|
||||
# Need course module for overrides to function properly
|
||||
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
|
||||
course.id, request.user, course, depth=2)
|
||||
course = get_module_for_descriptor(
|
||||
request.user, request, course, field_data_cache, course.id)
|
||||
|
||||
ccx = get_ccx_for_coach(course, request.user)
|
||||
with ccx_context(ccx):
|
||||
# The grading policy for the MOOC is probably already cached. We need
|
||||
# to make sure we have the CCX grading policy loaded.
|
||||
course._field_data_cache = {} # pylint: disable=protected-access
|
||||
course.set_grading_policy(course.grading_policy)
|
||||
|
||||
enrolled_students = User.objects.filter(
|
||||
ccxmembership__ccx=ccx,
|
||||
ccxmembership__active=1
|
||||
).order_by('username').select_related("profile")
|
||||
|
||||
student_info = [
|
||||
{
|
||||
'username': student.username,
|
||||
'id': student.id,
|
||||
'email': student.email,
|
||||
'grade_summary': student_grades(student, request, course),
|
||||
'realname': student.profile.name,
|
||||
}
|
||||
for student in enrolled_students
|
||||
]
|
||||
|
||||
return render_to_response('courseware/gradebook.html', {
|
||||
'students': student_info,
|
||||
'course': course,
|
||||
'course_id': course.id,
|
||||
'staff_access': request.user.is_staff,
|
||||
'ordered_grades': sorted(
|
||||
course.grade_cutoffs.items(), key=lambda i: i[1], reverse=True),
|
||||
})
|
||||
|
||||
|
||||
@cache_control(no_cache=True, no_store=True, must_revalidate=True)
|
||||
@coach_dashboard
|
||||
def ccx_grades_csv(request, course):
|
||||
"""
|
||||
Download grades as CSV.
|
||||
"""
|
||||
# Need course module for overrides to function properly
|
||||
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
|
||||
course.id, request.user, course, depth=2)
|
||||
course = get_module_for_descriptor(
|
||||
request.user, request, course, field_data_cache, course.id)
|
||||
ccx = get_ccx_for_coach(course, request.user)
|
||||
with ccx_context(ccx):
|
||||
# The grading policy for the MOOC is probably already cached. We need
|
||||
# to make sure we have the CCX grading policy loaded.
|
||||
course._field_data_cache = {} # pylint: disable=protected-access
|
||||
course.set_grading_policy(course.grading_policy)
|
||||
|
||||
enrolled_students = User.objects.filter(
|
||||
ccxmembership__ccx=ccx,
|
||||
ccxmembership__active=1
|
||||
).order_by('username').select_related("profile")
|
||||
grades = iterate_grades_for(course, enrolled_students)
|
||||
|
||||
header = None
|
||||
rows = []
|
||||
for student, gradeset, __ in grades:
|
||||
if gradeset:
|
||||
# We were able to successfully grade this student for this
|
||||
# course.
|
||||
if not header:
|
||||
# Encode the header row in utf-8 encoding in case there are
|
||||
# unicode characters
|
||||
header = [section['label'].encode('utf-8')
|
||||
for section in gradeset[u'section_breakdown']]
|
||||
rows.append(["id", "email", "username", "grade"] + header)
|
||||
|
||||
percents = {
|
||||
section['label']: section.get('percent', 0.0)
|
||||
for section in gradeset[u'section_breakdown']
|
||||
if 'label' in section
|
||||
}
|
||||
|
||||
row_percents = [percents.get(label, 0.0) for label in header]
|
||||
rows.append([student.id, student.email, student.username,
|
||||
gradeset['percent']] + row_percents)
|
||||
|
||||
buf = StringIO()
|
||||
writer = csv.writer(buf)
|
||||
for row in rows:
|
||||
writer.writerow(row)
|
||||
|
||||
return HttpResponse(buf.getvalue(), content_type='text/plain')
|
||||
|
||||
|
||||
@login_required
|
||||
def switch_active_ccx(request, course_id, ccx_id=None):
|
||||
"""set the active CCX for the logged-in user
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
# will raise Http404 if course_id is bad
|
||||
course = get_course_by_id(course_key)
|
||||
course_url = reverse(
|
||||
'course_root', args=[course.id.to_deprecated_string()]
|
||||
)
|
||||
if ccx_id is not None:
|
||||
try:
|
||||
requested_ccx = CustomCourseForEdX.objects.get(pk=ccx_id)
|
||||
assert unicode(requested_ccx.course_id) == course_id
|
||||
if not CcxMembership.objects.filter(
|
||||
ccx=requested_ccx, student=request.user, active=True
|
||||
).exists():
|
||||
ccx_id = None
|
||||
except CustomCourseForEdX.DoesNotExist:
|
||||
# what to do here? Log the failure? Do we care?
|
||||
ccx_id = None
|
||||
except AssertionError:
|
||||
# what to do here? Log the failure? Do we care?
|
||||
ccx_id = None
|
||||
|
||||
request.session[ACTIVE_CCX_KEY] = ccx_id
|
||||
|
||||
return HttpResponseRedirect(course_url)
|
||||
204
lms/djangoapps/courseware/field_overrides.py
Normal file
204
lms/djangoapps/courseware/field_overrides.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
This module provides a :class:`~xblock.field_data.FieldData` implementation
|
||||
which wraps an other `FieldData` object and provides overrides based on the
|
||||
user. The use of providers allows for overrides that are arbitrarily
|
||||
extensible. One provider is found in `courseware.student_field_overrides`
|
||||
which allows for fields to be overridden for individual students. One can
|
||||
envision other providers being written that allow for fields to be overridden
|
||||
base on membership of a student in a cohort, or similar. The use of an
|
||||
extensible, modular architecture allows for overrides being done in ways not
|
||||
envisioned by the authors.
|
||||
|
||||
Currently, this module is used in the `module_render` module in this same
|
||||
package and is used to wrap the `authored_data` when constructing an
|
||||
`LmsFieldData`. This means overrides will be in effect for all scopes covered
|
||||
by `authored_data`, e.g. course content and settings stored in Mongo.
|
||||
"""
|
||||
import threading
|
||||
|
||||
from abc import ABCMeta, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from django.conf import settings
|
||||
from xblock.field_data import FieldData
|
||||
from xmodule.modulestore.inheritance import InheritanceMixin
|
||||
|
||||
|
||||
NOTSET = object()
|
||||
|
||||
|
||||
def resolve_dotted(name):
|
||||
"""
|
||||
Given the dotted name for a Python object, performs any necessary imports
|
||||
and returns the object.
|
||||
"""
|
||||
names = name.split('.')
|
||||
path = names.pop(0)
|
||||
target = __import__(path)
|
||||
while names:
|
||||
segment = names.pop(0)
|
||||
path += '.' + segment
|
||||
try:
|
||||
target = getattr(target, segment)
|
||||
except AttributeError:
|
||||
__import__(path)
|
||||
target = getattr(target, segment)
|
||||
return target
|
||||
|
||||
|
||||
class OverrideFieldData(FieldData):
|
||||
"""
|
||||
A :class:`~xblock.field_data.FieldData` which wraps another `FieldData`
|
||||
object and allows for fields handled by the wrapped `FieldData` to be
|
||||
overriden by arbitrary providers.
|
||||
|
||||
Providers are configured by use of the Django setting,
|
||||
`FIELD_OVERRIDE_PROVIDERS` which should be a tuple of dotted names of
|
||||
:class:`FieldOverrideProvider` concrete implementations. Note that order
|
||||
is important for this setting. Override providers will tried in the order
|
||||
configured in the setting. The first provider to find an override 'wins'
|
||||
for a particular field lookup.
|
||||
"""
|
||||
provider_classes = None
|
||||
|
||||
@classmethod
|
||||
def wrap(cls, user, wrapped):
|
||||
"""
|
||||
Will return a :class:`OverrideFieldData` which wraps the field data
|
||||
given in `wrapped` for the given `user`, if override providers are
|
||||
configred. If no override providers are configured, using the Django
|
||||
setting, `FIELD_OVERRIDE_PROVIDERS`, returns `wrapped`, eliminating
|
||||
any performance impact of this feature if no override providers are
|
||||
configured.
|
||||
"""
|
||||
if cls.provider_classes is None:
|
||||
cls.provider_classes = tuple(
|
||||
(resolve_dotted(name) for name in
|
||||
settings.FIELD_OVERRIDE_PROVIDERS))
|
||||
|
||||
if cls.provider_classes:
|
||||
return cls(user, wrapped)
|
||||
|
||||
return wrapped
|
||||
|
||||
def __init__(self, user, fallback):
|
||||
self.fallback = fallback
|
||||
self.providers = tuple((cls(user) for cls in self.provider_classes))
|
||||
|
||||
def get_override(self, block, name):
|
||||
"""
|
||||
Checks for an override for the field identified by `name` in `block`.
|
||||
Returns the overridden value or `NOTSET` if no override is found.
|
||||
"""
|
||||
if not overrides_disabled():
|
||||
for provider in self.providers:
|
||||
value = provider.get(block, name, NOTSET)
|
||||
if value is not NOTSET:
|
||||
return value
|
||||
return NOTSET
|
||||
|
||||
def get(self, block, name):
|
||||
value = self.get_override(block, name)
|
||||
if value is not NOTSET:
|
||||
return value
|
||||
return self.fallback.get(block, name)
|
||||
|
||||
def set(self, block, name, value):
|
||||
self.fallback.set(block, name, value)
|
||||
|
||||
def delete(self, block, name):
|
||||
self.fallback.delete(block, name)
|
||||
|
||||
def has(self, block, name):
|
||||
has = self.get_override(block, name)
|
||||
if has is NOTSET:
|
||||
# If this is an inheritable field and an override is set above,
|
||||
# then we want to return False here, so the field_data uses the
|
||||
# override and not the original value for this block.
|
||||
inheritable = InheritanceMixin.fields.keys()
|
||||
if name in inheritable:
|
||||
for ancestor in _lineage(block):
|
||||
if self.get_override(ancestor, name) is not NOTSET:
|
||||
return False
|
||||
|
||||
return has is not NOTSET or self.fallback.has(block, name)
|
||||
|
||||
def set_many(self, block, update_dict):
|
||||
return self.fallback.set_many(block, update_dict)
|
||||
|
||||
def default(self, block, name):
|
||||
# The `default` method is overloaded by the field storage system to
|
||||
# also handle inheritance.
|
||||
if not overrides_disabled():
|
||||
inheritable = InheritanceMixin.fields.keys()
|
||||
if name in inheritable:
|
||||
for ancestor in _lineage(block):
|
||||
value = self.get_override(ancestor, name)
|
||||
if value is not NOTSET:
|
||||
return value
|
||||
return self.fallback.default(block, name)
|
||||
|
||||
|
||||
class _OverridesDisabled(threading.local):
|
||||
"""
|
||||
A thread local used to manage state of overrides being disabled or not.
|
||||
"""
|
||||
disabled = ()
|
||||
|
||||
|
||||
_OVERRIDES_DISABLED = _OverridesDisabled()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def disable_overrides():
|
||||
"""
|
||||
A context manager which disables field overrides inside the context of a
|
||||
`with` statement, allowing code to get at the `original` value of a field.
|
||||
"""
|
||||
prev = _OVERRIDES_DISABLED.disabled
|
||||
_OVERRIDES_DISABLED.disabled += (True,)
|
||||
yield
|
||||
_OVERRIDES_DISABLED.disabled = prev
|
||||
|
||||
|
||||
def overrides_disabled():
|
||||
"""
|
||||
Checks to see whether overrides are disabled in the current context.
|
||||
Returns a boolean value. See `disable_overrides`.
|
||||
"""
|
||||
return bool(_OVERRIDES_DISABLED.disabled)
|
||||
|
||||
|
||||
class FieldOverrideProvider(object):
|
||||
"""
|
||||
Abstract class which defines the interface that a `FieldOverrideProvider`
|
||||
must provide. In general, providers should derive from this class, but
|
||||
it's not strictly necessary as long as they correctly implement this
|
||||
interface.
|
||||
|
||||
A `FieldOverrideProvider` implementation is only responsible for looking up
|
||||
field overrides. To set overrides, there will be a domain specific API for
|
||||
the concrete override implementation being used.
|
||||
"""
|
||||
__metaclass__ = ABCMeta
|
||||
|
||||
def __init__(self, user):
|
||||
self.user = user
|
||||
|
||||
@abstractmethod
|
||||
def get(self, block, name, default): # pragma no cover
|
||||
"""
|
||||
Look for an override value for the field named `name` in `block`.
|
||||
Returns the overridden value or `default` if no override is found.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def _lineage(block):
|
||||
"""
|
||||
Returns an iterator over all ancestors of the given block, starting with
|
||||
its immediate parent and ending at the root of the block tree.
|
||||
"""
|
||||
parent = block.get_parent()
|
||||
while parent:
|
||||
yield parent
|
||||
parent = parent.get_parent()
|
||||
@@ -20,11 +20,11 @@ from xmodule import graders
|
||||
from xmodule.graders import Score
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.util.duedate import get_extended_due_date
|
||||
from .models import StudentModule
|
||||
from .module_render import get_module_for_descriptor
|
||||
from submissions import api as sub_api # installed from the edx-submissions repository
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
|
||||
log = logging.getLogger("edx.courseware")
|
||||
@@ -247,6 +247,8 @@ def _grade(student, request, course, keep_raw_scores):
|
||||
|
||||
totaled_scores[section_format] = format_scores
|
||||
|
||||
# Grading policy might be overriden by a CCX, need to reset it
|
||||
course.set_grading_policy(course.grading_policy)
|
||||
grade_summary = course.grader.grade(totaled_scores, generate_random_scores=settings.GENERATE_PROFILE_SCORES)
|
||||
|
||||
# We round the grade here, to make sure that the grade is an whole percentage and
|
||||
@@ -330,6 +332,8 @@ def _progress_summary(student, request, course):
|
||||
# This student must not have access to the course.
|
||||
return None
|
||||
|
||||
course_module = getattr(course_module, '_x_module', course_module)
|
||||
|
||||
submissions_scores = sub_api.get_scores(course.id.to_deprecated_string(), anonymous_id_for_user(student, course.id))
|
||||
|
||||
chapters = []
|
||||
@@ -373,7 +377,7 @@ def _progress_summary(student, request, course):
|
||||
'scores': scores,
|
||||
'section_total': section_total,
|
||||
'format': module_format,
|
||||
'due': get_extended_due_date(section_module),
|
||||
'due': section_module.due,
|
||||
'graded': graded,
|
||||
})
|
||||
|
||||
@@ -480,7 +484,7 @@ def manual_transaction():
|
||||
transaction.commit()
|
||||
|
||||
|
||||
def iterate_grades_for(course_id, students):
|
||||
def iterate_grades_for(course_or_id, students):
|
||||
"""Given a course_id and an iterable of students (User), yield a tuple of:
|
||||
|
||||
(student, gradeset, err_msg) for every student enrolled in the course.
|
||||
@@ -498,7 +502,10 @@ def iterate_grades_for(course_id, students):
|
||||
make up the final grade. (For display)
|
||||
- raw_scores: contains scores for every graded module
|
||||
"""
|
||||
course = courses.get_course_by_id(course_id)
|
||||
if isinstance(course_or_id, (basestring, CourseKey)):
|
||||
course = courses.get_course_by_id(course_or_id)
|
||||
else:
|
||||
course = course_or_id
|
||||
|
||||
# We make a fake request because grading code expects to be able to look at
|
||||
# the request. We have to attach the correct user to the request before
|
||||
@@ -506,7 +513,7 @@ def iterate_grades_for(course_id, students):
|
||||
request = RequestFactory().get('/')
|
||||
|
||||
for student in students:
|
||||
with dog_stats_api.timer('lms.grades.iterate_grades_for', tags=[u'action:{}'.format(course_id)]):
|
||||
with dog_stats_api.timer('lms.grades.iterate_grades_for', tags=[u'action:{}'.format(course.id)]):
|
||||
try:
|
||||
request.user = student
|
||||
# Grading calls problem rendering, which calls masquerading,
|
||||
@@ -523,7 +530,7 @@ def iterate_grades_for(course_id, students):
|
||||
'Cannot grade student %s (%s) in course %s because of exception: %s',
|
||||
student.username,
|
||||
student.id,
|
||||
course_id,
|
||||
course.id,
|
||||
exc.message
|
||||
)
|
||||
yield student, {}, exc.message
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# pylint: disable=invalid-name, missing-docstring, unused-argument, unused-import, line-too-long
|
||||
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding model 'StudentFieldOverride'
|
||||
db.create_table('courseware_studentfieldoverride', (
|
||||
('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
|
||||
('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)),
|
||||
('location', self.gf('xmodule_django.models.LocationKeyField')(max_length=255, db_index=True)),
|
||||
('student', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
|
||||
('field', self.gf('django.db.models.fields.CharField')(max_length=255)),
|
||||
('value', self.gf('django.db.models.fields.TextField')(default='null')),
|
||||
))
|
||||
db.send_create_signal('courseware', ['StudentFieldOverride'])
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting model 'StudentFieldOverride'
|
||||
db.delete_table('courseware_studentfieldoverride')
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'courseware.offlinecomputedgrade': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'},
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'courseware.offlinecomputedgradelog': {
|
||||
'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'},
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
|
||||
'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'})
|
||||
},
|
||||
'courseware.studentfieldoverride': {
|
||||
'Meta': {'unique_together': "(('course_id', 'location', 'student'),)", 'object_name': 'StudentFieldOverride'},
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'field': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'location': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
|
||||
'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
|
||||
},
|
||||
'courseware.studentmodule': {
|
||||
'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'},
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}),
|
||||
'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'module_state_key': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}),
|
||||
'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}),
|
||||
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'courseware.studentmodulehistory': {
|
||||
'Meta': {'object_name': 'StudentModuleHistory'},
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
|
||||
'grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'student_module': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['courseware.StudentModule']"}),
|
||||
'version': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'null': 'True', 'blank': 'True'})
|
||||
},
|
||||
'courseware.xmodulestudentinfofield': {
|
||||
'Meta': {'unique_together': "(('student', 'field_name'),)", 'object_name': 'XModuleStudentInfoField'},
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
|
||||
'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
|
||||
},
|
||||
'courseware.xmodulestudentprefsfield': {
|
||||
'Meta': {'unique_together': "(('student', 'module_type', 'field_name'),)", 'object_name': 'XModuleStudentPrefsField'},
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'module_type': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
|
||||
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
|
||||
'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
|
||||
},
|
||||
'courseware.xmoduleuserstatesummaryfield': {
|
||||
'Meta': {'unique_together': "(('usage_id', 'field_name'),)", 'object_name': 'XModuleUserStateSummaryField'},
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'usage_id': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['courseware']
|
||||
@@ -0,0 +1,138 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# pylint: disable=invalid-name, missing-docstring, unused-argument, unused-import, line-too-long
|
||||
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
|
||||
# Adding unique constraint on 'StudentFieldOverride', fields ['course_id', 'field', 'location', 'student']
|
||||
db.create_unique('courseware_studentfieldoverride', ['course_id', 'field', 'location', 'student_id'])
|
||||
|
||||
def backwards(self, orm):
|
||||
# Removing unique constraint on 'StudentFieldOverride', fields ['course_id', 'field', 'location', 'student']
|
||||
db.delete_unique('courseware_studentfieldoverride', ['course_id', 'field', 'location', 'student_id'])
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'courseware.offlinecomputedgrade': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'},
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'courseware.offlinecomputedgradelog': {
|
||||
'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'},
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
|
||||
'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'})
|
||||
},
|
||||
'courseware.studentfieldoverride': {
|
||||
'Meta': {'unique_together': "(('course_id', 'field', 'location', 'student'),)", 'object_name': 'StudentFieldOverride'},
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'field': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'location': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
|
||||
'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
|
||||
},
|
||||
'courseware.studentmodule': {
|
||||
'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'},
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}),
|
||||
'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'module_state_key': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}),
|
||||
'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}),
|
||||
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'courseware.studentmodulehistory': {
|
||||
'Meta': {'object_name': 'StudentModuleHistory'},
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
|
||||
'grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'student_module': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['courseware.StudentModule']"}),
|
||||
'version': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'null': 'True', 'blank': 'True'})
|
||||
},
|
||||
'courseware.xmodulestudentinfofield': {
|
||||
'Meta': {'unique_together': "(('student', 'field_name'),)", 'object_name': 'XModuleStudentInfoField'},
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
|
||||
'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
|
||||
},
|
||||
'courseware.xmodulestudentprefsfield': {
|
||||
'Meta': {'unique_together': "(('student', 'module_type', 'field_name'),)", 'object_name': 'XModuleStudentPrefsField'},
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'module_type': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
|
||||
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
|
||||
'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
|
||||
},
|
||||
'courseware.xmoduleuserstatesummaryfield': {
|
||||
'Meta': {'unique_together': "(('usage_id', 'field_name'),)", 'object_name': 'XModuleUserStateSummaryField'},
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'usage_id': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['courseware']
|
||||
@@ -0,0 +1,155 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# pylint: disable=invalid-name, missing-docstring, unused-argument, unused-import, line-too-long
|
||||
|
||||
import datetime
|
||||
from south.db import db
|
||||
from south.v2 import SchemaMigration
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Migration(SchemaMigration):
|
||||
|
||||
def forwards(self, orm):
|
||||
# Adding field 'StudentFieldOverride.created'
|
||||
db.add_column('courseware_studentfieldoverride', 'created',
|
||||
self.gf('model_utils.fields.AutoCreatedField')(default=datetime.datetime.now),
|
||||
keep_default=False)
|
||||
|
||||
# Adding field 'StudentFieldOverride.modified'
|
||||
db.add_column('courseware_studentfieldoverride', 'modified',
|
||||
self.gf('model_utils.fields.AutoLastModifiedField')(default=datetime.datetime.now),
|
||||
keep_default=False)
|
||||
|
||||
# Adding index on 'StudentFieldOverride', fields ['course_id', 'location', 'student']
|
||||
db.create_index('courseware_studentfieldoverride', ['course_id', 'location', 'student_id'])
|
||||
|
||||
def backwards(self, orm):
|
||||
# Deleting field 'StudentFieldOverride.created'
|
||||
db.delete_column('courseware_studentfieldoverride', 'created')
|
||||
|
||||
# Deleting field 'StudentFieldOverride.modified'
|
||||
db.delete_column('courseware_studentfieldoverride', 'modified')
|
||||
|
||||
# Removing index on 'StudentFieldOverride', fields ['course_id', 'location', 'student']
|
||||
db.delete_index('courseware_studentfieldoverride', ['course_id', 'location', 'student_id'])
|
||||
|
||||
models = {
|
||||
'auth.group': {
|
||||
'Meta': {'object_name': 'Group'},
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
|
||||
'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
|
||||
},
|
||||
'auth.permission': {
|
||||
'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
|
||||
'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
|
||||
},
|
||||
'auth.user': {
|
||||
'Meta': {'object_name': 'User'},
|
||||
'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
|
||||
'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
|
||||
'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
|
||||
'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
|
||||
'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
|
||||
'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
|
||||
'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
|
||||
'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
|
||||
},
|
||||
'contenttypes.contenttype': {
|
||||
'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
|
||||
'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
|
||||
'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
|
||||
},
|
||||
'courseware.offlinecomputedgrade': {
|
||||
'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'OfflineComputedGrade'},
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'gradeset': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'updated': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'courseware.offlinecomputedgradelog': {
|
||||
'Meta': {'ordering': "['-created']", 'object_name': 'OfflineComputedGradeLog'},
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'nstudents': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
|
||||
'seconds': ('django.db.models.fields.IntegerField', [], {'default': '0'})
|
||||
},
|
||||
'courseware.studentfieldoverride': {
|
||||
'Meta': {'unique_together': "(('course_id', 'field', 'location', 'student'),)", 'object_name': 'StudentFieldOverride'},
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('model_utils.fields.AutoCreatedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'field': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'location': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'modified': ('model_utils.fields.AutoLastModifiedField', [], {'default': 'datetime.datetime.now'}),
|
||||
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
|
||||
'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
|
||||
},
|
||||
'courseware.studentmodule': {
|
||||
'Meta': {'unique_together': "(('student', 'module_state_key', 'course_id'),)", 'object_name': 'StudentModule'},
|
||||
'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'done': ('django.db.models.fields.CharField', [], {'default': "'na'", 'max_length': '8', 'db_index': 'True'}),
|
||||
'grade': ('django.db.models.fields.FloatField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'module_state_key': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_column': "'module_id'", 'db_index': 'True'}),
|
||||
'module_type': ('django.db.models.fields.CharField', [], {'default': "'problem'", 'max_length': '32', 'db_index': 'True'}),
|
||||
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
|
||||
},
|
||||
'courseware.studentmodulehistory': {
|
||||
'Meta': {'object_name': 'StudentModuleHistory'},
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}),
|
||||
'grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'max_grade': ('django.db.models.fields.FloatField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'state': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}),
|
||||
'student_module': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['courseware.StudentModule']"}),
|
||||
'version': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'null': 'True', 'blank': 'True'})
|
||||
},
|
||||
'courseware.xmodulestudentinfofield': {
|
||||
'Meta': {'unique_together': "(('student', 'field_name'),)", 'object_name': 'XModuleStudentInfoField'},
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
|
||||
'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
|
||||
},
|
||||
'courseware.xmodulestudentprefsfield': {
|
||||
'Meta': {'unique_together': "(('student', 'module_type', 'field_name'),)", 'object_name': 'XModuleStudentPrefsField'},
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'module_type': ('xmodule_django.models.BlockTypeKeyField', [], {'max_length': '64', 'db_index': 'True'}),
|
||||
'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
|
||||
'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
|
||||
},
|
||||
'courseware.xmoduleuserstatesummaryfield': {
|
||||
'Meta': {'unique_together': "(('usage_id', 'field_name'),)", 'object_name': 'XModuleUserStateSummaryField'},
|
||||
'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'field_name': ('django.db.models.fields.CharField', [], {'max_length': '64', 'db_index': 'True'}),
|
||||
'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
|
||||
'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}),
|
||||
'usage_id': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}),
|
||||
'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
|
||||
}
|
||||
}
|
||||
|
||||
complete_apps = ['courseware']
|
||||
@@ -18,7 +18,9 @@ from django.db import models
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from xmodule_django.models import CourseKeyField, LocationKeyField, BlockTypeKeyField
|
||||
from model_utils.models import TimeStampedModel
|
||||
|
||||
from xmodule_django.models import CourseKeyField, LocationKeyField, BlockTypeKeyField # pylint: disable=import-error
|
||||
|
||||
|
||||
class StudentModule(models.Model):
|
||||
@@ -35,8 +37,7 @@ class StudentModule(models.Model):
|
||||
('course', 'course'),
|
||||
('chapter', 'Section'),
|
||||
('sequential', 'Subsection'),
|
||||
('library_content', 'Library Content'),
|
||||
)
|
||||
('library_content', 'Library Content'))
|
||||
## These three are the key for the object
|
||||
module_type = models.CharField(max_length=32, choices=MODULE_TYPES, default='problem', db_index=True)
|
||||
|
||||
@@ -86,7 +87,7 @@ class StudentModule(models.Model):
|
||||
return 'StudentModule<%r>' % ({
|
||||
'course_id': self.course_id,
|
||||
'module_type': self.module_type,
|
||||
'student': self.student.username,
|
||||
'student': self.student.username, # pylint: disable=no-member
|
||||
'module_state_key': self.module_state_key,
|
||||
'state': str(self.state)[:20],
|
||||
},)
|
||||
@@ -230,3 +231,20 @@ class OfflineComputedGradeLog(models.Model):
|
||||
|
||||
def __unicode__(self):
|
||||
return "[OCGLog] %s: %s" % (self.course_id.to_deprecated_string(), self.created) # pylint: disable=no-member
|
||||
|
||||
|
||||
class StudentFieldOverride(TimeStampedModel):
|
||||
"""
|
||||
Holds the value of a specific field overriden for a student. This is used
|
||||
by the code in the `courseware.student_field_overrides` module to provide
|
||||
overrides of xblock fields on a per user basis.
|
||||
"""
|
||||
course_id = CourseKeyField(max_length=255, db_index=True)
|
||||
location = LocationKeyField(max_length=255, db_index=True)
|
||||
student = models.ForeignKey(User, db_index=True)
|
||||
|
||||
class Meta(object): # pylint: disable=missing-docstring
|
||||
unique_together = (('course_id', 'field', 'location', 'student'),)
|
||||
|
||||
field = models.CharField(max_length=255)
|
||||
value = models.TextField(default='null')
|
||||
|
||||
@@ -54,7 +54,6 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
|
||||
from xmodule.contentstore.django import contentstore
|
||||
from xmodule.modulestore.django import modulestore, ModuleI18nService
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
from xmodule.util.duedate import get_extended_due_date
|
||||
from xmodule_modifiers import (
|
||||
replace_course_urls,
|
||||
replace_jump_to_id_urls,
|
||||
@@ -72,6 +71,8 @@ from util import milestones_helpers
|
||||
from util.module_utils import yield_dynamic_descriptor_descendents
|
||||
from verify_student.services import ReverificationService
|
||||
|
||||
from .field_overrides import OverrideFieldData
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -171,7 +172,7 @@ def toc_for_course(request, course, active_chapter, active_section, field_data_c
|
||||
sections.append({'display_name': section.display_name_with_default,
|
||||
'url_name': section.url_name,
|
||||
'format': section.format if section.format is not None else '',
|
||||
'due': get_extended_due_date(section),
|
||||
'due': section.due,
|
||||
'active': active,
|
||||
'graded': section.graded,
|
||||
})
|
||||
@@ -497,11 +498,17 @@ def get_module_system_for_user(user, field_data_cache,
|
||||
request_token=request_token
|
||||
)
|
||||
# rebinds module to a different student. We'll change system, student_data, and scope_ids
|
||||
authored_data = OverrideFieldData.wrap(
|
||||
real_user, module.descriptor._field_data # pylint: disable=protected-access
|
||||
)
|
||||
module.descriptor.bind_for_student(
|
||||
inner_system,
|
||||
LmsFieldData(module.descriptor._field_data, inner_student_data), # pylint: disable=protected-access
|
||||
LmsFieldData(authored_data, inner_student_data),
|
||||
real_user.id,
|
||||
)
|
||||
module.descriptor.scope_ids = (
|
||||
module.descriptor.scope_ids._replace(user_id=real_user.id) # pylint: disable=protected-access
|
||||
)
|
||||
module.scope_ids = module.descriptor.scope_ids # this is needed b/c NamedTuples are immutable
|
||||
# now bind the module to the new ModuleSystem instance and vice-versa
|
||||
module.runtime = inner_system
|
||||
@@ -670,13 +677,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
|
||||
request_token (str): A unique token for this request, used to isolate xblock rendering
|
||||
"""
|
||||
|
||||
# Do not check access when it's a noauth request.
|
||||
if getattr(user, 'known', True):
|
||||
# Short circuit--if the user shouldn't have access, bail without doing any work
|
||||
if not has_access(user, 'load', descriptor, course_id):
|
||||
return None
|
||||
|
||||
(system, field_data) = get_module_system_for_user(
|
||||
(system, student_data) = get_module_system_for_user(
|
||||
user=user,
|
||||
field_data_cache=field_data_cache, # These have implicit user bindings, the rest of args are considered not to
|
||||
descriptor=descriptor,
|
||||
@@ -691,7 +692,18 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
|
||||
request_token=request_token
|
||||
)
|
||||
|
||||
descriptor.bind_for_student(system, field_data, user.id) # pylint: disable=protected-access
|
||||
authored_data = OverrideFieldData.wrap(user, descriptor._field_data) # pylint: disable=protected-access
|
||||
descriptor.bind_for_student(system, LmsFieldData(authored_data, student_data), user.id)
|
||||
descriptor.scope_ids = descriptor.scope_ids._replace(user_id=user.id) # pylint: disable=protected-access
|
||||
|
||||
# Do not check access when it's a noauth request.
|
||||
# Not that the access check needs to happen after the descriptor is bound
|
||||
# for the student, since there may be field override data for the student
|
||||
# that affects xblock visibility.
|
||||
if getattr(user, 'known', True):
|
||||
if not has_access(user, 'load', descriptor, course_id):
|
||||
return None
|
||||
|
||||
return descriptor
|
||||
|
||||
|
||||
|
||||
84
lms/djangoapps/courseware/student_field_overrides.py
Normal file
84
lms/djangoapps/courseware/student_field_overrides.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
API related to providing field overrides for individual students. This is used
|
||||
by the individual due dates feature.
|
||||
"""
|
||||
import json
|
||||
|
||||
from .field_overrides import FieldOverrideProvider
|
||||
from .models import StudentFieldOverride
|
||||
|
||||
|
||||
class IndividualStudentOverrideProvider(FieldOverrideProvider):
|
||||
"""
|
||||
A concrete implementation of
|
||||
:class:`~courseware.field_overrides.FieldOverrideProvider` which allows for
|
||||
overrides to be made on a per user basis.
|
||||
"""
|
||||
def get(self, block, name, default):
|
||||
return get_override_for_user(self.user, block, name, default)
|
||||
|
||||
|
||||
def get_override_for_user(user, block, name, default=None):
|
||||
"""
|
||||
Gets the value of the overridden field for the `user`. `block` and `name`
|
||||
specify the block and the name of the field. If the field is not
|
||||
overridden for the given user, returns `default`.
|
||||
"""
|
||||
if not hasattr(block, '_student_overrides'):
|
||||
block._student_overrides = {} # pylint: disable=protected-access
|
||||
overrides = block._student_overrides.get(user.id) # pylint: disable=protected-access
|
||||
if overrides is None:
|
||||
overrides = _get_overrides_for_user(user, block)
|
||||
block._student_overrides[user.id] = overrides # pylint: disable=protected-access
|
||||
return overrides.get(name, default)
|
||||
|
||||
|
||||
def _get_overrides_for_user(user, block):
|
||||
"""
|
||||
Gets all of the individual student overrides for given user and block.
|
||||
Returns a dictionary of field override values keyed by field name.
|
||||
"""
|
||||
query = StudentFieldOverride.objects.filter(
|
||||
course_id=block.runtime.course_id,
|
||||
location=block.location,
|
||||
student_id=user.id,
|
||||
)
|
||||
overrides = {}
|
||||
for override in query:
|
||||
field = block.fields[override.field]
|
||||
value = field.from_json(json.loads(override.value))
|
||||
overrides[override.field] = value
|
||||
return overrides
|
||||
|
||||
|
||||
def override_field_for_user(user, block, name, value):
|
||||
"""
|
||||
Overrides a field for the `user`. `block` and `name` specify the block
|
||||
and the name of the field on that block to override. `value` is the
|
||||
value to set for the given field.
|
||||
"""
|
||||
override, _ = StudentFieldOverride.objects.get_or_create(
|
||||
course_id=block.runtime.course_id,
|
||||
location=block.location,
|
||||
student_id=user.id,
|
||||
field=name)
|
||||
field = block.fields[name]
|
||||
override.value = json.dumps(field.to_json(value))
|
||||
override.save()
|
||||
|
||||
|
||||
def clear_override_for_user(user, block, name):
|
||||
"""
|
||||
Clears a previously set field override for the `user`. `block` and `name`
|
||||
specify the block and the name of the field on that block to clear.
|
||||
This function is idempotent--if no override is set, nothing action is
|
||||
performed.
|
||||
"""
|
||||
try:
|
||||
StudentFieldOverride.objects.get(
|
||||
course_id=block.runtime.course_id,
|
||||
student_id=user.id,
|
||||
location=block.location,
|
||||
field=name).delete()
|
||||
except StudentFieldOverride.DoesNotExist:
|
||||
pass
|
||||
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'
|
||||
123
lms/djangoapps/courseware/tests/test_field_overrides.py
Normal file
123
lms/djangoapps/courseware/tests/test_field_overrides.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Tests for `field_overrides` module.
|
||||
"""
|
||||
import unittest
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from xblock.field_data import DictFieldData
|
||||
|
||||
from ..field_overrides import (
|
||||
disable_overrides,
|
||||
FieldOverrideProvider,
|
||||
OverrideFieldData,
|
||||
resolve_dotted,
|
||||
)
|
||||
|
||||
|
||||
TESTUSER = "testuser"
|
||||
|
||||
|
||||
@override_settings(FIELD_OVERRIDE_PROVIDERS=(
|
||||
'courseware.tests.test_field_overrides.TestOverrideProvider',))
|
||||
class OverrideFieldDataTests(TestCase):
|
||||
"""
|
||||
Tests for `OverrideFieldData`.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(OverrideFieldDataTests, self).setUp()
|
||||
OverrideFieldData.provider_classes = None
|
||||
|
||||
def tearDown(self):
|
||||
super(OverrideFieldDataTests, self).tearDown()
|
||||
OverrideFieldData.provider_classes = None
|
||||
|
||||
def make_one(self):
|
||||
"""
|
||||
Factory method.
|
||||
"""
|
||||
return OverrideFieldData.wrap(TESTUSER, DictFieldData({
|
||||
'foo': 'bar',
|
||||
'bees': 'knees',
|
||||
}))
|
||||
|
||||
def test_get(self):
|
||||
data = self.make_one()
|
||||
self.assertEqual(data.get('block', 'foo'), 'fu')
|
||||
self.assertEqual(data.get('block', 'bees'), 'knees')
|
||||
with disable_overrides():
|
||||
self.assertEqual(data.get('block', 'foo'), 'bar')
|
||||
|
||||
def test_set(self):
|
||||
data = self.make_one()
|
||||
data.set('block', 'foo', 'yowza')
|
||||
self.assertEqual(data.get('block', 'foo'), 'fu')
|
||||
with disable_overrides():
|
||||
self.assertEqual(data.get('block', 'foo'), 'yowza')
|
||||
|
||||
def test_delete(self):
|
||||
data = self.make_one()
|
||||
data.delete('block', 'foo')
|
||||
self.assertEqual(data.get('block', 'foo'), 'fu')
|
||||
with disable_overrides():
|
||||
# Since field_data is responsible for attribute access, you'd
|
||||
# expect it to raise AttributeError. In fact, it raises KeyError,
|
||||
# so we check for that.
|
||||
with self.assertRaises(KeyError):
|
||||
data.get('block', 'foo')
|
||||
|
||||
def test_has(self):
|
||||
data = self.make_one()
|
||||
self.assertTrue(data.has('block', 'foo'))
|
||||
self.assertTrue(data.has('block', 'bees'))
|
||||
self.assertTrue(data.has('block', 'oh'))
|
||||
with disable_overrides():
|
||||
self.assertFalse(data.has('block', 'oh'))
|
||||
|
||||
def test_many(self):
|
||||
data = self.make_one()
|
||||
data.set_many('block', {'foo': 'baz', 'ah': 'ic'})
|
||||
self.assertEqual(data.get('block', 'foo'), 'fu')
|
||||
self.assertEqual(data.get('block', 'ah'), 'ic')
|
||||
with disable_overrides():
|
||||
self.assertEqual(data.get('block', 'foo'), 'baz')
|
||||
|
||||
@override_settings(FIELD_OVERRIDE_PROVIDERS=())
|
||||
def test_no_overrides_configured(self):
|
||||
data = self.make_one()
|
||||
self.assertIsInstance(data, DictFieldData)
|
||||
|
||||
|
||||
class ResolveDottedTests(unittest.TestCase):
|
||||
"""
|
||||
Tests for `resolve_dotted`.
|
||||
"""
|
||||
|
||||
def test_bad_sub_import(self):
|
||||
with self.assertRaises(ImportError):
|
||||
resolve_dotted('courseware.tests.test_foo')
|
||||
|
||||
def test_bad_import(self):
|
||||
with self.assertRaises(ImportError):
|
||||
resolve_dotted('nosuchpackage')
|
||||
|
||||
def test_import_something_that_isnt_already_loaded(self):
|
||||
self.assertEqual(
|
||||
resolve_dotted('courseware.tests.animport.SOMENAME'),
|
||||
'bar'
|
||||
)
|
||||
|
||||
|
||||
class TestOverrideProvider(FieldOverrideProvider):
|
||||
"""
|
||||
A concrete implementation of `FieldOverrideProvider` for testing.
|
||||
"""
|
||||
def get(self, block, name, default):
|
||||
assert self.user is TESTUSER
|
||||
assert block == 'block'
|
||||
if name == 'foo':
|
||||
return 'fu'
|
||||
if name == 'oh':
|
||||
return 'man'
|
||||
return default
|
||||
@@ -12,7 +12,13 @@ TO DO sync instructor and staff flags
|
||||
import logging
|
||||
from django_comment_common.models import Role
|
||||
|
||||
from student.roles import CourseBetaTesterRole, CourseInstructorRole, CourseStaffRole
|
||||
from student.roles import (
|
||||
CourseBetaTesterRole,
|
||||
CourseInstructorRole,
|
||||
CourseCcxCoachRole,
|
||||
CourseStaffRole,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -20,6 +26,7 @@ ROLES = {
|
||||
'beta': CourseBetaTesterRole,
|
||||
'instructor': CourseInstructorRole,
|
||||
'staff': CourseStaffRole,
|
||||
'ccx_coach': CourseCcxCoachRole,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -81,7 +81,6 @@ def student_grades(student, request, course, keep_raw_scores=False, use_offline=
|
||||
This is the main interface to get grades. It has the same parameters as grades.grade, as well
|
||||
as use_offline. If use_offline is True then this will look for an offline computed gradeset in the DB.
|
||||
'''
|
||||
|
||||
if not use_offline:
|
||||
return grades.grade(student, request, course, keep_raw_scores=keep_raw_scores)
|
||||
|
||||
|
||||
@@ -50,6 +50,9 @@ from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from xmodule.fields import Date
|
||||
|
||||
from courseware.models import StudentFieldOverride
|
||||
|
||||
import instructor_task.api
|
||||
import instructor.views.api
|
||||
@@ -61,8 +64,8 @@ from instructor_task.api_helper import AlreadyRunningError
|
||||
from openedx.core.djangoapps.course_groups.cohorts import set_course_cohort_settings
|
||||
|
||||
from .test_tools import msk_from_problem_urlname
|
||||
from ..views.tools import get_extended_due
|
||||
|
||||
DATE_FIELD = Date()
|
||||
EXPECTED_CSV_HEADER = (
|
||||
'"code","redeem_code_url","course_id","company_name","created_by","redeemed_by","invoice_id","purchaser",'
|
||||
'"customer_reference_number","internal_reference"'
|
||||
@@ -3114,6 +3117,23 @@ class TestInstructorAPIHelpers(TestCase):
|
||||
msk_from_problem_urlname(*args)
|
||||
|
||||
|
||||
def get_extended_due(course, unit, user):
|
||||
"""
|
||||
Gets the overridden due date for the given user on the given unit. Returns
|
||||
`None` if there is no override set.
|
||||
"""
|
||||
try:
|
||||
override = StudentFieldOverride.objects.get(
|
||||
course_id=course.id,
|
||||
student=user,
|
||||
location=unit.location,
|
||||
field='due'
|
||||
)
|
||||
return DATE_FIELD.from_json(json.loads(override.value))
|
||||
except StudentFieldOverride.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
class TestDueDateExtensions(ModuleStoreTestCase, LoginEnrollmentTestCase):
|
||||
"""
|
||||
Test data dumps for reporting.
|
||||
|
||||
@@ -3,15 +3,15 @@ Tests for views/tools.py.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import functools
|
||||
import mock
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from django.utils.timezone import utc
|
||||
from django.test.utils import override_settings
|
||||
|
||||
from courseware.models import StudentModule
|
||||
from student.tests.factories import UserFactory
|
||||
from courseware.field_overrides import OverrideFieldData # pylint: disable=import-error
|
||||
from student.tests.factories import UserFactory # pylint: disable=import-error
|
||||
from xmodule.fields import Date
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
@@ -180,6 +180,10 @@ class TestTitleOrUrl(unittest.TestCase):
|
||||
self.assertEquals(tools.title_or_url(unit), 'test:hello')
|
||||
|
||||
|
||||
@override_settings(
|
||||
FIELD_OVERRIDE_PROVIDERS=(
|
||||
'courseware.student_field_overrides.IndividualStudentOverrideProvider',),
|
||||
)
|
||||
class TestSetDueDateExtension(ModuleStoreTestCase):
|
||||
"""
|
||||
Test the set_due_date_extensions function.
|
||||
@@ -189,53 +193,60 @@ class TestSetDueDateExtension(ModuleStoreTestCase):
|
||||
Fixtures.
|
||||
"""
|
||||
super(TestSetDueDateExtension, self).setUp()
|
||||
OverrideFieldData.provider_classes = None
|
||||
|
||||
due = datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc)
|
||||
self.due = due = datetime.datetime(2010, 5, 12, 2, 42, tzinfo=utc)
|
||||
course = CourseFactory.create()
|
||||
week1 = ItemFactory.create(due=due, parent=course)
|
||||
week2 = ItemFactory.create(due=due, parent=course)
|
||||
week3 = ItemFactory.create(parent=course)
|
||||
|
||||
homework = ItemFactory.create(
|
||||
parent=week1,
|
||||
due=due
|
||||
)
|
||||
homework = ItemFactory.create(parent=week1)
|
||||
assignment = ItemFactory.create(parent=homework, due=due)
|
||||
|
||||
user = UserFactory.create()
|
||||
StudentModule(
|
||||
state='{}',
|
||||
student_id=user.id,
|
||||
course_id=course.id,
|
||||
module_state_key=week1.location).save()
|
||||
StudentModule(
|
||||
state='{}',
|
||||
student_id=user.id,
|
||||
course_id=course.id,
|
||||
module_state_key=homework.location).save()
|
||||
|
||||
self.course = course
|
||||
self.week1 = week1
|
||||
self.homework = homework
|
||||
self.assignment = assignment
|
||||
self.week2 = week2
|
||||
self.week3 = week3
|
||||
self.user = user
|
||||
|
||||
self.extended_due = functools.partial(
|
||||
tools.get_extended_due, course, student=user)
|
||||
# Apparently the test harness doesn't use LmsFieldStorage, and I'm not
|
||||
# sure if there's a way to poke the test harness to do so. So, we'll
|
||||
# just inject the override field storage in this brute force manner.
|
||||
for block in (course, week1, week2, week3, homework, assignment):
|
||||
block._field_data = OverrideFieldData.wrap( # pylint: disable=protected-access
|
||||
user, block._field_data) # pylint: disable=protected-access
|
||||
|
||||
def tearDown(self):
|
||||
super(TestSetDueDateExtension, self).tearDown()
|
||||
OverrideFieldData.provider_classes = None
|
||||
|
||||
def _clear_field_data_cache(self):
|
||||
"""
|
||||
Clear field data cache for xblocks under test. Normally this would be
|
||||
done by virtue of the fact that xblocks are reloaded on subsequent
|
||||
requests.
|
||||
"""
|
||||
for block in (self.week1, self.week2, self.week3,
|
||||
self.homework, self.assignment):
|
||||
block.fields['due']._del_cached_value(block) # pylint: disable=protected-access
|
||||
|
||||
def test_set_due_date_extension(self):
|
||||
extended = datetime.datetime(2013, 12, 25, 0, 0, tzinfo=utc)
|
||||
tools.set_due_date_extension(self.course, self.week1, self.user, extended)
|
||||
self.assertEqual(self.extended_due(self.week1), extended)
|
||||
self.assertEqual(self.extended_due(self.homework), extended)
|
||||
self._clear_field_data_cache()
|
||||
self.assertEqual(self.week1.due, extended)
|
||||
self.assertEqual(self.homework.due, extended)
|
||||
self.assertEqual(self.assignment.due, extended)
|
||||
|
||||
def test_set_due_date_extension_create_studentmodule(self):
|
||||
def test_set_due_date_extension_num_queries(self):
|
||||
extended = datetime.datetime(2013, 12, 25, 0, 0, tzinfo=utc)
|
||||
user = UserFactory.create() # No student modules for this user
|
||||
tools.set_due_date_extension(self.course, self.week1, user, extended)
|
||||
extended_due = functools.partial(tools.get_extended_due, self.course, student=user)
|
||||
self.assertEqual(extended_due(self.week1), extended)
|
||||
self.assertEqual(extended_due(self.homework), extended)
|
||||
with self.assertNumQueries(4):
|
||||
tools.set_due_date_extension(self.course, self.week1, self.user, extended)
|
||||
self._clear_field_data_cache()
|
||||
|
||||
def test_set_due_date_extension_invalid_date(self):
|
||||
extended = datetime.datetime(2009, 1, 1, 0, 0, tzinfo=utc)
|
||||
@@ -251,8 +262,7 @@ class TestSetDueDateExtension(ModuleStoreTestCase):
|
||||
extended = datetime.datetime(2013, 12, 25, 0, 0, tzinfo=utc)
|
||||
tools.set_due_date_extension(self.course, self.week1, self.user, extended)
|
||||
tools.set_due_date_extension(self.course, self.week1, self.user, None)
|
||||
self.assertEqual(self.extended_due(self.week1), None)
|
||||
self.assertEqual(self.extended_due(self.homework), None)
|
||||
self.assertEqual(self.week1.due, self.due)
|
||||
|
||||
|
||||
class TestDataDumps(ModuleStoreTestCase):
|
||||
@@ -270,7 +280,6 @@ class TestDataDumps(ModuleStoreTestCase):
|
||||
course = CourseFactory.create()
|
||||
week1 = ItemFactory.create(due=due, parent=course)
|
||||
week2 = ItemFactory.create(due=due, parent=course)
|
||||
week3 = ItemFactory.create(due=due, parent=course)
|
||||
|
||||
homework = ItemFactory.create(
|
||||
parent=week1,
|
||||
@@ -278,51 +287,7 @@ class TestDataDumps(ModuleStoreTestCase):
|
||||
)
|
||||
|
||||
user1 = UserFactory.create()
|
||||
StudentModule(
|
||||
state='{}',
|
||||
student_id=user1.id,
|
||||
course_id=course.id,
|
||||
module_state_key=week1.location).save()
|
||||
StudentModule(
|
||||
state='{}',
|
||||
student_id=user1.id,
|
||||
course_id=course.id,
|
||||
module_state_key=week2.location).save()
|
||||
StudentModule(
|
||||
state='{}',
|
||||
student_id=user1.id,
|
||||
course_id=course.id,
|
||||
module_state_key=week3.location).save()
|
||||
StudentModule(
|
||||
state='{}',
|
||||
student_id=user1.id,
|
||||
course_id=course.id,
|
||||
module_state_key=homework.location).save()
|
||||
|
||||
user2 = UserFactory.create()
|
||||
StudentModule(
|
||||
state='{}',
|
||||
student_id=user2.id,
|
||||
course_id=course.id,
|
||||
module_state_key=week1.location).save()
|
||||
StudentModule(
|
||||
state='{}',
|
||||
student_id=user2.id,
|
||||
course_id=course.id,
|
||||
module_state_key=homework.location).save()
|
||||
|
||||
user3 = UserFactory.create()
|
||||
StudentModule(
|
||||
state='{}',
|
||||
student_id=user3.id,
|
||||
course_id=course.id,
|
||||
module_state_key=week1.location).save()
|
||||
StudentModule(
|
||||
state='{}',
|
||||
student_id=user3.id,
|
||||
course_id=course.id,
|
||||
module_state_key=homework.location).save()
|
||||
|
||||
self.course = course
|
||||
self.week1 = week1
|
||||
self.homework = homework
|
||||
|
||||
@@ -73,7 +73,7 @@ from instructor.enrollment import (
|
||||
send_beta_role_email,
|
||||
unenroll_email,
|
||||
)
|
||||
from instructor.access import list_with_level, allow_access, revoke_access, update_forum_role
|
||||
from instructor.access import list_with_level, allow_access, revoke_access, ROLES, update_forum_role
|
||||
from instructor.offline_gradecalc import student_grades
|
||||
import instructor_analytics.basic
|
||||
import instructor_analytics.distributions
|
||||
@@ -679,7 +679,7 @@ def bulk_beta_modify_access(request, course_id):
|
||||
@common_exceptions_400
|
||||
@require_query_params(
|
||||
unique_student_identifier="email or username of user to change access",
|
||||
rolename="'instructor', 'staff', or 'beta'",
|
||||
rolename="'instructor', 'staff', 'beta', or 'ccx_coach'",
|
||||
action="'allow' or 'revoke'"
|
||||
)
|
||||
def modify_access(request, course_id):
|
||||
@@ -691,7 +691,7 @@ def modify_access(request, course_id):
|
||||
|
||||
Query parameters:
|
||||
unique_student_identifer is the target user's username or email
|
||||
rolename is one of ['instructor', 'staff', 'beta']
|
||||
rolename is one of ['instructor', 'staff', 'beta', 'ccx_coach']
|
||||
action is one of ['allow', 'revoke']
|
||||
"""
|
||||
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
|
||||
@@ -720,10 +720,10 @@ def modify_access(request, course_id):
|
||||
rolename = request.GET.get('rolename')
|
||||
action = request.GET.get('action')
|
||||
|
||||
if rolename not in ['instructor', 'staff', 'beta']:
|
||||
return HttpResponseBadRequest(strip_tags(
|
||||
"unknown rolename '{}'".format(rolename)
|
||||
))
|
||||
if rolename not in ROLES:
|
||||
error = strip_tags("unknown rolename '{}'".format(rolename))
|
||||
log.error(error)
|
||||
return HttpResponseBadRequest(error)
|
||||
|
||||
# disallow instructors from removing their own instructor access.
|
||||
if rolename == 'instructor' and user == request.user and action != 'allow':
|
||||
@@ -762,7 +762,7 @@ def list_course_role_members(request, course_id):
|
||||
List instructors and staff.
|
||||
Requires instructor access.
|
||||
|
||||
rolename is one of ['instructor', 'staff', 'beta']
|
||||
rolename is one of ['instructor', 'staff', 'beta', 'ccx_coach']
|
||||
|
||||
Returns JSON of the form {
|
||||
"course_id": "some/course/id",
|
||||
@@ -783,7 +783,7 @@ def list_course_role_members(request, course_id):
|
||||
|
||||
rolename = request.GET.get('rolename')
|
||||
|
||||
if rolename not in ['instructor', 'staff', 'beta']:
|
||||
if rolename not in ROLES:
|
||||
return HttpResponseBadRequest()
|
||||
|
||||
def extract_user_info(user):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -572,3 +576,17 @@ ONLOAD_BEACON_SAMPLE_RATE = ENV_TOKENS.get('ONLOAD_BEACON_SAMPLE_RATE', ONLOAD_B
|
||||
ECOMMERCE_API_URL = ENV_TOKENS.get('ECOMMERCE_API_URL', ECOMMERCE_API_URL)
|
||||
ECOMMERCE_API_SIGNING_KEY = AUTH_TOKENS.get('ECOMMERCE_API_SIGNING_KEY', ECOMMERCE_API_SIGNING_KEY)
|
||||
ECOMMERCE_API_TIMEOUT = ENV_TOKENS.get('ECOMMERCE_API_TIMEOUT', ECOMMERCE_API_TIMEOUT)
|
||||
|
||||
##### Custom Courses for EdX #####
|
||||
if FEATURES.get('CUSTOM_COURSES_EDX'):
|
||||
INSTALLED_APPS += ('ccx',)
|
||||
MIDDLEWARE_CLASSES += ('ccx.overrides.CcxMiddleware',)
|
||||
FIELD_OVERRIDE_PROVIDERS += (
|
||||
'ccx.overrides.CustomCoursesForEdxOverrideProvider',
|
||||
)
|
||||
|
||||
##### Individual Due Date Extensions #####
|
||||
if FEATURES.get('INDIVIDUAL_DUE_DATES'):
|
||||
FIELD_OVERRIDE_PROVIDERS += (
|
||||
'courseware.student_field_overrides.IndividualStudentOverrideProvider',
|
||||
)
|
||||
|
||||
@@ -208,8 +208,15 @@ FEATURES = {
|
||||
'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True,
|
||||
|
||||
# Enable instructor to assign individual due dates
|
||||
# Note: In order for this feature to work, you must also add
|
||||
# 'courseware.student_field_overrides.IndividualStudentOverrideProvider' to
|
||||
# the setting FIELD_OVERRIDE_PROVIDERS, in addition to setting this flag to
|
||||
# True.
|
||||
'INDIVIDUAL_DUE_DATES': False,
|
||||
|
||||
# Enable Custom Courses for EdX
|
||||
'CUSTOM_COURSES_EDX': False,
|
||||
|
||||
# Enable legacy instructor dashboard
|
||||
'ENABLE_INSTRUCTOR_LEGACY_DASHBOARD': True,
|
||||
|
||||
@@ -1196,6 +1203,9 @@ reverify_js = [
|
||||
'js/verify_student/incourse_reverify.js',
|
||||
]
|
||||
|
||||
ccx_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/ccx/**/*.js'))
|
||||
|
||||
|
||||
PIPELINE_CSS = {
|
||||
'style-vendor': {
|
||||
'source_filenames': [
|
||||
@@ -1389,6 +1399,10 @@ PIPELINE_JS = {
|
||||
'reverify': {
|
||||
'source_filenames': reverify_js,
|
||||
'output_filename': 'js/reverify.js'
|
||||
},
|
||||
'ccx': {
|
||||
'source_filenames': ccx_js,
|
||||
'output_filename': 'js/ccx.js'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2216,3 +2230,9 @@ ECOMMERCE_API_TIMEOUT = 5
|
||||
|
||||
# Reverification checkpoint name pattern
|
||||
CHECKPOINT_PATTERN = r'(?P<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 = ()
|
||||
|
||||
@@ -445,7 +445,6 @@ MONGODB_LOG = {
|
||||
'db': 'xlog',
|
||||
}
|
||||
|
||||
|
||||
# Enable EdxNotes for tests.
|
||||
FEATURES['ENABLE_EDXNOTES'] = True
|
||||
|
||||
@@ -469,3 +468,7 @@ FACEBOOK_API_VERSION = "v2.2"
|
||||
|
||||
# Certificates Views
|
||||
FEATURES['CERTIFICATES_HTML_VIEW'] = True
|
||||
|
||||
######### custom courses #########
|
||||
INSTALLED_APPS += ('ccx',)
|
||||
MIDDLEWARE_CLASSES += ('ccx.overrides.CcxMiddleware',)
|
||||
|
||||
396
lms/static/js/ccx/schedule.js
Normal file
396
lms/static/js/ccx/schedule.js
Normal file
@@ -0,0 +1,396 @@
|
||||
var edx = edx || {};
|
||||
|
||||
(function($, _, Backbone, gettext) {
|
||||
'use strict';
|
||||
|
||||
edx.ccx = edx.ccx || {};
|
||||
edx.ccx.schedule = edx.ccx.schedule || {};
|
||||
|
||||
var syncErrorMessage = gettext("The data could not be saved.");
|
||||
|
||||
var self;
|
||||
|
||||
edx.ccx.schedule.reloadPage = function() {
|
||||
location.reload();
|
||||
};
|
||||
|
||||
edx.ccx.schedule.UnitModel = Backbone.Model.extend({
|
||||
defaults: {
|
||||
location: '',
|
||||
display_name: '',
|
||||
start: null,
|
||||
due: null,
|
||||
category: '',
|
||||
hidden: false,
|
||||
children: []
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
edx.ccx.schedule.Schedule = Backbone.Collection.extend({
|
||||
|
||||
model: edx.ccx.schedule.UnitModel,
|
||||
url: 'ccx_schedule'
|
||||
|
||||
});
|
||||
|
||||
edx.ccx.schedule.ScheduleView = Backbone.View.extend({
|
||||
|
||||
initialize: function() {
|
||||
_.bindAll(this, 'render');
|
||||
this.schedule_collection = new edx.ccx.schedule.Schedule();
|
||||
this.schedule = {};
|
||||
this.schedule_collection.bind('reset', this.render);
|
||||
this.schedule_collection.fetch({reset: true});
|
||||
this.chapter_select = $('form#add-unit select[name="chapter"]'),
|
||||
this.sequential_select = $('form#add-unit select[name="sequential"]'),
|
||||
this.vertical_select = $('form#add-unit select[name="vertical"]');
|
||||
this.dirty = false;
|
||||
self = this;
|
||||
$('#add-all').on('click', function(event) {
|
||||
event.preventDefault();
|
||||
self.schedule_apply(self.schedule, self.show);
|
||||
self.dirty = true;
|
||||
self.schedule_collection.set(self.schedule);
|
||||
self.render();
|
||||
});
|
||||
|
||||
// Add unit handlers
|
||||
this.chapter_select.on('change', function(event) {
|
||||
var chapter_location = self.chapter_select.val();
|
||||
self.vertical_select.html('').prop('disabled', true);
|
||||
if (chapter_location !== 'none') {
|
||||
var chapter = self.find_unit(self.hidden, chapter_location);
|
||||
self.sequential_select.html('')
|
||||
.append('<option value="all">'+gettext("All subsections")+'</option>')
|
||||
.append(self.schedule_options(chapter.children));
|
||||
self.sequential_select.prop('disabled', false);
|
||||
$('#add-unit-button').prop('disabled', false);
|
||||
self.set_datetime('start', chapter.start);
|
||||
self.set_datetime('due', chapter.due);
|
||||
}
|
||||
else {
|
||||
self.sequential_select.html('').prop('disabled', true);
|
||||
}
|
||||
});
|
||||
|
||||
this.sequential_select.on('change', function(event) {
|
||||
var sequential_location = self.sequential_select.val();
|
||||
if (sequential_location !== 'all') {
|
||||
var chapter = self.chapter_select.val(),
|
||||
sequential = self.find_unit(self.hidden, chapter, sequential_location);
|
||||
self.vertical_select.html('')
|
||||
.append('<option value="all">'+gettext("All units")+'</option>')
|
||||
.append(self.schedule_options(sequential.children));
|
||||
self.vertical_select.prop('disabled', false);
|
||||
self.set_datetime('start', sequential.start);
|
||||
self.set_datetime('due', sequential.due);
|
||||
}
|
||||
else {
|
||||
self.vertical_select.html('').prop('disabled', true);
|
||||
}
|
||||
});
|
||||
|
||||
this.vertical_select.on('change', function(event) {
|
||||
var vertical_location = self.vertical_select.val();
|
||||
if (vertical_location !== 'all') {
|
||||
var chapter = self.chapter_select.val(),
|
||||
sequential = self.sequential_select.val();
|
||||
var vertical = self.find_unit(
|
||||
self.hidden, chapter, sequential, vertical_location);
|
||||
self.set_datetime('start', vertical.start);
|
||||
self.set_datetime('due', vertical.due);
|
||||
}
|
||||
});
|
||||
|
||||
// Add unit handler
|
||||
$('#add-unit-button').on('click', function(event) {
|
||||
event.preventDefault();
|
||||
var chapter = self.chapter_select.val(),
|
||||
sequential = self.sequential_select.val(),
|
||||
vertical = self.vertical_select.val(),
|
||||
units = self.find_lineage(self.schedule,
|
||||
chapter,
|
||||
sequential == 'all' ? null : sequential,
|
||||
vertical == 'all' ? null: vertical),
|
||||
start = self.get_datetime('start'),
|
||||
due = self.get_datetime('due');
|
||||
units.map(self.show);
|
||||
var unit = units[units.length - 1]
|
||||
self.schedule_apply([unit], self.show);
|
||||
if (unit !== undefined && start) unit.start = start;
|
||||
if (unit !== undefined && due) unit.due = due;
|
||||
self.schedule_collection.set(self.schedule);
|
||||
self.dirty = true;
|
||||
self.render();
|
||||
});
|
||||
|
||||
// Handle save button
|
||||
$('#dirty-schedule #save-changes').on('click', function(event) {
|
||||
event.preventDefault();
|
||||
self.save();
|
||||
});
|
||||
|
||||
},
|
||||
|
||||
render: function() {
|
||||
self.schedule = this.schedule_collection.toJSON();
|
||||
self.hidden = this.pruned(self.schedule, function(node) {
|
||||
return node.hidden || node.category !== 'vertical'});
|
||||
this.showing = this.pruned(self.schedule, function(node) {
|
||||
return !node.hidden});
|
||||
this.$el.html(schedule_template({chapters: this.showing}));
|
||||
$('table.ccx-schedule .sequential,.vertical').hide();
|
||||
$('table.ccx-schedule .toggle-collapse').on('click', this.toggle_collapse);
|
||||
//
|
||||
// Hidden hover fields for empty date fields
|
||||
$('table.ccx-schedule .date a').each(function() {
|
||||
if (! $(this).text()) {
|
||||
$(this).text('Set date').addClass('empty');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle date edit clicks
|
||||
$('table.ccx-schedule .date a').attr('href', '#enter-date-modal')
|
||||
.leanModal({closeButton: '.close-modal'});
|
||||
$('table.ccx-schedule .due-date a').on('click', this.enterNewDate('due'));
|
||||
$('table.ccx-schedule .start-date a').on('click', this.enterNewDate('start'));
|
||||
// Click handler for remove all
|
||||
$('table.ccx-schedule a#remove-all').on('click', function(event) {
|
||||
event.preventDefault();
|
||||
self.schedule_apply(self.schedule, self.hide);
|
||||
self.dirty = true;
|
||||
self.schedule_collection.set(self.schedule);
|
||||
self.render();
|
||||
});
|
||||
// Remove unit handler
|
||||
$('table.ccx-schedule a.remove-unit').on('click', function(event) {
|
||||
var row = $(this).closest('tr'),
|
||||
path = row.data('location').split(' '),
|
||||
unit = self.find_unit(self.schedule, path[0], path[1], path[2]);
|
||||
self.schedule_apply([unit], self.hide);
|
||||
self.schedule_collection.set(self.schedule);
|
||||
self.dirty = true;
|
||||
self.render();
|
||||
});
|
||||
|
||||
|
||||
// Show or hide form
|
||||
if (this.hidden.length) {
|
||||
// Populate chapters select, depopulate others
|
||||
this.chapter_select.html('')
|
||||
.append('<option value="none">'+gettext("Select a chapter")+'...</option>')
|
||||
.append(self.schedule_options(this.hidden));
|
||||
this.sequential_select.html('').prop('disabled', true);
|
||||
this.vertical_select.html('').prop('disabled', true);
|
||||
$('form#add-unit').show();
|
||||
$('#all-units-added').hide();
|
||||
$('#add-unit-button').prop('disabled', true);
|
||||
}
|
||||
else {
|
||||
$('form#add-unit').hide();
|
||||
$('#all-units-added').show();
|
||||
}
|
||||
|
||||
// Show or hide save button
|
||||
if (this.dirty) $('#dirty-schedule').show()
|
||||
else $('#dirty-schedule').hide();
|
||||
|
||||
$('#ajax-error').hide();
|
||||
|
||||
return this;
|
||||
},
|
||||
|
||||
save: function() {
|
||||
self.schedule_collection.set(self.schedule);
|
||||
var button = $('#dirty-schedule #save-changes');
|
||||
button.prop('disabled', true).text(gettext("Saving")+'...');
|
||||
|
||||
$.ajax({
|
||||
url: save_url,
|
||||
type: 'POST',
|
||||
contentType: 'application/json',
|
||||
data: JSON.stringify(self.schedule),
|
||||
success: function(data, textStatus, jqXHR) {
|
||||
self.dirty = false;
|
||||
self.render();
|
||||
button.prop('disabled', false).text(gettext("Save changes"));
|
||||
|
||||
// Update textarea with grading policy JSON, since grading policy
|
||||
// may have changed.
|
||||
$('#grading-policy').text(data.grading_policy);
|
||||
},
|
||||
error: function(jqXHR, textStatus, error) {
|
||||
console.log(jqXHR.responseText);
|
||||
$('#ajax-error').show();
|
||||
$('#dirty-schedule').hide();
|
||||
$('form#add-unit select,input,button').prop('disabled', true);
|
||||
button.prop('disabled', false).text(gettext("Save changes"));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
hide: function(unit) {
|
||||
if (unit !== undefined) {
|
||||
unit.hidden = true;
|
||||
}
|
||||
},
|
||||
|
||||
show: function(unit) {
|
||||
if (unit !== undefined) {
|
||||
unit.hidden = false;
|
||||
}
|
||||
},
|
||||
|
||||
get_datetime: function(which) {
|
||||
var date = $('form#add-unit input[name=' + which + '_date]').val();
|
||||
var time = $('form#add-unit input[name=' + which + '_time]').val();
|
||||
if (date && time)
|
||||
return date + ' ' + time;
|
||||
return null;
|
||||
},
|
||||
|
||||
set_datetime: function(which, value) {
|
||||
var parts = value ? value.split(' ') : ['', ''],
|
||||
date = parts[0],
|
||||
time = parts[1];
|
||||
$('form#add-unit input[name=' + which + '_date]').val(date);
|
||||
$('form#add-unit input[name=' + which + '_time]').val(time);
|
||||
},
|
||||
|
||||
schedule_options: function(nodes) {
|
||||
return nodes.map(function(node) {
|
||||
return $('<option>')
|
||||
.attr('value', node.location)
|
||||
.text(node.display_name)[0];
|
||||
});
|
||||
},
|
||||
|
||||
schedule_apply: function(nodes, f) {
|
||||
nodes.map(function(node) {
|
||||
f(node);
|
||||
if (node !== undefined && node.children !== undefined) self.schedule_apply(node.children, f);
|
||||
});
|
||||
},
|
||||
|
||||
pruned: function(tree, filter) {
|
||||
return tree.filter(filter)
|
||||
.map(function(node) {
|
||||
var copy = {};
|
||||
$.extend(copy, node);
|
||||
if (node.children) copy.children = self.pruned(node.children, filter);
|
||||
return copy;
|
||||
})
|
||||
.filter(function(node) {
|
||||
return node.children === undefined || node.children.length;
|
||||
});
|
||||
},
|
||||
|
||||
toggle_collapse: function(event) {
|
||||
event.preventDefault();
|
||||
var row = $(this).closest('tr');
|
||||
var children = self.get_children(row);
|
||||
|
||||
if (row.is('.expanded')) {
|
||||
$(this).removeClass('fa-caret-down').addClass('fa-caret-right');
|
||||
row.removeClass('expanded').addClass('collapsed');
|
||||
children.hide();
|
||||
}
|
||||
|
||||
else {
|
||||
$(this).removeClass('fa-caret-right').addClass('fa-caret-down');
|
||||
row.removeClass('collapsed').addClass('expanded');
|
||||
children.filter('.collapsed').each(function() {
|
||||
children = children.not(self.get_children(this));
|
||||
});
|
||||
children.show();
|
||||
}
|
||||
},
|
||||
|
||||
enterNewDate: function(what) {
|
||||
return function(event) {
|
||||
var row = $(this).closest('tr');
|
||||
var modal = $('#enter-date-modal')
|
||||
.data('what', what)
|
||||
.data('location', row.data('location'));
|
||||
modal.find('h2').text(
|
||||
what == 'due' ? gettext("Enter Due Date") :
|
||||
gettext("Enter Start Date"));
|
||||
modal.find('label').text(row.find('td:first').text());
|
||||
|
||||
var path = row.data('location').split(' '),
|
||||
unit = self.find_unit(self.schedule, path[0], path[1], path[2]),
|
||||
parts = unit[what] ? unit[what].split(' ') : ['', ''],
|
||||
date = parts[0],
|
||||
time = parts[1];
|
||||
|
||||
modal.find('input[name=date]').val(date);
|
||||
modal.find('input[name=time]').val(time);
|
||||
|
||||
modal.find('form').off('submit').on('submit', function(event) {
|
||||
event.preventDefault();
|
||||
var date = $(this).find('input[name=date]').val(),
|
||||
time = $(this).find('input[name=time]').val();
|
||||
var valid_date = new Date(date);
|
||||
if (isNaN(valid_date.valueOf())) {
|
||||
alert('Please enter a valid date');
|
||||
return;
|
||||
}
|
||||
var valid_time = /^\d{1,2}:\d{2}?$/;
|
||||
if (!time.match(valid_time)) {
|
||||
alert('Please enter a valid time');
|
||||
return;
|
||||
}
|
||||
if (what == 'start') {
|
||||
unit.start = date + ' ' + time;
|
||||
} else {
|
||||
unit.due = date + ' ' + time;
|
||||
}
|
||||
modal.find('.close-modal').click();
|
||||
self.dirty = true;
|
||||
self.schedule_collection.set(self.schedule);
|
||||
self.render();
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
find_unit: function(tree, chapter, sequential, vertical) {
|
||||
var units = self.find_lineage(tree, chapter, sequential, vertical);
|
||||
return units[units.length -1];
|
||||
},
|
||||
|
||||
find_lineage: function(tree, chapter, sequential, vertical) {
|
||||
function find_in(seq, location) {
|
||||
for (var i = 0; i < seq.length; i++)
|
||||
if (seq[i].location === location)
|
||||
return seq[i];
|
||||
}
|
||||
|
||||
var units = [],
|
||||
unit = find_in(tree, chapter);
|
||||
units[units.length] = unit;
|
||||
if (sequential) {
|
||||
units[units.length] = unit = find_in(unit.children, sequential);
|
||||
if (vertical)
|
||||
units[units.length] = unit = find_in(unit.children, vertical);
|
||||
}
|
||||
|
||||
return units;
|
||||
},
|
||||
|
||||
get_children: function(row) {
|
||||
var depth = $(row).data('depth');
|
||||
return $(row).nextUntil(
|
||||
$(row).siblings().filter(function() {
|
||||
return $(this).data('depth') <= depth;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
})(jQuery, _, Backbone, gettext);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -77,5 +77,8 @@
|
||||
@import "course/instructor/email";
|
||||
@import "xmodule/descriptors/css/module-styles.scss";
|
||||
|
||||
// course - discussion
|
||||
// course - ccx_coach
|
||||
@import "course/ccx_coach/dashboard";
|
||||
|
||||
// discussion
|
||||
@import "course/discussion/form-wmd-toolbar";
|
||||
|
||||
57
lms/static/sass/course/ccx_coach/_dashboard.scss
Normal file
57
lms/static/sass/course/ccx_coach/_dashboard.scss
Normal file
@@ -0,0 +1,57 @@
|
||||
.ccx-schedule-container {
|
||||
float: left;
|
||||
width: 750px;
|
||||
}
|
||||
|
||||
table.ccx-schedule {
|
||||
width: 100%;
|
||||
|
||||
thead {
|
||||
border-bottom: 2px solid black;
|
||||
}
|
||||
th:first-child {
|
||||
width: 40%;
|
||||
}
|
||||
th:last-child {
|
||||
width: 18%;
|
||||
}
|
||||
th, td {
|
||||
padding: 10px;
|
||||
}
|
||||
.sequential .unit {
|
||||
padding-left: 25px;
|
||||
}
|
||||
.vertical .unit {
|
||||
padding-left: 40px;
|
||||
}
|
||||
a.empty {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: white;
|
||||
}
|
||||
a.empty:hover {
|
||||
color: #cbcbcb;
|
||||
}
|
||||
}
|
||||
|
||||
.ccx-schedule-sidebar {
|
||||
float: left;
|
||||
width: 295px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.ccx-sidebar-panel {
|
||||
border: 1px solid #cbcbcb;
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
form.ccx-form {
|
||||
line-height: 1.5;
|
||||
select {
|
||||
width: 100%;
|
||||
}
|
||||
.field {
|
||||
margin: 5px 0 5px 0;
|
||||
}
|
||||
}
|
||||
29
lms/templates/ccx/_dashboard_ccx_listing.html
Normal file
29
lms/templates/ccx/_dashboard_ccx_listing.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<%page args="ccx, membership, course" />
|
||||
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%!
|
||||
from django.core.urlresolvers import reverse
|
||||
from courseware.courses import course_image_url, get_course_about_section
|
||||
%>
|
||||
<%
|
||||
ccx_switch_target = reverse('switch_active_ccx', args=[course.id.to_deprecated_string(), ccx.id])
|
||||
%>
|
||||
<li class="course-item">
|
||||
<article class="course">
|
||||
<a href="${ccx_switch_target}" class="cover">
|
||||
<img src="${course_image_url(course)}" alt="${_('{course_number} {ccx_name} Cover Image').format(course_number=course.number, ccx_name=ccx.display_name) |h}" />
|
||||
</a>
|
||||
<section class="info">
|
||||
<hgroup>
|
||||
<p class="date-block">
|
||||
Custom Course
|
||||
</p>
|
||||
<h2 class="university">${get_course_about_section(course, 'university')}</h2>
|
||||
<h3>
|
||||
<a href="${ccx_switch_target}">${course.display_number_with_default | h} ${ccx.display_name}</a>
|
||||
</h3>
|
||||
</hgroup>
|
||||
<a href="${ccx_switch_target}" class="enter-course">${_('View Course')}</a>
|
||||
</section>
|
||||
</article>
|
||||
</li>
|
||||
141
lms/templates/ccx/coach_dashboard.html
Normal file
141
lms/templates/ccx/coach_dashboard.html
Normal file
@@ -0,0 +1,141 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
|
||||
<%inherit file="/main.html" />
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
|
||||
<%block name="pagetitle">${_("CCX Coach Dashboard")}</%block>
|
||||
<%block name="nav_skip">#ccx-coach-dashboard-content</%block>
|
||||
|
||||
<%block name="headextra">
|
||||
<%static:css group='style-course-vendor'/>
|
||||
<%static:css group='style-vendor-tinymce-content'/>
|
||||
<%static:css group='style-vendor-tinymce-skin'/>
|
||||
<%static:css group='style-course'/>
|
||||
</%block>
|
||||
|
||||
<%include file="/courseware/course_navigation.html" args="active_page='ccx_coach'" />
|
||||
|
||||
<section class="container">
|
||||
<div class="instructor-dashboard-wrapper-2">
|
||||
<section class="instructor-dashboard-content-2" id="ccx-coach-dashboard-content">
|
||||
<h1>${_("CCX Coach Dashboard")}</h1>
|
||||
|
||||
%if not ccx:
|
||||
<section>
|
||||
<form action="${create_ccx_url}" method="POST">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"/>
|
||||
<input name="name" placeholder="Name your CCX"/><br/>
|
||||
<button id="create-ccx">Coach a new Custom Course for EdX</button>
|
||||
</form>
|
||||
</section>
|
||||
%endif
|
||||
|
||||
%if ccx:
|
||||
<ul class="instructor-nav">
|
||||
<li class="nav-item">
|
||||
<a href="#" data-section="membership">${_("Enrollment")}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="#" data-section="schedule">${_("Schedule")}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="#" data-section="student_admin">${_("Student Admin")}</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="#" data-section="grading_policy">${_("Grading Policy")}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<section id="membership" class="idash-section">
|
||||
<%include file="enrollment.html" args="" />
|
||||
</section>
|
||||
<section id="schedule" class="idash-section">
|
||||
<%include file="schedule.html" args="" />
|
||||
</section>
|
||||
<section id="student_admin" class="idash-section">
|
||||
<%include file="student_admin.html" args="" />
|
||||
</section>
|
||||
<section id="grading_policy" class="idash-section">
|
||||
<%include file="grading_policy.html" args="" />
|
||||
</section>
|
||||
%endif
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
function setup_tabs() {
|
||||
$(".instructor-nav a").on("click", function(event) {
|
||||
event.preventDefault();
|
||||
$(".instructor-nav a").removeClass("active-section");
|
||||
var section_sel = "#" + $(this).attr("data-section");
|
||||
$("section.idash-section").hide();
|
||||
$(section_sel).show();
|
||||
$(this).addClass("active-section");
|
||||
});
|
||||
|
||||
var url = document.URL,
|
||||
hashbang = url.indexOf('#!');
|
||||
if (hashbang != -1) {
|
||||
var selector = '.instructor-nav a[data-section=' +
|
||||
url.substr(hashbang + 2) + ']';
|
||||
$(selector).click();
|
||||
}
|
||||
else {
|
||||
$(".instructor-nav a").first().click();
|
||||
}
|
||||
}
|
||||
|
||||
function setup_management_form() {
|
||||
|
||||
$(".member-lists-management form").on("submit", function (event) {
|
||||
var target, action;
|
||||
target = $(event.target);
|
||||
if (target.serialize().indexOf('student-action') < 0) {
|
||||
action = $('<input />', {
|
||||
type: 'hidden',
|
||||
name: 'student-action',
|
||||
value: 'add'
|
||||
});
|
||||
target.append(action);
|
||||
}
|
||||
});
|
||||
|
||||
$(".member-lists-management form .add, .member-lists-management form .revoke").on("click", function(event) {
|
||||
var target, form, action, studentId, selectedStudent;
|
||||
event.preventDefault();
|
||||
target = $(event.target);
|
||||
form = target.parents('form').first();
|
||||
if (target.hasClass('add')) {
|
||||
// adding a new student, add the student-action input and submit
|
||||
action = $('<input />', {
|
||||
type: 'hidden',
|
||||
name: 'student-action',
|
||||
// this is untenable, tied to a translated value. Fix it.
|
||||
value: 'add'
|
||||
});
|
||||
form.append(action).submit();
|
||||
} else if (target.hasClass('revoke')) {
|
||||
// revoking access for a student, get set form values and submit
|
||||
// get the email address of the student, since they might not be 'enrolled' yet.
|
||||
selectedStudent = target.parent('td').siblings().last().text();
|
||||
action = $('<input />', {
|
||||
type: 'hidden',
|
||||
name: 'student-action',
|
||||
value: 'revoke'
|
||||
});
|
||||
studentId = $('<input />', {
|
||||
type: 'hidden',
|
||||
name: 'student-id',
|
||||
value: selectedStudent
|
||||
});
|
||||
form.append(action, studentId).submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$(setup_tabs);
|
||||
$(setup_management_form)
|
||||
|
||||
</script>
|
||||
43
lms/templates/ccx/enroll_email_allowedmessage.txt
Normal file
43
lms/templates/ccx/enroll_email_allowedmessage.txt
Normal file
@@ -0,0 +1,43 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
${_("Dear student,")}
|
||||
|
||||
${_("You have been invited to join {course_name} at {site_name} by a "
|
||||
"member of the course staff.").format(
|
||||
course_name=course.display_name,
|
||||
site_name=site_name
|
||||
)}
|
||||
% if is_shib_course:
|
||||
% if auto_enroll:
|
||||
|
||||
${_("To access the course visit {course_url} and login.").format(course_url=course_url)}
|
||||
% elif course_about_url is not None:
|
||||
|
||||
${_("To access the course visit {course_about_url} and register for the course.").format(
|
||||
course_about_url=course_about_url)}
|
||||
% endif
|
||||
% else:
|
||||
|
||||
${_("To finish your registration, please visit {registration_url} and fill "
|
||||
"out the registration form making sure to use {email_address} in the E-mail field.").format(
|
||||
registration_url=registration_url,
|
||||
email_address=email_address
|
||||
)}
|
||||
% if auto_enroll:
|
||||
${_("Once you have registered and activated your account, you will see "
|
||||
"{course_name} listed on your dashboard.").format(
|
||||
course_name=course.display_name
|
||||
)}
|
||||
% elif course_about_url is not None:
|
||||
${_("Once you have registered and activated your account, visit {course_about_url} "
|
||||
"to join the course.").format(course_about_url=course_about_url)}
|
||||
% else:
|
||||
${_("You can then enroll in {course_name}.").format(course_name=course.display_name)}
|
||||
% endif
|
||||
% endif
|
||||
|
||||
----
|
||||
${_("This email was automatically sent from {site_name} to "
|
||||
"{email_address}").format(
|
||||
site_name=site_name, email_address=email_address
|
||||
)}
|
||||
5
lms/templates/ccx/enroll_email_allowedsubject.txt
Normal file
5
lms/templates/ccx/enroll_email_allowedsubject.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
${_("You have been invited to register for {course_name}").format(
|
||||
course_name=course.display_name
|
||||
)}
|
||||
20
lms/templates/ccx/enroll_email_enrolledmessage.txt
Normal file
20
lms/templates/ccx/enroll_email_enrolledmessage.txt
Normal file
@@ -0,0 +1,20 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
${_("Dear {full_name}").format(full_name=full_name)}
|
||||
|
||||
${_("You have been enrolled in {course_name} at {site_name} by a member "
|
||||
"of the course staff. The course should now appear on your {site_name} "
|
||||
"dashboard.").format(
|
||||
course_name=course.display_name,
|
||||
site_name=site_name
|
||||
)}
|
||||
|
||||
${_("To start accessing course materials, please visit {course_url}").format(
|
||||
course_url=course_url
|
||||
)}
|
||||
|
||||
----
|
||||
${_("This email was automatically sent from {site_name} to "
|
||||
"{full_name}").format(
|
||||
site_name=site_name, full_name=full_name
|
||||
)}
|
||||
5
lms/templates/ccx/enroll_email_enrolledsubject.txt
Normal file
5
lms/templates/ccx/enroll_email_enrolledsubject.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
${_("You have been enrolled in {course_name}").format(
|
||||
course_name=course.display_name
|
||||
)}
|
||||
81
lms/templates/ccx/enrollment.html
Normal file
81
lms/templates/ccx/enrollment.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
<div class="batch-enrollment" style="float:left;width:50%">
|
||||
<form method="POST" action="ccx_invite">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${ csrf_token }">
|
||||
<h2> ${_("Batch Enrollment")} </h2>
|
||||
<p>
|
||||
<label for="student-ids">
|
||||
${_("Enter email addresses and/or usernames separated by new lines or commas.")}
|
||||
${_("You will not get notification for emails that bounce, so please double-check spelling.")} </label>
|
||||
<textarea rows="6" name="student-ids" placeholder="${_("Email Addresses/Usernames")}" spellcheck="false"></textarea>
|
||||
</p>
|
||||
|
||||
<div class="enroll-option">
|
||||
<input type="checkbox" name="auto-enroll" value="Auto-Enroll" checked="yes">
|
||||
<label style="display:inline" for="auto-enroll">${_("Auto Enroll")}</label>
|
||||
<div class="hint auto-enroll-hint">
|
||||
<span class="hint-caret"></span>
|
||||
<p>
|
||||
${_("If this option is <em>checked</em>, users who have not yet registered for {platform_name} will be automatically enrolled.").format(platform_name=settings.PLATFORM_NAME)}
|
||||
${_("If this option is left <em>unchecked</em>, users who have not yet registered for {platform_name} will not be enrolled, but will be allowed to enroll once they make an account.").format(platform_name=settings.PLATFORM_NAME)}
|
||||
<br /><br />
|
||||
${_("Checking this box has no effect if 'Unenroll' is selected.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="enroll-option">
|
||||
<input type="checkbox" name="email-students" value="Notify-students-by-email" checked="yes">
|
||||
<label style="display:inline" for="email-students">${_("Notify users by email")}</label>
|
||||
<div class="hint email-students-hint">
|
||||
<span class="hint-caret"></span>
|
||||
<p>
|
||||
${_("If this option is <em>checked</em>, users will receive an email notification.")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input type="submit" name="enrollment-button" class="enrollment-button" value="${_("Enroll")}">
|
||||
<input type="submit" name="enrollment-button" class="enrollment-button" value="${_("Unenroll")}">
|
||||
</div>
|
||||
<div class="request-response"></div>
|
||||
<div class="request-response-error"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="member-lists-management" style="float:left;width:50%">
|
||||
<form method="POST" action="ccx_manage_student">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${ csrf_token }">
|
||||
<div class="auth-list-container active">
|
||||
<div class="member-list-widget">
|
||||
<div class="member-list">
|
||||
<h2> ${_("Student List Management")}</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td class="label">Username</td>
|
||||
<td class="label">Email</td>
|
||||
<td class="label">Revoke access</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
%for member in ccx_members:
|
||||
<tr>
|
||||
<td>${member.student}</td>
|
||||
<td>${member.student.email}</td>
|
||||
<td><div class="revoke"><i class="icon-remove-sign"></i> Revoke access</div></td>
|
||||
</tr>
|
||||
%endfor
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="bottom-bar">
|
||||
<input name="student-id" class="add-field" placeholder="Enter username or email" type="text">
|
||||
<input name="student-action" class="add" value="Add Student" type="button">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
23
lms/templates/ccx/grading_policy.html
Normal file
23
lms/templates/ccx/grading_policy.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
<div id="warn-coach" class="wrapper-msg urgency-high warning">
|
||||
<div class="msg">
|
||||
<i class="msg-icon icon-warning-sign"></i>
|
||||
<div class="msg-content">
|
||||
<h3 class="title">${_("WARNING")}</h3>
|
||||
<div class="copy">
|
||||
<p>${_("For advanced users only. Errors in the grading policy can lead to the course failing to display. This form does not check the validity of the policy before saving.")}</p>
|
||||
<p>${_("Most coaches should not need to make changes to the grading policy.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>${_("Grading Policy")}</h2>
|
||||
|
||||
<form action="${grading_policy_url}" method="POST">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"/>
|
||||
<textarea cols="80" style="height: 500px;"
|
||||
name="policy" id="grading-policy">${grading_policy}</textarea><br/>
|
||||
<button type="submit">${_("Save Grading Policy")}</button>
|
||||
</form>
|
||||
128
lms/templates/ccx/schedule.html
Normal file
128
lms/templates/ccx/schedule.html
Normal file
@@ -0,0 +1,128 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
|
||||
<%block name="jsextra">
|
||||
<script>
|
||||
var save_url = '${save_url}';
|
||||
var schedule = ${schedule};
|
||||
</script>
|
||||
<script src="${static.url('js/vendor/backbone-min.js')}"></script>
|
||||
<script src="${static.url('js/vendor/timepicker/jquery.timepicker.js')}"></script>
|
||||
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
|
||||
<style>
|
||||
.ui-timepicker-list { z-index: 100000; }
|
||||
.ui-datepicker { z-index: 100000 !important; }
|
||||
input.date, input.time { width: auto !important; display: inline !important; }
|
||||
</style>
|
||||
<%static:js group='ccx'/>
|
||||
</%block>
|
||||
|
||||
%for template_name in ["schedule"]:
|
||||
<script type="text/template" id="ccx-${template_name}-template">
|
||||
<%static:include path="ccx/${template_name}.underscore" />
|
||||
</script>
|
||||
%endfor
|
||||
|
||||
<div class="ccx-schedule-container">
|
||||
<div id="ccx-schedule"></div>
|
||||
<div id="new-ccx-schedule"></div>
|
||||
</div>
|
||||
|
||||
<section id="enter-date-modal" class="modal" aria-hidden="true">
|
||||
<div class="inner-wrapper" role="dialog">
|
||||
<button class="close-modal">
|
||||
<i class="fa-remove"></i>
|
||||
<span class="sr">
|
||||
${_("Close")}
|
||||
</span>
|
||||
</button>
|
||||
<header>
|
||||
<h2></h2>
|
||||
</header>
|
||||
<form role="form">
|
||||
<div class="field datepair">
|
||||
<label></label>
|
||||
<input placeholder="Date" class="date" type="text" name="date"/ size="11">
|
||||
<input placeholder="Time" class="time" type="text" name="time"/ size="6">
|
||||
</div>
|
||||
<div class="field">
|
||||
<button type="submit" class="btn btn-primary">${_('Set date')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ccx-schedule-sidebar">
|
||||
<div class="ccx-sidebar-panel" id="dirty-schedule">
|
||||
<h2>${_('Save changes')}</h2>
|
||||
<form role="form">
|
||||
<p>${_("You have unsaved changes.")}</p>
|
||||
<div class="field">
|
||||
<br/>
|
||||
<button id="save-changes">${_("Save changes")}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="ccx-sidebar-panel" id="ajax-error">
|
||||
<h2>${_('Error')}</h2>
|
||||
<p>${_("There was an error saving changes.")}</p>
|
||||
</div>
|
||||
<div class="ccx-sidebar-panel">
|
||||
<h2>${_('Schedule a Unit')}</h2>
|
||||
<form role="form" id="add-unit" name="add-unit" class="ccx-form">
|
||||
<div class="field">
|
||||
<b>${_('Section')}</b><br/>
|
||||
<select name="chapter"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<b>${_('Subsection')}</b><br/>
|
||||
<select name="sequential"></select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<b>${_('Unit')}</b><br/>
|
||||
<select name="vertical"></select>
|
||||
</div>
|
||||
<div class="field datepair">
|
||||
<b>${_('Start Date')}</b><br/>
|
||||
<input placeholder="Date" type="date" class="date" name="start_date"/>
|
||||
<input placeholder="time" type="time" class="time" name="start_time"/>
|
||||
</div>
|
||||
<div class="field datepair">
|
||||
<b>${_('Due Date')}</b> ${_('(Optional)')}<br/>
|
||||
<input placeholder="Date" type="date" class="date" name="due_date"/>
|
||||
<input placeholder="time" type="time" class="time" name="due_time"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<br/>
|
||||
<button id="add-unit-button">${_('Add Unit')}</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<br/>
|
||||
<button id="add-all">${_('Add All Units')}</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="all-units-added">
|
||||
${_("All units have been added.")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
$(function() {
|
||||
schedule_template = _.template($('#ccx-schedule-template').html());
|
||||
var view = new edx.ccx.schedule.ScheduleView({
|
||||
el: $('#new-ccx-schedule')
|
||||
});
|
||||
view.render();
|
||||
//ccx_schedule.render();
|
||||
$('.datepair .time').timepicker({
|
||||
'showDuration': true,
|
||||
'timeFormat': 'G:i'
|
||||
});
|
||||
$('.datepair .date').datepicker({
|
||||
'dateFormat': 'yy-mm-dd',
|
||||
'autoclose': true
|
||||
});
|
||||
});
|
||||
</script>
|
||||
52
lms/templates/ccx/schedule.underscore
Normal file
52
lms/templates/ccx/schedule.underscore
Normal file
@@ -0,0 +1,52 @@
|
||||
<table class="ccx-schedule">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%- gettext('Unit') %></th>
|
||||
<th><%- gettext('Start Date') %></th>
|
||||
<th><%- gettext('Due Date') %></th>
|
||||
<th><a href="#" id="remove-all">
|
||||
<i class="fa fa-remove"></i> <%- gettext('remove all') %>
|
||||
</a></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% _.each(chapters, function(chapter) { %>
|
||||
<tr class="chapter collapsed" data-location="<%= chapter.location %>" data-depth="1">
|
||||
<td class="unit">
|
||||
<a href="#"><i class="fa fa-caret-right toggle-collapse"></i></a>
|
||||
<%= chapter.display_name %>
|
||||
</td>
|
||||
<td class="date start-date"><a><%= chapter.start %></a></td>
|
||||
<td class="date due-date"><a><%= chapter.due %></a></td>
|
||||
<td><a href="#" class="remove-unit">
|
||||
<i class="fa fa-remove"></i> <%- gettext('remove') %>
|
||||
</a></td>
|
||||
</tr>
|
||||
<% _.each(chapter.children, function(child) { %>
|
||||
<tr class="sequential collapsed" data-depth="2"
|
||||
data-location="<%= chapter.location %> <%= child.location %>">
|
||||
<td class="unit">
|
||||
<a href="#"><i class="fa fa-caret-right toggle-collapse"></i></a>
|
||||
<%= child.display_name %>
|
||||
</td>
|
||||
<td class="date start-date"><a><%= child.start %></a></td>
|
||||
<td class="date due-date"><a><%= child.due %></a></td>
|
||||
<td><a href="#" class="remove-unit">
|
||||
<i class="fa fa-remove"></i> <%- gettext('remove') %>
|
||||
</a></td>
|
||||
</tr>
|
||||
<% _.each(child.children, function(subchild) { %>
|
||||
<tr class="vertical" data-dapth="3"
|
||||
data-location="<%= chapter.location %> <%= child.location %> <%= subchild.location %>">
|
||||
<td class="unit"> <%= subchild.display_name %></td>
|
||||
<td class="date start-date"><a><%= subchild.start %></a></td>
|
||||
<td class="date due-date"><a><%= subchild.due %></a></td>
|
||||
<td><a href="#" class="remove-unit">
|
||||
<i class="fa fa-remove"></i> <%- gettext('remove') %>
|
||||
</a></td>
|
||||
<% }); %>
|
||||
<% }); %>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
11
lms/templates/ccx/student_admin.html
Normal file
11
lms/templates/ccx/student_admin.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
<section>
|
||||
<h2>${_('Student Grades')}</h2>
|
||||
<p>
|
||||
<a href="${gradebook_url}">${_('View gradebook')}</a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="${grades_csv_url}">${_('Download student grades')}</a>
|
||||
</p>
|
||||
</section>
|
||||
13
lms/templates/ccx/unenroll_email_allowedmessage.txt
Normal file
13
lms/templates/ccx/unenroll_email_allowedmessage.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
${_("Dear Student,")}
|
||||
|
||||
${_("You have been un-enrolled from course {course_name} by a member "
|
||||
"of the course staff. Please disregard the invitation "
|
||||
"previously sent.").format(course_name=course.display_name)}
|
||||
|
||||
----
|
||||
${_("This email was automatically sent from {site_name} "
|
||||
"to {email_address}").format(
|
||||
site_name=site_name, email_address=email_address
|
||||
)}
|
||||
17
lms/templates/ccx/unenroll_email_enrolledmessage.txt
Normal file
17
lms/templates/ccx/unenroll_email_enrolledmessage.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
${_("Dear {full_name}").format(full_name=full_name)}
|
||||
|
||||
${_("You have been un-enrolled in {course_name} at {site_name} by a member "
|
||||
"of the course staff. The course will no longer appear on your "
|
||||
"{site_name} dashboard.").format(
|
||||
course_name=course.display_name, site_name=site_name
|
||||
)}
|
||||
|
||||
${_("Your other courses have not been affected.")}
|
||||
|
||||
----
|
||||
${_("This email was automatically sent from {site_name} to "
|
||||
"{full_name}").format(
|
||||
full_name=full_name, site_name=site_name
|
||||
)}
|
||||
5
lms/templates/ccx/unenroll_email_subject.txt
Normal file
5
lms/templates/ccx/unenroll_email_subject.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
${_("You have been un-enrolled from {course_name}").format(
|
||||
course_name=course.display_name
|
||||
)}
|
||||
@@ -94,6 +94,13 @@
|
||||
<% course_requirements = courses_requirements_not_met.get(course.id) %>
|
||||
<%include file='dashboard/_dashboard_course_listing.html' args="course=course, enrollment=enrollment, show_courseware_link=show_courseware_link, cert_status=cert_status, show_email_settings=show_email_settings, course_mode_info=course_mode_info, show_refund_option = show_refund_option, is_paid_course = is_paid_course, is_course_blocked = is_course_blocked, verification_status=course_verification_status, course_requirements=course_requirements, dashboard_index=dashboard_index, share_settings=share_settings" />
|
||||
% endfor
|
||||
|
||||
% if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
|
||||
% for ccx, membership, course in ccx_membership_triplets:
|
||||
<%include file='ccx/_dashboard_ccx_listing.html' args="ccx=ccx, membership=membership, course=course" />
|
||||
% endfor
|
||||
% endif
|
||||
|
||||
</ul>
|
||||
% else:
|
||||
<section class="empty-dashboard-message">
|
||||
|
||||
@@ -243,5 +243,18 @@
|
||||
data-add-button-label="${_("Add Community TA")}"
|
||||
></div>
|
||||
%endif
|
||||
|
||||
|
||||
%if section_data['access']['instructor'] and settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
|
||||
<div class="auth-list-container"
|
||||
data-rolename="ccx_coach"
|
||||
data-display-name="${_("CCX Coaches")}"
|
||||
data-info-text="
|
||||
${_("CCX Coaches are able to create their own Custom Courses "
|
||||
"based on this course, which they can use to provide personalized "
|
||||
"instruction to their own students based in this course material.")}"
|
||||
data-list-endpoint="${section_data['list_course_role_members_url']}"
|
||||
data-modify-endpoint="${section_data['modify_access_url']}"
|
||||
data-add-button-label="${_("Add CCX Coach")}"
|
||||
></div>
|
||||
%endif
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@ from status.status import get_site_status_msg
|
||||
|
||||
<%! from microsite_configuration import microsite %>
|
||||
<%! from microsite_configuration.templatetags.microsite import platform_name %>
|
||||
<%! from ccx.overrides import get_current_ccx %>
|
||||
|
||||
## Provide a hook for themes to inject branding on top.
|
||||
<%block name="navigation_top" />
|
||||
@@ -47,7 +48,16 @@ site_status_msg = get_site_status_msg(course_id)
|
||||
</h1>
|
||||
|
||||
% if course:
|
||||
<h2><span class="provider">${course.display_org_with_default | h}:</span> ${course.display_number_with_default | h} ${course.display_name_with_default}</h2>
|
||||
<h2><span class="provider">${course.display_org_with_default | h}:</span>
|
||||
${course.display_number_with_default | h}
|
||||
<%
|
||||
display_name = course.display_name_with_default
|
||||
if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
|
||||
ccx = get_current_ccx()
|
||||
if ccx:
|
||||
display_name = ccx.display_name
|
||||
%>
|
||||
${display_name}</h2>
|
||||
% endif
|
||||
|
||||
% if user.is_authenticated():
|
||||
|
||||
20
lms/urls.py
20
lms/urls.py
@@ -343,6 +343,26 @@ if settings.COURSEWARE_ENABLED:
|
||||
# For the instructor
|
||||
url(r'^courses/{}/instructor$'.format(settings.COURSE_ID_PATTERN),
|
||||
'instructor.views.instructor_dashboard.instructor_dashboard_2', name="instructor_dashboard"),
|
||||
url(r'^courses/{}/ccx_coach$'.format(settings.COURSE_ID_PATTERN),
|
||||
'ccx.views.dashboard', name='ccx_coach_dashboard'),
|
||||
url(r'^courses/{}/create_ccx$'.format(settings.COURSE_ID_PATTERN),
|
||||
'ccx.views.create_ccx', name='create_ccx'),
|
||||
url(r'^courses/{}/save_ccx$'.format(settings.COURSE_ID_PATTERN),
|
||||
'ccx.views.save_ccx', name='save_ccx'),
|
||||
url(r'^courses/{}/ccx_invite$'.format(settings.COURSE_ID_PATTERN),
|
||||
'ccx.views.ccx_invite', name='ccx_invite'),
|
||||
url(r'^courses/{}/ccx_schedule$'.format(settings.COURSE_ID_PATTERN),
|
||||
'ccx.views.ccx_schedule', name='ccx_schedule'),
|
||||
url(r'^courses/{}/ccx_manage_student$'.format(settings.COURSE_ID_PATTERN),
|
||||
'ccx.views.ccx_student_management', name='ccx_manage_student'),
|
||||
url(r'^courses/{}/ccx_gradebook$'.format(settings.COURSE_ID_PATTERN),
|
||||
'ccx.views.ccx_gradebook', name='ccx_gradebook'),
|
||||
url(r'^courses/{}/ccx_grades.csv$'.format(settings.COURSE_ID_PATTERN),
|
||||
'ccx.views.ccx_grades_csv', name='ccx_grades_csv'),
|
||||
url(r'^courses/{}/ccx_set_grading_policy$'.format(settings.COURSE_ID_PATTERN),
|
||||
'ccx.views.set_grading_policy', name='ccx_set_grading_policy'),
|
||||
url(r'^courses/{}/switch_ccx(?:/(?P<ccx_id>[\d]+))?$'.format(settings.COURSE_ID_PATTERN),
|
||||
'ccx.views.switch_active_ccx', name='switch_active_ccx'),
|
||||
url(r'^courses/{}/set_course_mode_price$'.format(settings.COURSE_ID_PATTERN),
|
||||
'instructor.views.instructor_dashboard.set_course_mode_price', name="set_course_mode_price"),
|
||||
url(r'^courses/{}/instructor/api/'.format(settings.COURSE_ID_PATTERN),
|
||||
|
||||
Reference in New Issue
Block a user