diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py
index c1b78748e4..b2127bee41 100644
--- a/common/djangoapps/student/roles.py
+++ b/common/djangoapps/student/roles.py
@@ -267,6 +267,14 @@ class LibraryUserRole(CourseRole):
super(LibraryUserRole, self).__init__(self.ROLE, *args, **kwargs)
+class CourseCcxCoachRole(CourseRole):
+ """A CCX Coach"""
+ ROLE = 'ccx_coach'
+
+ def __init__(self, *args, **kwargs):
+ super(CourseCcxCoachRole, self).__init__(self.ROLE, *args, **kwargs)
+
+
class OrgStaffRole(OrgRole):
"""An organization staff member"""
def __init__(self, *args, **kwargs):
diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py
index 8a06ff60c8..9aad5d538a 100644
--- a/common/djangoapps/student/views.py
+++ b/common/djangoapps/student/views.py
@@ -662,6 +662,17 @@ def dashboard(request):
if course.pre_requisite_courses)
courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites)
+ ccx_membership_triplets = []
+ if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
+ from ccx import ACTIVE_CCX_KEY
+ from ccx.utils import get_ccx_membership_triplets
+ ccx_membership_triplets = get_ccx_membership_triplets(
+ user, course_org_filter, org_filter_out_set
+ )
+ # should we deselect any active CCX at this time so that we don't have
+ # to change the URL for viewing a course? I think so.
+ request.session[ACTIVE_CCX_KEY] = None
+
context = {
'enrollment_message': enrollment_message,
'course_enrollment_pairs': course_enrollment_pairs,
@@ -693,6 +704,7 @@ def dashboard(request):
'provider_states': [],
'order_history_list': order_history_list,
'courses_requirements_not_met': courses_requirements_not_met,
+ 'ccx_membership_triplets': ccx_membership_triplets,
}
if third_party_auth.is_enabled():
@@ -1810,6 +1822,16 @@ def activate_account(request, key):
if cea.auto_enroll:
CourseEnrollment.enroll(student[0], cea.course_id)
+ # enroll student in any pending CCXs he/she may have if auto_enroll flag is set
+ if settings.FEATURES.get('CUSTOM_COURSES_EDX'):
+ from ccx.models import CcxMembership, CcxFutureMembership
+ ccxfms = CcxFutureMembership.objects.filter(
+ email=student[0].email
+ )
+ for ccxfm in ccxfms:
+ if ccxfm.auto_enroll:
+ CcxMembership.auto_enroll(student[0], ccxfm)
+
resp = render_to_response(
"registration/activation_complete.html",
{
diff --git a/common/lib/xmodule/xmodule/capa_base.py b/common/lib/xmodule/xmodule/capa_base.py
index 3b8ce984c2..3082933fe8 100644
--- a/common/lib/xmodule/xmodule/capa_base.py
+++ b/common/lib/xmodule/xmodule/capa_base.py
@@ -26,7 +26,6 @@ from xmodule.exceptions import NotFoundError
from xblock.fields import Scope, String, Boolean, Dict, Integer, Float
from .fields import Timedelta, Date
from django.utils.timezone import UTC
-from .util.duedate import get_extended_due_date
from xmodule.capa_base_constants import RANDOMIZATION, SHOWANSWER
from django.conf import settings
@@ -107,14 +106,6 @@ class CapaFields(object):
values={"min": 0}, scope=Scope.settings
)
due = Date(help=_("Date that this problem is due by"), scope=Scope.settings)
- extended_due = Date(
- help=_("Date that this problem is due by for a particular student. This "
- "can be set by an instructor, and will override the global due "
- "date if it is set to a date that is later than the global due "
- "date."),
- default=None,
- scope=Scope.user_state,
- )
graceperiod = Timedelta(
help=_("Amount of time after the due date that submissions will be accepted"),
scope=Scope.settings
@@ -218,7 +209,7 @@ class CapaMixin(CapaFields):
def __init__(self, *args, **kwargs):
super(CapaMixin, self).__init__(*args, **kwargs)
- due_date = get_extended_due_date(self)
+ due_date = self.due
if self.graceperiod is not None and due_date:
self.close_date = due_date + self.graceperiod
diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py
index 03b1f217d5..7e3d1fe8cc 100644
--- a/common/lib/xmodule/xmodule/combined_open_ended_module.py
+++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py
@@ -23,7 +23,6 @@ V1_SETTINGS_ATTRIBUTES = [
"accept_file_upload",
"skip_spelling_checks",
"due",
- "extended_due",
"graceperiod",
"weight",
"min_to_calibrate",
@@ -258,16 +257,6 @@ class CombinedOpenEndedFields(object):
help=_("Date that this problem is due by"),
scope=Scope.settings
)
- extended_due = Date(
- help=_(
- "Date that this problem is due by for a particular student. This "
- "can be set by an instructor, and will override the global due "
- "date if it is set to a date that is later than the global due "
- "date."
- ),
- default=None,
- scope=Scope.user_state,
- )
graceperiod = Timedelta(
help=_("Amount of time after the due date that submissions will be accepted"),
scope=Scope.settings
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py
index 48a6807510..e71fa5fdb8 100644
--- a/common/lib/xmodule/xmodule/course_module.py
+++ b/common/lib/xmodule/xmodule/course_module.py
@@ -11,7 +11,7 @@ from datetime import datetime
import dateutil.parser
from lazy import lazy
-
+from xmodule.exceptions import UndefinedContext
from xmodule.seq_module import SequenceDescriptor, SequenceModule
from xmodule.graders import grader_from_conf
from xmodule.tabs import CourseTabList
@@ -835,8 +835,16 @@ class CourseFields(object):
)
+class CourseModule(CourseFields, SequenceModule): # pylint: disable=abstract-method
+ """
+ The CourseDescriptor needs its module_class to be a SequenceModule, but some code that
+ expects a CourseDescriptor to have all its fields can fail if it gets a SequenceModule instead.
+ This class is to make sure that all the fields are present in all cases.
+ """
+
+
class CourseDescriptor(CourseFields, SequenceDescriptor):
- module_class = SequenceModule
+ module_class = CourseModule
def __init__(self, *args, **kwargs):
"""
@@ -1213,6 +1221,14 @@ class CourseDescriptor(CourseFields, SequenceDescriptor):
"""
+ # If this descriptor has been bound to a student, return the corresponding
+ # XModule. If not, just use the descriptor itself
+ try:
+ module = getattr(self, '_xmodule', None)
+ if not module:
+ module = self
+ except UndefinedContext:
+ module = self
all_descriptors = []
graded_sections = {}
diff --git a/common/lib/xmodule/xmodule/foldit_module.py b/common/lib/xmodule/xmodule/foldit_module.py
index 666d247bfd..de5957db94 100644
--- a/common/lib/xmodule/xmodule/foldit_module.py
+++ b/common/lib/xmodule/xmodule/foldit_module.py
@@ -8,7 +8,6 @@ from xmodule.x_module import XModule
from xmodule.xml_module import XmlDescriptor
from xblock.fields import Scope, Integer, String
from .fields import Date
-from .util.duedate import get_extended_due_date
log = logging.getLogger(__name__)
@@ -21,14 +20,6 @@ class FolditFields(object):
required_level = Integer(default=4, scope=Scope.settings)
required_sublevel = Integer(default=5, scope=Scope.settings)
due = Date(help="Date that this problem is due by", scope=Scope.settings)
- extended_due = Date(
- help="Date that this problem is due by for a particular student. This "
- "can be set by an instructor, and will override the global due "
- "date if it is set to a date that is later than the global due "
- "date.",
- default=None,
- scope=Scope.user_state,
- )
show_basic_score = String(scope=Scope.settings, default='false')
show_leaderboard = String(scope=Scope.settings, default='false')
@@ -49,7 +40,7 @@ class FolditModule(FolditFields, XModule):
show_leaderboard="false"/>
"""
super(FolditModule, self).__init__(*args, **kwargs)
- self.due_time = get_extended_due_date(self)
+ self.due_time = self.due
def is_complete(self):
"""
diff --git a/common/lib/xmodule/xmodule/modulestore/inheritance.py b/common/lib/xmodule/xmodule/modulestore/inheritance.py
index 2727e2fa9c..d6293647c7 100644
--- a/common/lib/xmodule/xmodule/modulestore/inheritance.py
+++ b/common/lib/xmodule/xmodule/modulestore/inheritance.py
@@ -44,14 +44,6 @@ class InheritanceMixin(XBlockMixin):
help=_("Enter the default date by which problems are due."),
scope=Scope.settings,
)
- extended_due = Date(
- help="Date that this problem is due by for a particular student. This "
- "can be set by an instructor, and will override the global due "
- "date if it is set to a date that is later than the global due "
- "date.",
- default=None,
- scope=Scope.user_state,
- )
visible_to_staff_only = Boolean(
help=_("If true, can be seen only by course staff, regardless of start date."),
default=False,
diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
index 8e49027203..d99884f392 100644
--- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
+++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py
@@ -8,7 +8,6 @@ from xmodule.progress import Progress
from xmodule.stringify import stringify_children
from xmodule.open_ended_grading_classes import self_assessment_module
from xmodule.open_ended_grading_classes import open_ended_module
-from xmodule.util.duedate import get_extended_due_date
from .combined_open_ended_rubric import CombinedOpenEndedRubric, GRADER_TYPE_IMAGE_DICT, HUMAN_GRADER_TYPE, LEGEND_LIST
from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, MockPeerGradingService
from xmodule.open_ended_grading_classes.openendedchild import OpenEndedChild
@@ -150,7 +149,7 @@ class CombinedOpenEndedV1Module(object):
'peer_grade_finished_submissions_when_none_pending', False
)
- due_date = get_extended_due_date(instance_state)
+ due_date = instance_state.get('due', None)
grace_period_string = instance_state.get('graceperiod', None)
try:
self.timeinfo = TimeInfo(due_date, grace_period_string)
diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py
index 086580f06e..6e0da07a8f 100644
--- a/common/lib/xmodule/xmodule/peer_grading_module.py
+++ b/common/lib/xmodule/xmodule/peer_grading_module.py
@@ -11,7 +11,6 @@ from xmodule.fields import Date, Timedelta
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
from xmodule.raw_module import RawDescriptor
from xmodule.timeinfo import TimeInfo
-from xmodule.util.duedate import get_extended_due_date
from xmodule.x_module import XModule, module_attr
from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, MockPeerGradingService
@@ -52,14 +51,6 @@ class PeerGradingFields(object):
due = Date(
help=_("Due date that should be displayed."),
scope=Scope.settings)
- extended_due = Date(
- help=_("Date that this problem is due by for a particular student. This "
- "can be set by an instructor, and will override the global due "
- "date if it is set to a date that is later than the global due "
- "date."),
- default=None,
- scope=Scope.user_state,
- )
graceperiod = Timedelta(
help=_("Amount of grace to give on the due date."),
scope=Scope.settings
@@ -141,8 +132,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
self.linked_problem = self.system.get_module(linked_descriptors[0])
try:
- self.timeinfo = TimeInfo(
- get_extended_due_date(self), self.graceperiod)
+ self.timeinfo = TimeInfo(self.due, self.graceperiod)
except Exception:
log.error("Error parsing due date information in location {0}".format(self.location))
raise
@@ -570,7 +560,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
except (NoPathToItem, ItemNotFoundError):
continue
if descriptor:
- problem['due'] = get_extended_due_date(descriptor)
+ problem['due'] = descriptor.due
grace_period = descriptor.graceperiod
try:
problem_timeinfo = TimeInfo(problem['due'], grace_period)
diff --git a/common/lib/xmodule/xmodule/seq_module.py b/common/lib/xmodule/xmodule/seq_module.py
index 1bdc289440..137b7dd31a 100644
--- a/common/lib/xmodule/xmodule/seq_module.py
+++ b/common/lib/xmodule/xmodule/seq_module.py
@@ -36,14 +36,6 @@ class SequenceFields(object):
help=_("Enter the date by which problems are due."),
scope=Scope.settings,
)
- extended_due = Date(
- help="Date that this problem is due by for a particular student. This "
- "can be set by an instructor, and will override the global due "
- "date if it is set to a date that is later than the global due "
- "date.",
- default=None,
- scope=Scope.user_state,
- )
# Entrance Exam flag -- see cms/contentstore/views/entrance_exam.py for usage
is_entrance_exam = Boolean(
diff --git a/common/lib/xmodule/xmodule/tabs.py b/common/lib/xmodule/xmodule/tabs.py
index d4b986d3b6..a28432918a 100644
--- a/common/lib/xmodule/xmodule/tabs.py
+++ b/common/lib/xmodule/xmodule/tabs.py
@@ -193,6 +193,7 @@ class CourseTab(object):
'edxnotes': EdxNotesTab,
'syllabus': SyllabusTab,
'instructor': InstructorTab, # not persisted
+ 'ccx_coach': CcxCoachTab, # not persisted
}
tab_type = tab_dict.get('type')
@@ -375,6 +376,10 @@ class DiscussionTab(EnrolledOrStaffTab):
)
def can_display(self, course, settings, is_user_authenticated, is_user_staff, is_user_enrolled):
+ if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
+ from ccx.overrides import get_current_ccx # pylint: disable=import-error
+ if get_current_ccx():
+ return False
super_can_display = super(DiscussionTab, self).can_display(
course, settings, is_user_authenticated, is_user_staff, is_user_enrolled
)
@@ -733,6 +738,42 @@ class InstructorTab(StaffTab):
)
+class CcxCoachTab(CourseTab):
+ """
+ A tab for the custom course coaches.
+ """
+ type = 'ccx_coach'
+
+ def __init__(self, tab_dict=None): # pylint: disable=unused-argument
+ super(CcxCoachTab, self).__init__(
+ name=_('CCX Coach'),
+ tab_id=self.type,
+ link_func=link_reverse_func('ccx_coach_dashboard'),
+ )
+
+ def can_display(self, course, settings, *args, **kw):
+ """
+ Since we don't get the user here, we use a thread local defined in the ccx
+ overrides to get it, then use the course to get the coach role and find out if
+ the user is one.
+ """
+ user_is_coach = False
+ if settings.FEATURES.get('CUSTOM_COURSES_EDX', False):
+ from opaque_keys.edx.locations import SlashSeparatedCourseKey
+ from student.roles import CourseCcxCoachRole # pylint: disable=import-error
+ from ccx.overrides import get_current_request # pylint: disable=import-error
+ course_id = course.id.to_deprecated_string()
+ course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id)
+ role = CourseCcxCoachRole(course_key)
+ request = get_current_request()
+ if request is not None:
+ user_is_coach = role.has_user(request.user)
+ super_can_display = super(CcxCoachTab, self).can_display(
+ course, settings, *args, **kw
+ )
+ return user_is_coach and super_can_display
+
+
class CourseTabList(List):
"""
An XBlock field class that encapsulates a collection of Tabs in a course.
@@ -833,6 +874,9 @@ class CourseTabList(List):
instructor_tab = InstructorTab()
if instructor_tab.can_display(course, settings, is_user_authenticated, is_user_staff, is_user_enrolled):
yield instructor_tab
+ ccx_coach_tab = CcxCoachTab()
+ if ccx_coach_tab.can_display(course, settings, is_user_authenticated, is_user_staff, is_user_enrolled):
+ yield ccx_coach_tab
@staticmethod
def iterate_displayable_cms(
diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py
index d1309fded4..2c666743ae 100644
--- a/common/lib/xmodule/xmodule/tests/test_capa_module.py
+++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py
@@ -430,13 +430,6 @@ class CapaModuleTest(unittest.TestCase):
due=self.yesterday_str)
self.assertTrue(module.closed())
- def test_due_date_extension(self):
-
- module = CapaFactory.create(
- max_attempts="1", attempts="0", due=self.yesterday_str,
- extended_due=self.tomorrow_str)
- self.assertFalse(module.closed())
-
def test_parse_get_params(self):
# Valid GET param dict
@@ -1742,7 +1735,7 @@ class TestProblemCheckTracking(unittest.TestCase):
self.maxDiff = None
def test_choice_answer_text(self):
- factory = self.capa_factory_for_problem_xml("""\
+ xml = """\
What color is the open ocean on a sunny day?
@@ -1767,7 +1760,11 @@ class TestProblemCheckTracking(unittest.TestCase):
- """)
+ """
+
+ # Whitespace screws up comparisons
+ xml = ''.join(line.strip() for line in xml.split('\n'))
+ factory = self.capa_factory_for_problem_xml(xml)
module = factory.create()
answer_input_dict = {
diff --git a/conf/locale/eo/LC_MESSAGES/django.po b/conf/locale/eo/LC_MESSAGES/django.po
index 9114ffed40..eb351c00af 100644
--- a/conf/locale/eo/LC_MESSAGES/django.po
+++ b/conf/locale/eo/LC_MESSAGES/django.po
@@ -1554,19 +1554,6 @@ msgstr ""
msgid "Date that this problem is due by"
msgstr "Däté thät thïs prößlém ïs düé ßý Ⱡ'σяєм ι#"
-#: common/lib/xmodule/xmodule/capa_base.py
-#: common/lib/xmodule/xmodule/combined_open_ended_module.py
-#: common/lib/xmodule/xmodule/peer_grading_module.py
-msgid ""
-"Date that this problem is due by for a particular student. This can be set "
-"by an instructor, and will override the global due date if it is set to a "
-"date that is later than the global due date."
-msgstr ""
-"Däté thät thïs prößlém ïs düé ßý för ä pärtïçülär stüdént. Thïs çän ßé sét "
-"ßý än ïnstrüçtör, änd wïll övérrïdé thé glößäl düé däté ïf ït ïs sét tö ä "
-"däté thät ïs lätér thän thé glößäl düé däté. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, "
-"¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє#"
-
#: common/lib/xmodule/xmodule/capa_base.py
#: common/lib/xmodule/xmodule/combined_open_ended_module.py
msgid "Amount of time after the due date that submissions will be accepted"
@@ -11920,6 +11907,19 @@ msgstr "änd çhöösé ýöür stüdént träçk Ⱡ'σяєм #"
msgid "and proceed to verification"
msgstr "änd pröçééd tö vérïfïçätïön Ⱡ'σяєм#"
+#. Translators: This line appears next a checkbox which users can leave
+#. checked
+#. or uncheck in order
+#. to indicate whether they want to receive emails from the organization
+#. offering the course.
+#: lms/templates/courseware/mktg_course_about.html
+msgid ""
+"I would like to receive email about other {organization_full_name} programs "
+"and offers."
+msgstr ""
+"Ì wöüld lïké tö réçéïvé émäïl äßöüt öthér {organization_full_name} prögräms "
+"änd öfférs. Ⱡ'σяєм ιρѕυм ∂σłσя #"
+
#: lms/templates/courseware/mktg_course_about.html
msgid "Enrollment Is Closed"
msgstr "Énröllmént Ìs Çlöséd Ⱡ'σя#"
diff --git a/lms/djangoapps/ccx/__init__.py b/lms/djangoapps/ccx/__init__.py
new file mode 100644
index 0000000000..3584f0006a
--- /dev/null
+++ b/lms/djangoapps/ccx/__init__.py
@@ -0,0 +1,4 @@
+"""
+we use this to mark the active ccx, for use by ccx middleware and some views
+"""
+ACTIVE_CCX_KEY = '_ccx_id'
diff --git a/lms/djangoapps/ccx/migrations/0001_initial.py b/lms/djangoapps/ccx/migrations/0001_initial.py
new file mode 100644
index 0000000000..6ec2fe67ce
--- /dev/null
+++ b/lms/djangoapps/ccx/migrations/0001_initial.py
@@ -0,0 +1,136 @@
+# -*- coding: utf-8 -*-
+# pylint: disable=invalid-name, missing-docstring, unused-argument, unused-import, line-too-long
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+ # Adding model 'CustomCourseForEdX'
+ db.create_table('ccx_customcourseforedx', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('course_id', self.gf('xmodule_django.models.CourseKeyField')(max_length=255, db_index=True)),
+ ('display_name', self.gf('django.db.models.fields.CharField')(max_length=255)),
+ ('coach', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
+ ))
+ db.send_create_signal('ccx', ['CustomCourseForEdX'])
+
+ # Adding model 'CcxMembership'
+ db.create_table('ccx_ccxmembership', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('ccx', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['ccx.CustomCourseForEdX'])),
+ ('student', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])),
+ ('active', self.gf('django.db.models.fields.BooleanField')(default=False)),
+ ))
+ db.send_create_signal('ccx', ['CcxMembership'])
+
+ # Adding model 'CcxFutureMembership'
+ db.create_table('ccx_ccxfuturemembership', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('ccx', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['ccx.CustomCourseForEdX'])),
+ ('email', self.gf('django.db.models.fields.CharField')(max_length=255)),
+ ('auto_enroll', self.gf('django.db.models.fields.BooleanField')(default=False)),
+ ))
+ db.send_create_signal('ccx', ['CcxFutureMembership'])
+
+ # Adding model 'CcxFieldOverride'
+ db.create_table('ccx_ccxfieldoverride', (
+ ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)),
+ ('ccx', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['ccx.CustomCourseForEdX'])),
+ ('location', self.gf('xmodule_django.models.LocationKeyField')(max_length=255, db_index=True)),
+ ('field', self.gf('django.db.models.fields.CharField')(max_length=255)),
+ ('value', self.gf('django.db.models.fields.TextField')(default='null')),
+ ))
+ db.send_create_signal('ccx', ['CcxFieldOverride'])
+
+ # Adding unique constraint on 'CcxFieldOverride', fields ['ccx', 'location', 'field']
+ db.create_unique('ccx_ccxfieldoverride', ['ccx_id', 'location', 'field'])
+
+ def backwards(self, orm):
+ # Removing unique constraint on 'CcxFieldOverride', fields ['ccx', 'location', 'field']
+ db.delete_unique('ccx_ccxfieldoverride', ['ccx_id', 'location', 'field'])
+
+ # Deleting model 'CustomCourseForEdX'
+ db.delete_table('ccx_customcourseforedx')
+
+ # Deleting model 'CcxMembership'
+ db.delete_table('ccx_ccxmembership')
+
+ # Deleting model 'CcxFutureMembership'
+ db.delete_table('ccx_ccxfuturemembership')
+
+ # Deleting model 'CcxFieldOverride'
+ db.delete_table('ccx_ccxfieldoverride')
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'ccx.ccxfieldoverride': {
+ 'Meta': {'unique_together': "(('ccx', 'location', 'field'),)", 'object_name': 'CcxFieldOverride'},
+ 'ccx': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ccx.CustomCourseForEdX']"}),
+ 'field': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'location': ('xmodule_django.models.LocationKeyField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"})
+ },
+ 'ccx.ccxfuturemembership': {
+ 'Meta': {'object_name': 'CcxFutureMembership'},
+ 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'ccx': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ccx.CustomCourseForEdX']"}),
+ 'email': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+ },
+ 'ccx.ccxmembership': {
+ 'Meta': {'object_name': 'CcxMembership'},
+ 'active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'ccx': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['ccx.CustomCourseForEdX']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+ },
+ 'ccx.customcourseforedx': {
+ 'Meta': {'object_name': 'CustomCourseForEdX'},
+ 'coach': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}),
+ 'course_id': ('xmodule_django.models.CourseKeyField', [], {'max_length': '255', 'db_index': 'True'}),
+ 'display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ }
+ }
+
+ complete_apps = ['ccx']
diff --git a/lms/djangoapps/ccx/migrations/__init__.py b/lms/djangoapps/ccx/migrations/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/lms/djangoapps/ccx/models.py b/lms/djangoapps/ccx/models.py
new file mode 100644
index 0000000000..048b63d9c2
--- /dev/null
+++ b/lms/djangoapps/ccx/models.py
@@ -0,0 +1,77 @@
+"""
+Models for the custom course feature
+"""
+from django.contrib.auth.models import User
+from django.db import models
+
+from student.models import CourseEnrollment, AlreadyEnrolledError # pylint: disable=import-error
+from xmodule_django.models import CourseKeyField, LocationKeyField # pylint: disable=import-error
+
+
+class CustomCourseForEdX(models.Model):
+ """
+ A Custom Course.
+ """
+ course_id = CourseKeyField(max_length=255, db_index=True)
+ display_name = models.CharField(max_length=255)
+ coach = models.ForeignKey(User, db_index=True)
+
+
+class CcxMembership(models.Model):
+ """
+ Which students are in a CCX?
+ """
+ ccx = models.ForeignKey(CustomCourseForEdX, db_index=True)
+ student = models.ForeignKey(User, db_index=True)
+ active = models.BooleanField(default=False)
+
+ @classmethod
+ def auto_enroll(cls, student, future_membership):
+ """convert future_membership to an active membership
+ """
+ if not future_membership.auto_enroll:
+ msg = "auto enrollment not allowed for {}"
+ raise ValueError(msg.format(future_membership))
+ membership = cls(
+ ccx=future_membership.ccx, student=student, active=True
+ )
+ try:
+ CourseEnrollment.enroll(
+ student, future_membership.ccx.course_id, check_access=True
+ )
+ except AlreadyEnrolledError:
+ # if the user is already enrolled in the course, great!
+ pass
+
+ membership.save()
+ future_membership.delete()
+
+ @classmethod
+ def memberships_for_user(cls, user, active=True):
+ """
+ active memberships for a user
+ """
+ return cls.objects.filter(student=user, active__exact=active)
+
+
+class CcxFutureMembership(models.Model):
+ """
+ Which emails for non-users are waiting to be added to CCX on registration
+ """
+ ccx = models.ForeignKey(CustomCourseForEdX, db_index=True)
+ email = models.CharField(max_length=255)
+ auto_enroll = models.BooleanField(default=0)
+
+
+class CcxFieldOverride(models.Model):
+ """
+ Field overrides for custom courses.
+ """
+ ccx = models.ForeignKey(CustomCourseForEdX, db_index=True)
+ location = LocationKeyField(max_length=255, db_index=True)
+ field = models.CharField(max_length=255)
+
+ class Meta: # pylint: disable=missing-docstring,old-style-class
+ unique_together = (('ccx', 'location', 'field'),)
+
+ value = models.TextField(default='null')
diff --git a/lms/djangoapps/ccx/overrides.py b/lms/djangoapps/ccx/overrides.py
new file mode 100644
index 0000000000..752de4bca6
--- /dev/null
+++ b/lms/djangoapps/ccx/overrides.py
@@ -0,0 +1,184 @@
+"""
+API related to providing field overrides for individual students. This is used
+by the individual custom courses feature.
+"""
+import json
+import threading
+
+from contextlib import contextmanager
+
+from django.db import transaction, IntegrityError
+
+from courseware.field_overrides import FieldOverrideProvider # pylint: disable=import-error
+from ccx import ACTIVE_CCX_KEY # pylint: disable=import-error
+
+from .models import CcxMembership, CcxFieldOverride
+
+
+class CustomCoursesForEdxOverrideProvider(FieldOverrideProvider):
+ """
+ A concrete implementation of
+ :class:`~courseware.field_overrides.FieldOverrideProvider` which allows for
+ overrides to be made on a per user basis.
+ """
+ def get(self, block, name, default):
+ """
+ Just call the get_override_for_ccx method if there is a ccx
+ """
+ ccx = get_current_ccx()
+ if ccx:
+ return get_override_for_ccx(ccx, block, name, default)
+ return default
+
+
+class _CcxContext(threading.local):
+ """
+ A threading local used to implement the `with_ccx` context manager, that
+ keeps track of the CCX currently set as the context.
+ """
+ ccx = None
+ request = None
+
+
+_CCX_CONTEXT = _CcxContext()
+
+
+@contextmanager
+def ccx_context(ccx):
+ """
+ A context manager which can be used to explicitly set the CCX that is in
+ play for field overrides. This mechanism overrides the standard mechanism
+ of looking in the user's session to see if they are enrolled in a CCX and
+ viewing that CCX.
+ """
+ prev = _CCX_CONTEXT.ccx
+ _CCX_CONTEXT.ccx = ccx
+ yield
+ _CCX_CONTEXT.ccx = prev
+
+
+def get_current_ccx():
+ """
+ Return the ccx that is active for this request.
+ """
+ return _CCX_CONTEXT.ccx
+
+
+def get_current_request():
+ """
+ Return the active request, so that we can get context information in places
+ where it is limited, like in the tabs.
+ """
+ request = _CCX_CONTEXT.request
+ return request
+
+
+def get_override_for_ccx(ccx, block, name, default=None):
+ """
+ Gets the value of the overridden field for the `ccx`. `block` and `name`
+ specify the block and the name of the field. If the field is not
+ overridden for the given ccx, returns `default`.
+ """
+ if not hasattr(block, '_ccx_overrides'):
+ block._ccx_overrides = {} # pylint: disable=protected-access
+ overrides = block._ccx_overrides.get(ccx.id) # pylint: disable=protected-access
+ if overrides is None:
+ overrides = _get_overrides_for_ccx(ccx, block)
+ block._ccx_overrides[ccx.id] = overrides # pylint: disable=protected-access
+ return overrides.get(name, default)
+
+
+def _get_overrides_for_ccx(ccx, block):
+ """
+ Returns a dictionary mapping field name to overriden value for any
+ overrides set on this block for this CCX.
+ """
+ overrides = {}
+ query = CcxFieldOverride.objects.filter(
+ ccx=ccx,
+ location=block.location
+ )
+ for override in query:
+ field = block.fields[override.field]
+ value = field.from_json(json.loads(override.value))
+ overrides[override.field] = value
+ return overrides
+
+
+@transaction.commit_on_success
+def override_field_for_ccx(ccx, block, name, value):
+ """
+ Overrides a field for the `ccx`. `block` and `name` specify the block
+ and the name of the field on that block to override. `value` is the
+ value to set for the given field.
+ """
+ field = block.fields[name]
+ value = json.dumps(field.to_json(value))
+ try:
+ override = CcxFieldOverride.objects.create(
+ ccx=ccx,
+ location=block.location,
+ field=name,
+ value=value)
+ except IntegrityError:
+ transaction.commit()
+ override = CcxFieldOverride.objects.get(
+ ccx=ccx,
+ location=block.location,
+ field=name)
+ override.value = value
+ override.save()
+ if hasattr(block, '_ccx_overrides'):
+ del block._ccx_overrides[ccx.id] # pylint: disable=protected-access
+
+
+def clear_override_for_ccx(ccx, block, name):
+ """
+ Clears a previously set field override for the `ccx`. `block` and `name`
+ specify the block and the name of the field on that block to clear.
+ This function is idempotent--if no override is set, nothing action is
+ performed.
+ """
+ try:
+ CcxFieldOverride.objects.get(
+ ccx=ccx,
+ location=block.location,
+ field=name).delete()
+
+ if hasattr(block, '_ccx_overrides'):
+ del block._ccx_overrides[ccx.id] # pylint: disable=protected-access
+
+ except CcxFieldOverride.DoesNotExist:
+ pass
+
+
+class CcxMiddleware(object):
+ """
+ Checks to see if current session is examining a CCX and sets the CCX as
+ the current CCX for the override machinery if so.
+ """
+ def process_request(self, request):
+ """
+ Do the check.
+ """
+ ccx_id = request.session.get(ACTIVE_CCX_KEY, None)
+ if ccx_id is not None:
+ try:
+ membership = CcxMembership.objects.get(
+ student=request.user, active=True, ccx__id__exact=ccx_id
+ )
+ _CCX_CONTEXT.ccx = membership.ccx
+ except CcxMembership.DoesNotExist:
+ # if there is no membership, be sure to unset the active ccx
+ _CCX_CONTEXT.ccx = None
+ request.session.pop(ACTIVE_CCX_KEY)
+
+ _CCX_CONTEXT.request = request
+
+ def process_response(self, request, response): # pylint: disable=unused-argument
+ """
+ Clean up afterwards.
+ """
+ _CCX_CONTEXT.ccx = None
+ _CCX_CONTEXT.request = None
+ return response
diff --git a/lms/djangoapps/ccx/tests/__init__.py b/lms/djangoapps/ccx/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/lms/djangoapps/ccx/tests/factories.py b/lms/djangoapps/ccx/tests/factories.py
new file mode 100644
index 0000000000..36c976970f
--- /dev/null
+++ b/lms/djangoapps/ccx/tests/factories.py
@@ -0,0 +1,22 @@
+"""
+Dummy factories for tests
+"""
+from factory.django import DjangoModelFactory
+from ccx.models import CustomCourseForEdX # pylint: disable=import-error
+from ccx.models import CcxMembership # pylint: disable=import-error
+from ccx.models import CcxFutureMembership # pylint: disable=import-error
+
+
+class CcxFactory(DjangoModelFactory): # pylint: disable=missing-docstring
+ FACTORY_FOR = CustomCourseForEdX
+ display_name = "Test CCX"
+ id = None # pylint: disable=redefined-builtin, invalid-name
+
+
+class CcxMembershipFactory(DjangoModelFactory): # pylint: disable=missing-docstring
+ FACTORY_FOR = CcxMembership
+ active = False
+
+
+class CcxFutureMembershipFactory(DjangoModelFactory): # pylint: disable=missing-docstring
+ FACTORY_FOR = CcxFutureMembership
diff --git a/lms/djangoapps/ccx/tests/test_models.py b/lms/djangoapps/ccx/tests/test_models.py
new file mode 100644
index 0000000000..49689c4133
--- /dev/null
+++ b/lms/djangoapps/ccx/tests/test_models.py
@@ -0,0 +1,127 @@
+"""
+tests for the models
+"""
+from student.models import CourseEnrollment # pylint: disable=import-error
+from student.roles import CourseCcxCoachRole # pylint: disable=import-error
+from student.tests.factories import ( # pylint: disable=import-error
+ AdminFactory,
+ CourseEnrollmentFactory,
+ UserFactory,
+)
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory
+
+from .factories import (
+ CcxFactory,
+ CcxFutureMembershipFactory,
+)
+from ..models import (
+ CcxMembership,
+ CcxFutureMembership,
+)
+
+
+class TestCcxMembership(ModuleStoreTestCase):
+ """Unit tests for the CcxMembership model
+ """
+
+ def setUp(self):
+ """common setup for all tests"""
+ super(TestCcxMembership, self).setUp()
+ self.course = course = CourseFactory.create()
+ coach = AdminFactory.create()
+ role = CourseCcxCoachRole(course.id)
+ role.add_users(coach)
+ self.ccx = CcxFactory(course_id=course.id, coach=coach)
+ enrollment = CourseEnrollmentFactory.create(course_id=course.id)
+ self.enrolled_user = enrollment.user
+ self.unenrolled_user = UserFactory.create()
+
+ def create_future_enrollment(self, user, auto_enroll=True):
+ """
+ utility method to create future enrollment
+ """
+ pfm = CcxFutureMembershipFactory.create(
+ ccx=self.ccx,
+ email=user.email,
+ auto_enroll=auto_enroll
+ )
+ return pfm
+
+ def has_course_enrollment(self, user):
+ """
+ utility method to create future enrollment
+ """
+ enrollment = CourseEnrollment.objects.filter(
+ user=user, course_id=self.course.id
+ )
+ return enrollment.exists()
+
+ def has_ccx_membership(self, user):
+ """
+ verify ccx membership
+ """
+ membership = CcxMembership.objects.filter(
+ student=user, ccx=self.ccx, active=True
+ )
+ return membership.exists()
+
+ def has_ccx_future_membership(self, user):
+ """
+ verify future ccx membership
+ """
+ future_membership = CcxFutureMembership.objects.filter(
+ email=user.email, ccx=self.ccx
+ )
+ return future_membership.exists()
+
+ def call_mut(self, student, future_membership):
+ """
+ Call the method undser test
+ """
+ CcxMembership.auto_enroll(student, future_membership)
+
+ def test_ccx_auto_enroll_unregistered_user(self):
+ """verify auto_enroll works when user is not enrolled in the MOOC
+
+ n.b. After auto_enroll, user will have both a MOOC enrollment and a
+ CCX membership
+ """
+ user = self.unenrolled_user
+ pfm = self.create_future_enrollment(user)
+ self.assertTrue(self.has_ccx_future_membership(user))
+ self.assertFalse(self.has_course_enrollment(user))
+ # auto_enroll user
+ self.call_mut(user, pfm)
+
+ self.assertTrue(self.has_course_enrollment(user))
+ self.assertTrue(self.has_ccx_membership(user))
+ self.assertFalse(self.has_ccx_future_membership(user))
+
+ def test_ccx_auto_enroll_registered_user(self):
+ """verify auto_enroll works when user is enrolled in the MOOC
+ """
+ user = self.enrolled_user
+ pfm = self.create_future_enrollment(user)
+ self.assertTrue(self.has_ccx_future_membership(user))
+ self.assertTrue(self.has_course_enrollment(user))
+
+ self.call_mut(user, pfm)
+
+ self.assertTrue(self.has_course_enrollment(user))
+ self.assertTrue(self.has_ccx_membership(user))
+ self.assertFalse(self.has_ccx_future_membership(user))
+
+ def test_future_membership_disallows_auto_enroll(self):
+ """verify that the CcxFutureMembership can veto auto_enroll
+ """
+ user = self.unenrolled_user
+ pfm = self.create_future_enrollment(user, auto_enroll=False)
+ self.assertTrue(self.has_ccx_future_membership(user))
+ self.assertFalse(self.has_course_enrollment(user))
+
+ self.assertRaises(ValueError, self.call_mut, user, pfm)
+
+ self.assertFalse(self.has_course_enrollment(user))
+ self.assertFalse(self.has_ccx_membership(user))
+ self.assertTrue(self.has_ccx_future_membership(user))
diff --git a/lms/djangoapps/ccx/tests/test_overrides.py b/lms/djangoapps/ccx/tests/test_overrides.py
new file mode 100644
index 0000000000..f6edd44a43
--- /dev/null
+++ b/lms/djangoapps/ccx/tests/test_overrides.py
@@ -0,0 +1,129 @@
+"""
+tests for overrides
+"""
+import datetime
+import mock
+import pytz
+
+from courseware.field_overrides import OverrideFieldData # pylint: disable=import-error
+from django.test.utils import override_settings
+from student.tests.factories import AdminFactory # pylint: disable=import-error
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
+
+from ..models import CustomCourseForEdX
+from ..overrides import override_field_for_ccx
+
+from .test_views import flatten, iter_blocks
+
+
+@override_settings(FIELD_OVERRIDE_PROVIDERS=(
+ 'ccx.overrides.CustomCoursesForEdxOverrideProvider',))
+class TestFieldOverrides(ModuleStoreTestCase):
+ """
+ Make sure field overrides behave in the expected manner.
+ """
+ def setUp(self):
+ """
+ Set up tests
+ """
+ super(TestFieldOverrides, self).setUp()
+ self.course = course = CourseFactory.create()
+
+ # Create a course outline
+ self.mooc_start = start = datetime.datetime(
+ 2010, 5, 12, 2, 42, tzinfo=pytz.UTC)
+ self.mooc_due = due = datetime.datetime(
+ 2010, 7, 7, 0, 0, tzinfo=pytz.UTC)
+ chapters = [ItemFactory.create(start=start, parent=course)
+ for _ in xrange(2)]
+ sequentials = flatten([
+ [ItemFactory.create(parent=chapter) for _ in xrange(2)]
+ for chapter in chapters])
+ verticals = flatten([
+ [ItemFactory.create(due=due, parent=sequential) for _ in xrange(2)]
+ for sequential in sequentials])
+ blocks = flatten([ # pylint: disable=unused-variable
+ [ItemFactory.create(parent=vertical) for _ in xrange(2)]
+ for vertical in verticals])
+
+ self.ccx = ccx = CustomCourseForEdX(
+ course_id=course.id,
+ display_name='Test CCX',
+ coach=AdminFactory.create())
+ ccx.save()
+
+ patch = mock.patch('ccx.overrides.get_current_ccx')
+ self.get_ccx = get_ccx = patch.start()
+ get_ccx.return_value = ccx
+ self.addCleanup(patch.stop)
+
+ # Apparently the test harness doesn't use LmsFieldStorage, and I'm not
+ # sure if there's a way to poke the test harness to do so. So, we'll
+ # just inject the override field storage in this brute force manner.
+ OverrideFieldData.provider_classes = None
+ for block in iter_blocks(course):
+ block._field_data = OverrideFieldData.wrap( # pylint: disable=protected-access
+ AdminFactory.create(), block._field_data) # pylint: disable=protected-access
+
+ def cleanup_provider_classes():
+ """
+ After everything is done, clean up by un-doing the change to the
+ OverrideFieldData object that is done during the wrap method.
+ """
+ OverrideFieldData.provider_classes = None
+ self.addCleanup(cleanup_provider_classes)
+
+ def test_override_start(self):
+ """
+ Test that overriding start date on a chapter works.
+ """
+ ccx_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC)
+ chapter = self.course.get_children()[0]
+ override_field_for_ccx(self.ccx, chapter, 'start', ccx_start)
+ self.assertEquals(chapter.start, ccx_start)
+
+ def test_override_num_queries(self):
+ """
+ Test that overriding and accessing a field produce same number of queries.
+ """
+ ccx_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC)
+ chapter = self.course.get_children()[0]
+ with self.assertNumQueries(4):
+ override_field_for_ccx(self.ccx, chapter, 'start', ccx_start)
+ dummy = chapter.start
+
+ def test_overriden_field_access_produces_no_extra_queries(self):
+ """
+ Test no extra queries when accessing an overriden field more than once.
+ """
+ ccx_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC)
+ chapter = self.course.get_children()[0]
+ with self.assertNumQueries(4):
+ override_field_for_ccx(self.ccx, chapter, 'start', ccx_start)
+ dummy1 = chapter.start
+ dummy2 = chapter.start
+ dummy3 = chapter.start
+
+ def test_override_is_inherited(self):
+ """
+ Test that sequentials inherit overridden start date from chapter.
+ """
+ ccx_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC)
+ chapter = self.course.get_children()[0]
+ override_field_for_ccx(self.ccx, chapter, 'start', ccx_start)
+ self.assertEquals(chapter.get_children()[0].start, ccx_start)
+ self.assertEquals(chapter.get_children()[1].start, ccx_start)
+
+ def test_override_is_inherited_even_if_set_in_mooc(self):
+ """
+ Test that a due date set on a chapter is inherited by grandchildren
+ (verticals) even if a due date is set explicitly on grandchildren in
+ the mooc.
+ """
+ ccx_due = datetime.datetime(2015, 1, 1, 00, 00, tzinfo=pytz.UTC)
+ chapter = self.course.get_children()[0]
+ chapter.display_name = 'itsme!'
+ override_field_for_ccx(self.ccx, chapter, 'due', ccx_due)
+ vertical = chapter.get_children()[0].get_children()[0]
+ self.assertEqual(vertical.due, ccx_due)
diff --git a/lms/djangoapps/ccx/tests/test_utils.py b/lms/djangoapps/ccx/tests/test_utils.py
new file mode 100644
index 0000000000..cd44efe458
--- /dev/null
+++ b/lms/djangoapps/ccx/tests/test_utils.py
@@ -0,0 +1,570 @@
+"""
+test utils
+"""
+from ccx.models import ( # pylint: disable=import-error
+ CcxMembership,
+ CcxFutureMembership,
+)
+from ccx.tests.factories import ( # pylint: disable=import-error
+ CcxFactory,
+ CcxMembershipFactory,
+ CcxFutureMembershipFactory,
+)
+from student.roles import CourseCcxCoachRole # pylint: disable=import-error
+from student.tests.factories import ( # pylint: disable=import-error
+ AdminFactory,
+ UserFactory,
+ CourseEnrollmentFactory,
+ AnonymousUserFactory,
+)
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import CourseFactory
+
+
+class TestEmailEnrollmentState(ModuleStoreTestCase):
+ """unit tests for the EmailEnrollmentState class
+ """
+
+ def setUp(self):
+ """
+ Set up tests
+ """
+ super(TestEmailEnrollmentState, self).setUp()
+ # remove user provided by the parent test case so we can make our own
+ # when needed.
+ self.user = None
+ course = CourseFactory.create()
+ coach = AdminFactory.create()
+ role = CourseCcxCoachRole(course.id)
+ role.add_users(coach)
+ self.ccx = CcxFactory(course_id=course.id, coach=coach)
+
+ def create_user(self):
+ """provide a legitimate django user for testing
+ """
+ if getattr(self, 'user', None) is None:
+ self.user = UserFactory()
+
+ def register_user_in_ccx(self):
+ """create registration of self.user in self.ccx
+
+ registration will be inactive
+ """
+ self.create_user()
+ CcxMembershipFactory(ccx=self.ccx, student=self.user)
+
+ def create_one(self, email=None):
+ """Create a single EmailEnrollmentState object and return it
+ """
+ from ccx.utils import EmailEnrollmentState # pylint: disable=import-error
+ if email is None:
+ email = self.user.email
+ return EmailEnrollmentState(self.ccx, email)
+
+ def test_enrollment_state_for_non_user(self):
+ """verify behavior for non-user email address
+ """
+ ee_state = self.create_one(email='nobody@nowhere.com')
+ for attr in ['user', 'member', 'full_name', 'in_ccx']:
+ value = getattr(ee_state, attr, 'missing attribute')
+ self.assertFalse(value, "{}: {}".format(value, attr))
+
+ def test_enrollment_state_for_non_member_user(self):
+ """verify behavior for email address of user who is not a ccx memeber
+ """
+ self.create_user()
+ ee_state = self.create_one()
+ self.assertTrue(ee_state.user)
+ self.assertFalse(ee_state.in_ccx)
+ self.assertEqual(ee_state.member, self.user)
+ self.assertEqual(ee_state.full_name, self.user.profile.name)
+
+ def test_enrollment_state_for_member_user(self):
+ """verify behavior for email address of user who is a ccx member
+ """
+ self.create_user()
+ self.register_user_in_ccx()
+ ee_state = self.create_one()
+ for attr in ['user', 'in_ccx']:
+ self.assertTrue(
+ getattr(ee_state, attr, False),
+ "attribute {} is missing or False".format(attr)
+ )
+ self.assertEqual(ee_state.member, self.user)
+ self.assertEqual(ee_state.full_name, self.user.profile.name)
+
+ def test_enrollment_state_to_dict(self):
+ """verify dict representation of EmailEnrollmentState
+ """
+ self.create_user()
+ self.register_user_in_ccx()
+ ee_state = self.create_one()
+ ee_dict = ee_state.to_dict()
+ expected = {
+ 'user': True,
+ 'member': self.user,
+ 'in_ccx': True,
+ }
+ for expected_key, expected_value in expected.iteritems():
+ self.assertTrue(expected_key in ee_dict)
+ self.assertEqual(expected_value, ee_dict[expected_key])
+
+ def test_enrollment_state_repr(self):
+ self.create_user()
+ self.register_user_in_ccx()
+ ee_state = self.create_one()
+ representation = repr(ee_state)
+ self.assertTrue('user=True' in representation)
+ self.assertTrue('in_ccx=True' in representation)
+ member = 'member={}'.format(self.user)
+ self.assertTrue(member in representation)
+
+
+# TODO: deal with changes in behavior for auto_enroll
+class TestGetEmailParams(ModuleStoreTestCase):
+ """tests for ccx.utils.get_email_params
+ """
+ def setUp(self):
+ """
+ Set up tests
+ """
+ super(TestGetEmailParams, self).setUp()
+ course = CourseFactory.create()
+ coach = AdminFactory.create()
+ role = CourseCcxCoachRole(course.id)
+ role.add_users(coach)
+ self.ccx = CcxFactory(course_id=course.id, coach=coach)
+ self.all_keys = [
+ 'site_name', 'course', 'course_url', 'registration_url',
+ 'course_about_url', 'auto_enroll'
+ ]
+ self.url_keys = [k for k in self.all_keys if 'url' in k]
+ self.course_keys = [k for k in self.url_keys if 'course' in k]
+
+ def call_fut(self, auto_enroll=False, secure=False):
+ """
+ call function under test
+ """
+ from ccx.utils import get_email_params # pylint: disable=import-error
+ return get_email_params(self.ccx, auto_enroll, secure)
+
+ def test_params_have_expected_keys(self):
+ params = self.call_fut()
+ self.assertFalse(set(params.keys()) - set(self.all_keys))
+
+ def test_ccx_id_in_params(self):
+ expected_course_id = self.ccx.course_id.to_deprecated_string()
+ params = self.call_fut()
+ self.assertEqual(params['course'], self.ccx)
+ for url_key in self.url_keys:
+ self.assertTrue('http://' in params[url_key])
+ for url_key in self.course_keys:
+ self.assertTrue(expected_course_id in params[url_key])
+
+ def test_security_respected(self):
+ secure = self.call_fut(secure=True)
+ for url_key in self.url_keys:
+ self.assertTrue('https://' in secure[url_key])
+ insecure = self.call_fut(secure=False)
+ for url_key in self.url_keys:
+ self.assertTrue('http://' in insecure[url_key])
+
+ def test_auto_enroll_passed_correctly(self):
+ not_auto = self.call_fut(auto_enroll=False)
+ self.assertFalse(not_auto['auto_enroll'])
+ auto = self.call_fut(auto_enroll=True)
+ self.assertTrue(auto['auto_enroll'])
+
+
+# TODO: deal with changes in behavior for auto_enroll
+class TestEnrollEmail(ModuleStoreTestCase):
+ """tests for the enroll_email function from ccx.utils
+ """
+ def setUp(self):
+ super(TestEnrollEmail, self).setUp()
+ # unbind the user created by the parent, so we can create our own when
+ # needed.
+ self.user = None
+ course = CourseFactory.create()
+ coach = AdminFactory.create()
+ role = CourseCcxCoachRole(course.id)
+ role.add_users(coach)
+ self.ccx = CcxFactory(course_id=course.id, coach=coach)
+ self.outbox = self.get_outbox()
+
+ def create_user(self):
+ """provide a legitimate django user for testing
+ """
+ if getattr(self, 'user', None) is None:
+ self.user = UserFactory()
+
+ def register_user_in_ccx(self):
+ """create registration of self.user in self.ccx
+
+ registration will be inactive
+ """
+ self.create_user()
+ CcxMembershipFactory(ccx=self.ccx, student=self.user)
+
+ def get_outbox(self):
+ """Return the django mail outbox"""
+ from django.core import mail
+ return mail.outbox
+
+ def check_membership(self, email=None, user=None, future=False):
+ """Verify tjat an appropriate CCX Membership exists"""
+ if not email and not user:
+ self.fail(
+ "must provide user or email address to check CCX Membership"
+ )
+ if future and email:
+ membership = CcxFutureMembership.objects.filter(
+ ccx=self.ccx, email=email
+ )
+ elif not future:
+ if not user:
+ user = self.user
+ membership = CcxMembership.objects.filter(
+ ccx=self.ccx, student=user
+ )
+ self.assertTrue(membership.exists())
+
+ def check_enrollment_state(self, state, in_ccx, member, user):
+ """Verify an enrollment state object against provided arguments
+
+ state.in_ccx will always be a boolean
+ state.user will always be a boolean
+ state.member will be a Django user object or None
+ """
+ self.assertEqual(in_ccx, state.in_ccx)
+ self.assertEqual(member, state.member)
+ self.assertEqual(user, state.user)
+
+ def call_fut(
+ self,
+ student_email=None,
+ auto_enroll=False,
+ email_students=False,
+ email_params=None
+ ):
+ """Call function under test"""
+ from ccx.utils import enroll_email # pylint: disable=import-error
+ if student_email is None:
+ student_email = self.user.email
+ before, after = enroll_email(
+ self.ccx, student_email, auto_enroll, email_students, email_params
+ )
+ return before, after
+
+ def test_enroll_non_user_sending_email(self):
+ """enroll a non-user email and send an enrollment email to them
+ """
+ # ensure no emails are in the outbox now
+ self.assertEqual(self.outbox, [])
+ test_email = "nobody@nowhere.com"
+ before, after = self.call_fut(
+ student_email=test_email, email_students=True
+ )
+
+ # there should be a future membership set for this email address now
+ self.check_membership(email=test_email, future=True)
+ for state in [before, after]:
+ self.check_enrollment_state(state, False, None, False)
+ # mail was sent and to the right person
+ self.assertEqual(len(self.outbox), 1)
+ msg = self.outbox[0]
+ self.assertTrue(test_email in msg.recipients())
+
+ def test_enroll_non_member_sending_email(self):
+ """register a non-member and send an enrollment email to them
+ """
+ self.create_user()
+ # ensure no emails are in the outbox now
+ self.assertEqual(self.outbox, [])
+ before, after = self.call_fut(email_students=True)
+
+ # there should be a membership set for this email address now
+ self.check_membership(email=self.user.email)
+ self.check_enrollment_state(before, False, self.user, True)
+ self.check_enrollment_state(after, True, self.user, True)
+ # mail was sent and to the right person
+ self.assertEqual(len(self.outbox), 1)
+ msg = self.outbox[0]
+ self.assertTrue(self.user.email in msg.recipients())
+
+ def test_enroll_member_sending_email(self):
+ """register a member and send an enrollment email to them
+ """
+ self.register_user_in_ccx()
+ # ensure no emails are in the outbox now
+ self.assertEqual(self.outbox, [])
+ before, after = self.call_fut(email_students=True)
+
+ # there should be a membership set for this email address now
+ self.check_membership(email=self.user.email)
+ for state in [before, after]:
+ self.check_enrollment_state(state, True, self.user, True)
+ # mail was sent and to the right person
+ self.assertEqual(len(self.outbox), 1)
+ msg = self.outbox[0]
+ self.assertTrue(self.user.email in msg.recipients())
+
+ def test_enroll_non_user_no_email(self):
+ """register a non-user via email address but send no email
+ """
+ # ensure no emails are in the outbox now
+ self.assertEqual(self.outbox, [])
+ test_email = "nobody@nowhere.com"
+ before, after = self.call_fut(
+ student_email=test_email, email_students=False
+ )
+
+ # there should be a future membership set for this email address now
+ self.check_membership(email=test_email, future=True)
+ for state in [before, after]:
+ self.check_enrollment_state(state, False, None, False)
+ # ensure there are still no emails in the outbox now
+ self.assertEqual(self.outbox, [])
+
+ def test_enroll_non_member_no_email(self):
+ """register a non-member but send no email"""
+ self.create_user()
+ # ensure no emails are in the outbox now
+ self.assertEqual(self.outbox, [])
+ before, after = self.call_fut(email_students=False)
+
+ # there should be a membership set for this email address now
+ self.check_membership(email=self.user.email)
+ self.check_enrollment_state(before, False, self.user, True)
+ self.check_enrollment_state(after, True, self.user, True)
+ # ensure there are still no emails in the outbox now
+ self.assertEqual(self.outbox, [])
+
+ def test_enroll_member_no_email(self):
+ """enroll a member but send no email
+ """
+ self.register_user_in_ccx()
+ # ensure no emails are in the outbox now
+ self.assertEqual(self.outbox, [])
+ before, after = self.call_fut(email_students=False)
+
+ # there should be a membership set for this email address now
+ self.check_membership(email=self.user.email)
+ for state in [before, after]:
+ self.check_enrollment_state(state, True, self.user, True)
+ # ensure there are still no emails in the outbox now
+ self.assertEqual(self.outbox, [])
+
+
+# TODO: deal with changes in behavior for auto_enroll
+class TestUnenrollEmail(ModuleStoreTestCase):
+ """Tests for the unenroll_email function from ccx.utils"""
+ def setUp(self):
+ super(TestUnenrollEmail, self).setUp()
+ # unbind the user created by the parent, so we can create our own when
+ # needed.
+ self.user = None
+ course = CourseFactory.create()
+ coach = AdminFactory.create()
+ role = CourseCcxCoachRole(course.id)
+ role.add_users(coach)
+ self.ccx = CcxFactory(course_id=course.id, coach=coach)
+ self.outbox = self.get_outbox()
+ self.email = "nobody@nowhere.com"
+
+ def get_outbox(self):
+ """Return the django mail outbox"""
+ from django.core import mail
+ return mail.outbox
+
+ def create_user(self):
+ """provide a legitimate django user for testing
+ """
+ if getattr(self, 'user', None) is None:
+ self.user = UserFactory()
+
+ def make_ccx_membership(self):
+ """create registration of self.user in self.ccx
+
+ registration will be inactive
+ """
+ self.create_user()
+ CcxMembershipFactory.create(ccx=self.ccx, student=self.user)
+
+ def make_ccx_future_membership(self):
+ """create future registration for email in self.ccx"""
+ CcxFutureMembershipFactory.create(
+ ccx=self.ccx, email=self.email
+ )
+
+ def check_enrollment_state(self, state, in_ccx, member, user):
+ """Verify an enrollment state object against provided arguments
+
+ state.in_ccx will always be a boolean
+ state.user will always be a boolean
+ state.member will be a Django user object or None
+ """
+ self.assertEqual(in_ccx, state.in_ccx)
+ self.assertEqual(member, state.member)
+ self.assertEqual(user, state.user)
+
+ def check_membership(self, future=False):
+ """
+ check membership
+ """
+ if future:
+ membership = CcxFutureMembership.objects.filter(
+ ccx=self.ccx, email=self.email
+ )
+ else:
+ membership = CcxMembership.objects.filter(
+ ccx=self.ccx, student=self.user
+ )
+ return membership.exists()
+
+ def call_fut(self, email_students=False):
+ """call function under test"""
+ from ccx.utils import unenroll_email # pylint: disable=import-error
+ email = getattr(self, 'user', None) and self.user.email or self.email
+ return unenroll_email(self.ccx, email, email_students=email_students)
+
+ def test_unenroll_future_member_with_email(self):
+ """unenroll a future member and send an email
+ """
+ self.make_ccx_future_membership()
+ # assert that a membership exists and that no emails have been sent
+ self.assertTrue(self.check_membership(future=True))
+ self.assertEqual(self.outbox, [])
+ # unenroll the student
+ before, after = self.call_fut(email_students=True)
+
+ # assert that membership is now gone
+ self.assertFalse(self.check_membership(future=True))
+ # validate the before and after enrollment states
+ for state in [before, after]:
+ self.check_enrollment_state(state, False, None, False)
+ # check that mail was sent and to the right person
+ self.assertEqual(len(self.outbox), 1)
+ msg = self.outbox[0]
+ self.assertTrue(self.email in msg.recipients())
+
+ def test_unenroll_member_with_email(self):
+ """unenroll a current member and send an email"""
+ self.make_ccx_membership()
+ # assert that a membership exists and that no emails have been sent
+ self.assertTrue(self.check_membership())
+ self.assertEqual(self.outbox, [])
+ # unenroll the student
+ before, after = self.call_fut(email_students=True)
+
+ # assert that membership is now gone
+ self.assertFalse(self.check_membership())
+ # validate the before and after enrollment state
+ self.check_enrollment_state(after, False, self.user, True)
+ self.check_enrollment_state(before, True, self.user, True)
+ # check that mail was sent and to the right person
+ self.assertEqual(len(self.outbox), 1)
+ msg = self.outbox[0]
+ self.assertTrue(self.user.email in msg.recipients())
+
+ def test_unenroll_future_member_no_email(self):
+ """unenroll a future member but send no email
+ """
+ self.make_ccx_future_membership()
+ # assert that a membership exists and that no emails have been sent
+ self.assertTrue(self.check_membership(future=True))
+ self.assertEqual(self.outbox, [])
+ # unenroll the student
+ before, after = self.call_fut()
+
+ # assert that membership is now gone
+ self.assertFalse(self.check_membership(future=True))
+ # validate the before and after enrollment states
+ for state in [before, after]:
+ self.check_enrollment_state(state, False, None, False)
+ # no email was sent to the student
+ self.assertEqual(self.outbox, [])
+
+ def test_unenroll_member_no_email(self):
+ """unenroll a current member but send no email
+ """
+ self.make_ccx_membership()
+ # assert that a membership exists and that no emails have been sent
+ self.assertTrue(self.check_membership())
+ self.assertEqual(self.outbox, [])
+ # unenroll the student
+ before, after = self.call_fut()
+
+ # assert that membership is now gone
+ self.assertFalse(self.check_membership())
+ # validate the before and after enrollment state
+ self.check_enrollment_state(after, False, self.user, True)
+ self.check_enrollment_state(before, True, self.user, True)
+ # no email was sent to the student
+ self.assertEqual(self.outbox, [])
+
+
+class TestUserCCXList(ModuleStoreTestCase):
+ """Unit tests for ccx.utils.get_all_ccx_for_user"""
+
+ def setUp(self):
+ """Create required infrastructure for tests"""
+ super(TestUserCCXList, self).setUp()
+ self.course = CourseFactory.create()
+ coach = AdminFactory.create()
+ role = CourseCcxCoachRole(self.course.id)
+ role.add_users(coach)
+ self.ccx = CcxFactory(course_id=self.course.id, coach=coach)
+ enrollment = CourseEnrollmentFactory.create(course_id=self.course.id)
+ self.user = enrollment.user
+ self.anonymous = AnonymousUserFactory.create()
+
+ def register_user_in_ccx(self, active=False):
+ """create registration of self.user in self.ccx
+
+ registration will be inactive unless active=True
+ """
+ CcxMembershipFactory(ccx=self.ccx, student=self.user, active=active)
+
+ def get_course_title(self):
+ """Get course title"""
+ from courseware.courses import get_course_about_section # pylint: disable=import-error
+ return get_course_about_section(self.course, 'title')
+
+ def call_fut(self, user):
+ """Call function under test"""
+ from ccx.utils import get_all_ccx_for_user # pylint: disable=import-error
+ return get_all_ccx_for_user(user)
+
+ def test_anonymous_sees_no_ccx(self):
+ memberships = self.call_fut(self.anonymous)
+ self.assertEqual(memberships, [])
+
+ def test_unenrolled_sees_no_ccx(self):
+ memberships = self.call_fut(self.user)
+ self.assertEqual(memberships, [])
+
+ def test_enrolled_inactive_sees_no_ccx(self):
+ self.register_user_in_ccx()
+ memberships = self.call_fut(self.user)
+ self.assertEqual(memberships, [])
+
+ def test_enrolled_sees_a_ccx(self):
+ self.register_user_in_ccx(active=True)
+ memberships = self.call_fut(self.user)
+ self.assertEqual(len(memberships), 1)
+
+ def test_data_structure(self):
+ self.register_user_in_ccx(active=True)
+ memberships = self.call_fut(self.user)
+ this_membership = memberships[0]
+ self.assertTrue(this_membership)
+ # structure contains the expected keys
+ for key in ['ccx_name', 'ccx_url']:
+ self.assertTrue(key in this_membership.keys())
+ url_parts = [self.course.id.to_deprecated_string(), str(self.ccx.id)]
+ # all parts of the ccx url are present
+ for part in url_parts:
+ self.assertTrue(part in this_membership['ccx_url'])
+ actual_name = self.ccx.display_name
+ self.assertEqual(actual_name, this_membership['ccx_name'])
diff --git a/lms/djangoapps/ccx/tests/test_views.py b/lms/djangoapps/ccx/tests/test_views.py
new file mode 100644
index 0000000000..d321183375
--- /dev/null
+++ b/lms/djangoapps/ccx/tests/test_views.py
@@ -0,0 +1,776 @@
+"""
+test views
+"""
+import datetime
+import json
+import re
+import pytz
+from mock import patch
+
+from capa.tests.response_xml_factory import StringResponseXMLFactory
+from courseware.field_overrides import OverrideFieldData # pylint: disable=import-error
+from courseware.tests.factories import StudentModuleFactory # pylint: disable=import-error
+from courseware.tests.helpers import LoginEnrollmentTestCase # pylint: disable=import-error
+from django.core.urlresolvers import reverse
+from django.test.utils import override_settings
+from edxmako.shortcuts import render_to_response # pylint: disable=import-error
+from student.roles import CourseCcxCoachRole # pylint: disable=import-error
+from student.tests.factories import ( # pylint: disable=import-error
+ AdminFactory,
+ CourseEnrollmentFactory,
+ UserFactory,
+)
+
+from xmodule.x_module import XModuleMixin
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.factories import (
+ CourseFactory,
+ ItemFactory,
+)
+from ..models import (
+ CustomCourseForEdX,
+ CcxMembership,
+ CcxFutureMembership,
+)
+from ..overrides import get_override_for_ccx, override_field_for_ccx
+from .. import ACTIVE_CCX_KEY
+from .factories import (
+ CcxFactory,
+ CcxMembershipFactory,
+ CcxFutureMembershipFactory,
+)
+
+
+def intercept_renderer(path, context):
+ """
+ Intercept calls to `render_to_response` and attach the context dict to the
+ response for examination in unit tests.
+ """
+ # I think Django already does this for you in their TestClient, except
+ # we're bypassing that by using edxmako. Probably edxmako should be
+ # integrated better with Django's rendering and event system.
+ response = render_to_response(path, context)
+ response.mako_context = context
+ response.mako_template = path
+ return response
+
+
+class TestCoachDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase):
+ """
+ Tests for Custom Courses views.
+ """
+ def setUp(self):
+ """
+ Set up tests
+ """
+ super(TestCoachDashboard, self).setUp()
+ self.course = course = CourseFactory.create()
+
+ # Create instructor account
+ self.coach = coach = AdminFactory.create()
+ self.client.login(username=coach.username, password="test")
+
+ # Create a course outline
+ self.mooc_start = start = datetime.datetime(
+ 2010, 5, 12, 2, 42, tzinfo=pytz.UTC)
+ self.mooc_due = due = datetime.datetime(
+ 2010, 7, 7, 0, 0, tzinfo=pytz.UTC)
+ chapters = [ItemFactory.create(start=start, parent=course)
+ for _ in xrange(2)]
+ sequentials = flatten([
+ [
+ ItemFactory.create(parent=chapter) for _ in xrange(2)
+ ] for chapter in chapters
+ ])
+ verticals = flatten([
+ [
+ ItemFactory.create(
+ due=due, parent=sequential, graded=True, format='Homework'
+ ) for _ in xrange(2)
+ ] for sequential in sequentials
+ ])
+ blocks = flatten([ # pylint: disable=unused-variable
+ [
+ ItemFactory.create(parent=vertical) for _ in xrange(2)
+ ] for vertical in verticals
+ ])
+
+ def make_coach(self):
+ """
+ create coach user
+ """
+ role = CourseCcxCoachRole(self.course.id)
+ role.add_users(self.coach)
+
+ def make_ccx(self):
+ """
+ create ccx
+ """
+ ccx = CcxFactory(course_id=self.course.id, coach=self.coach)
+ return ccx
+
+ def get_outbox(self):
+ """
+ get fake outbox
+ """
+ from django.core import mail
+ return mail.outbox
+
+ def tearDown(self):
+ """
+ Undo patches.
+ """
+ super(TestCoachDashboard, self).tearDown()
+ patch.stopall()
+
+ def test_not_a_coach(self):
+ """
+ User is not a coach, should get Forbidden response.
+ """
+ url = reverse(
+ 'ccx_coach_dashboard',
+ kwargs={'course_id': self.course.id.to_deprecated_string()})
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 403)
+
+ def test_no_ccx_created(self):
+ """
+ No CCX is created, coach should see form to add a CCX.
+ """
+ self.make_coach()
+ url = reverse(
+ 'ccx_coach_dashboard',
+ kwargs={'course_id': self.course.id.to_deprecated_string()})
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ self.assertTrue(re.search(
+ '