Commit 03c3bbb3 by David Ormsbee

Merge pull request #6636 from jazkarta/feature-pocs

MIT CCx (was Personal Online Courses)
parents 953581e6 f9351ef8
...@@ -267,6 +267,14 @@ class LibraryUserRole(CourseRole): ...@@ -267,6 +267,14 @@ class LibraryUserRole(CourseRole):
super(LibraryUserRole, self).__init__(self.ROLE, *args, **kwargs) 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): class OrgStaffRole(OrgRole):
"""An organization staff member""" """An organization staff member"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
......
...@@ -662,6 +662,17 @@ def dashboard(request): ...@@ -662,6 +662,17 @@ def dashboard(request):
if course.pre_requisite_courses) if course.pre_requisite_courses)
courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites) 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 = { context = {
'enrollment_message': enrollment_message, 'enrollment_message': enrollment_message,
'course_enrollment_pairs': course_enrollment_pairs, 'course_enrollment_pairs': course_enrollment_pairs,
...@@ -693,6 +704,7 @@ def dashboard(request): ...@@ -693,6 +704,7 @@ def dashboard(request):
'provider_states': [], 'provider_states': [],
'order_history_list': order_history_list, 'order_history_list': order_history_list,
'courses_requirements_not_met': courses_requirements_not_met, 'courses_requirements_not_met': courses_requirements_not_met,
'ccx_membership_triplets': ccx_membership_triplets,
} }
if third_party_auth.is_enabled(): if third_party_auth.is_enabled():
...@@ -1810,6 +1822,16 @@ def activate_account(request, key): ...@@ -1810,6 +1822,16 @@ def activate_account(request, key):
if cea.auto_enroll: if cea.auto_enroll:
CourseEnrollment.enroll(student[0], cea.course_id) 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( resp = render_to_response(
"registration/activation_complete.html", "registration/activation_complete.html",
{ {
......
...@@ -26,7 +26,6 @@ from xmodule.exceptions import NotFoundError ...@@ -26,7 +26,6 @@ from xmodule.exceptions import NotFoundError
from xblock.fields import Scope, String, Boolean, Dict, Integer, Float from xblock.fields import Scope, String, Boolean, Dict, Integer, Float
from .fields import Timedelta, Date from .fields import Timedelta, Date
from django.utils.timezone import UTC from django.utils.timezone import UTC
from .util.duedate import get_extended_due_date
from xmodule.capa_base_constants import RANDOMIZATION, SHOWANSWER from xmodule.capa_base_constants import RANDOMIZATION, SHOWANSWER
from django.conf import settings from django.conf import settings
...@@ -107,14 +106,6 @@ class CapaFields(object): ...@@ -107,14 +106,6 @@ class CapaFields(object):
values={"min": 0}, scope=Scope.settings values={"min": 0}, scope=Scope.settings
) )
due = Date(help=_("Date that this problem is due by"), 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( graceperiod = Timedelta(
help=_("Amount of time after the due date that submissions will be accepted"), help=_("Amount of time after the due date that submissions will be accepted"),
scope=Scope.settings scope=Scope.settings
...@@ -218,7 +209,7 @@ class CapaMixin(CapaFields): ...@@ -218,7 +209,7 @@ class CapaMixin(CapaFields):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(CapaMixin, self).__init__(*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: if self.graceperiod is not None and due_date:
self.close_date = due_date + self.graceperiod self.close_date = due_date + self.graceperiod
......
...@@ -23,7 +23,6 @@ V1_SETTINGS_ATTRIBUTES = [ ...@@ -23,7 +23,6 @@ V1_SETTINGS_ATTRIBUTES = [
"accept_file_upload", "accept_file_upload",
"skip_spelling_checks", "skip_spelling_checks",
"due", "due",
"extended_due",
"graceperiod", "graceperiod",
"weight", "weight",
"min_to_calibrate", "min_to_calibrate",
...@@ -258,16 +257,6 @@ class CombinedOpenEndedFields(object): ...@@ -258,16 +257,6 @@ class CombinedOpenEndedFields(object):
help=_("Date that this problem is due by"), help=_("Date that this problem is due by"),
scope=Scope.settings 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( graceperiod = Timedelta(
help=_("Amount of time after the due date that submissions will be accepted"), help=_("Amount of time after the due date that submissions will be accepted"),
scope=Scope.settings scope=Scope.settings
......
...@@ -11,7 +11,7 @@ from datetime import datetime ...@@ -11,7 +11,7 @@ from datetime import datetime
import dateutil.parser import dateutil.parser
from lazy import lazy from lazy import lazy
from xmodule.exceptions import UndefinedContext
from xmodule.seq_module import SequenceDescriptor, SequenceModule from xmodule.seq_module import SequenceDescriptor, SequenceModule
from xmodule.graders import grader_from_conf from xmodule.graders import grader_from_conf
from xmodule.tabs import CourseTabList from xmodule.tabs import CourseTabList
...@@ -835,8 +835,16 @@ class CourseFields(object): ...@@ -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): class CourseDescriptor(CourseFields, SequenceDescriptor):
module_class = SequenceModule module_class = CourseModule
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
""" """
...@@ -1213,6 +1221,14 @@ class CourseDescriptor(CourseFields, SequenceDescriptor): ...@@ -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 = [] all_descriptors = []
graded_sections = {} graded_sections = {}
......
...@@ -8,7 +8,6 @@ from xmodule.x_module import XModule ...@@ -8,7 +8,6 @@ from xmodule.x_module import XModule
from xmodule.xml_module import XmlDescriptor from xmodule.xml_module import XmlDescriptor
from xblock.fields import Scope, Integer, String from xblock.fields import Scope, Integer, String
from .fields import Date from .fields import Date
from .util.duedate import get_extended_due_date
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -21,14 +20,6 @@ class FolditFields(object): ...@@ -21,14 +20,6 @@ class FolditFields(object):
required_level = Integer(default=4, scope=Scope.settings) required_level = Integer(default=4, scope=Scope.settings)
required_sublevel = Integer(default=5, scope=Scope.settings) required_sublevel = Integer(default=5, scope=Scope.settings)
due = Date(help="Date that this problem is due by", 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_basic_score = String(scope=Scope.settings, default='false')
show_leaderboard = String(scope=Scope.settings, default='false') show_leaderboard = String(scope=Scope.settings, default='false')
...@@ -49,7 +40,7 @@ class FolditModule(FolditFields, XModule): ...@@ -49,7 +40,7 @@ class FolditModule(FolditFields, XModule):
show_leaderboard="false"/> show_leaderboard="false"/>
""" """
super(FolditModule, self).__init__(*args, **kwargs) super(FolditModule, self).__init__(*args, **kwargs)
self.due_time = get_extended_due_date(self) self.due_time = self.due
def is_complete(self): def is_complete(self):
""" """
......
...@@ -44,14 +44,6 @@ class InheritanceMixin(XBlockMixin): ...@@ -44,14 +44,6 @@ class InheritanceMixin(XBlockMixin):
help=_("Enter the default date by which problems are due."), help=_("Enter the default date by which problems are due."),
scope=Scope.settings, 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( visible_to_staff_only = Boolean(
help=_("If true, can be seen only by course staff, regardless of start date."), help=_("If true, can be seen only by course staff, regardless of start date."),
default=False, default=False,
......
...@@ -8,7 +8,6 @@ from xmodule.progress import Progress ...@@ -8,7 +8,6 @@ from xmodule.progress import Progress
from xmodule.stringify import stringify_children from xmodule.stringify import stringify_children
from xmodule.open_ended_grading_classes import self_assessment_module from xmodule.open_ended_grading_classes import self_assessment_module
from xmodule.open_ended_grading_classes import open_ended_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 .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.peer_grading_service import PeerGradingService, MockPeerGradingService
from xmodule.open_ended_grading_classes.openendedchild import OpenEndedChild from xmodule.open_ended_grading_classes.openendedchild import OpenEndedChild
...@@ -150,7 +149,7 @@ class CombinedOpenEndedV1Module(object): ...@@ -150,7 +149,7 @@ class CombinedOpenEndedV1Module(object):
'peer_grade_finished_submissions_when_none_pending', False '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) grace_period_string = instance_state.get('graceperiod', None)
try: try:
self.timeinfo = TimeInfo(due_date, grace_period_string) self.timeinfo = TimeInfo(due_date, grace_period_string)
......
...@@ -11,7 +11,6 @@ from xmodule.fields import Date, Timedelta ...@@ -11,7 +11,6 @@ from xmodule.fields import Date, Timedelta
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
from xmodule.raw_module import RawDescriptor from xmodule.raw_module import RawDescriptor
from xmodule.timeinfo import TimeInfo from xmodule.timeinfo import TimeInfo
from xmodule.util.duedate import get_extended_due_date
from xmodule.x_module import XModule, module_attr from xmodule.x_module import XModule, module_attr
from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, MockPeerGradingService from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, MockPeerGradingService
...@@ -52,14 +51,6 @@ class PeerGradingFields(object): ...@@ -52,14 +51,6 @@ class PeerGradingFields(object):
due = Date( due = Date(
help=_("Due date that should be displayed."), help=_("Due date that should be displayed."),
scope=Scope.settings) 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( graceperiod = Timedelta(
help=_("Amount of grace to give on the due date."), help=_("Amount of grace to give on the due date."),
scope=Scope.settings scope=Scope.settings
...@@ -141,8 +132,7 @@ class PeerGradingModule(PeerGradingFields, XModule): ...@@ -141,8 +132,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
self.linked_problem = self.system.get_module(linked_descriptors[0]) self.linked_problem = self.system.get_module(linked_descriptors[0])
try: try:
self.timeinfo = TimeInfo( self.timeinfo = TimeInfo(self.due, self.graceperiod)
get_extended_due_date(self), self.graceperiod)
except Exception: except Exception:
log.error("Error parsing due date information in location {0}".format(self.location)) log.error("Error parsing due date information in location {0}".format(self.location))
raise raise
...@@ -570,7 +560,7 @@ class PeerGradingModule(PeerGradingFields, XModule): ...@@ -570,7 +560,7 @@ class PeerGradingModule(PeerGradingFields, XModule):
except (NoPathToItem, ItemNotFoundError): except (NoPathToItem, ItemNotFoundError):
continue continue
if descriptor: if descriptor:
problem['due'] = get_extended_due_date(descriptor) problem['due'] = descriptor.due
grace_period = descriptor.graceperiod grace_period = descriptor.graceperiod
try: try:
problem_timeinfo = TimeInfo(problem['due'], grace_period) problem_timeinfo = TimeInfo(problem['due'], grace_period)
......
...@@ -36,14 +36,6 @@ class SequenceFields(object): ...@@ -36,14 +36,6 @@ class SequenceFields(object):
help=_("Enter the date by which problems are due."), help=_("Enter the date by which problems are due."),
scope=Scope.settings, 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 # Entrance Exam flag -- see cms/contentstore/views/entrance_exam.py for usage
is_entrance_exam = Boolean( is_entrance_exam = Boolean(
......
...@@ -193,6 +193,7 @@ class CourseTab(object): ...@@ -193,6 +193,7 @@ class CourseTab(object):
'edxnotes': EdxNotesTab, 'edxnotes': EdxNotesTab,
'syllabus': SyllabusTab, 'syllabus': SyllabusTab,
'instructor': InstructorTab, # not persisted 'instructor': InstructorTab, # not persisted
'ccx_coach': CcxCoachTab, # not persisted
} }
tab_type = tab_dict.get('type') tab_type = tab_dict.get('type')
...@@ -375,6 +376,10 @@ class DiscussionTab(EnrolledOrStaffTab): ...@@ -375,6 +376,10 @@ class DiscussionTab(EnrolledOrStaffTab):
) )
def can_display(self, course, settings, is_user_authenticated, is_user_staff, is_user_enrolled): 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( super_can_display = super(DiscussionTab, self).can_display(
course, settings, is_user_authenticated, is_user_staff, is_user_enrolled course, settings, is_user_authenticated, is_user_staff, is_user_enrolled
) )
...@@ -733,6 +738,42 @@ class InstructorTab(StaffTab): ...@@ -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): class CourseTabList(List):
""" """
An XBlock field class that encapsulates a collection of Tabs in a course. An XBlock field class that encapsulates a collection of Tabs in a course.
...@@ -833,6 +874,9 @@ class CourseTabList(List): ...@@ -833,6 +874,9 @@ class CourseTabList(List):
instructor_tab = InstructorTab() instructor_tab = InstructorTab()
if instructor_tab.can_display(course, settings, is_user_authenticated, is_user_staff, is_user_enrolled): if instructor_tab.can_display(course, settings, is_user_authenticated, is_user_staff, is_user_enrolled):
yield instructor_tab 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 @staticmethod
def iterate_displayable_cms( def iterate_displayable_cms(
......
...@@ -430,13 +430,6 @@ class CapaModuleTest(unittest.TestCase): ...@@ -430,13 +430,6 @@ class CapaModuleTest(unittest.TestCase):
due=self.yesterday_str) due=self.yesterday_str)
self.assertTrue(module.closed()) 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): def test_parse_get_params(self):
# Valid GET param dict # Valid GET param dict
...@@ -1742,7 +1735,7 @@ class TestProblemCheckTracking(unittest.TestCase): ...@@ -1742,7 +1735,7 @@ class TestProblemCheckTracking(unittest.TestCase):
self.maxDiff = None self.maxDiff = None
def test_choice_answer_text(self): def test_choice_answer_text(self):
factory = self.capa_factory_for_problem_xml("""\ xml = """\
<problem display_name="Multiple Choice Questions"> <problem display_name="Multiple Choice Questions">
<p>What color is the open ocean on a sunny day?</p> <p>What color is the open ocean on a sunny day?</p>
<optionresponse> <optionresponse>
...@@ -1767,7 +1760,11 @@ class TestProblemCheckTracking(unittest.TestCase): ...@@ -1767,7 +1760,11 @@ class TestProblemCheckTracking(unittest.TestCase):
</checkboxgroup> </checkboxgroup>
</choiceresponse> </choiceresponse>
</problem> </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() module = factory.create()
answer_input_dict = { answer_input_dict = {
......
...@@ -1556,19 +1556,6 @@ msgstr "Däté thät thïs prößlém ïs düé ßý Ⱡ'σяєм ι#" ...@@ -1556,19 +1556,6 @@ msgstr "Däté thät thïs prößlém ïs düé ßý Ⱡ'σяєм ι#"
#: common/lib/xmodule/xmodule/capa_base.py #: common/lib/xmodule/xmodule/capa_base.py
#: common/lib/xmodule/xmodule/combined_open_ended_module.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" msgid "Amount of time after the due date that submissions will be accepted"
msgstr "" msgstr ""
"Àmöünt öf tïmé äftér thé düé däté thät süßmïssïöns wïll ßé äççéptéd Ⱡ'σяєм " "Àmöünt öf tïmé äftér thé düé däté thät süßmïssïöns wïll ßé äççéptéd Ⱡ'σяєм "
...@@ -11920,6 +11907,19 @@ msgstr "änd çhöösé ýöür stüdént träçk Ⱡ'σяєм #" ...@@ -11920,6 +11907,19 @@ msgstr "änd çhöösé ýöür stüdént träçk Ⱡ'σяєм #"
msgid "and proceed to verification" msgid "and proceed to verification"
msgstr "änd pröçééd tö vérïfïçätïön Ⱡ'σяєм#" 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 #: lms/templates/courseware/mktg_course_about.html
msgid "Enrollment Is Closed" msgid "Enrollment Is Closed"
msgstr "Énröllmént Ìs Çlöséd Ⱡ'σя#" msgstr "Énröllmént Ìs Çlöséd Ⱡ'σя#"
......
"""
we use this to mark the active ccx, for use by ccx middleware and some views
"""
ACTIVE_CCX_KEY = '_ccx_id'
# -*- 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']
"""
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')
"""
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
"""
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
"""
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))
"""
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)
"""
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 ...@@ -20,11 +20,11 @@ from xmodule import graders
from xmodule.graders import Score from xmodule.graders import Score
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.util.duedate import get_extended_due_date
from .models import StudentModule from .models import StudentModule
from .module_render import get_module_for_descriptor from .module_render import get_module_for_descriptor
from submissions import api as sub_api # installed from the edx-submissions repository from submissions import api as sub_api # installed from the edx-submissions repository
from opaque_keys import InvalidKeyError from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
log = logging.getLogger("edx.courseware") log = logging.getLogger("edx.courseware")
...@@ -247,6 +247,8 @@ def _grade(student, request, course, keep_raw_scores): ...@@ -247,6 +247,8 @@ def _grade(student, request, course, keep_raw_scores):
totaled_scores[section_format] = format_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) 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 # 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): ...@@ -330,6 +332,8 @@ def _progress_summary(student, request, course):
# This student must not have access to the course. # This student must not have access to the course.
return None 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)) submissions_scores = sub_api.get_scores(course.id.to_deprecated_string(), anonymous_id_for_user(student, course.id))
chapters = [] chapters = []
...@@ -373,7 +377,7 @@ def _progress_summary(student, request, course): ...@@ -373,7 +377,7 @@ def _progress_summary(student, request, course):
'scores': scores, 'scores': scores,
'section_total': section_total, 'section_total': section_total,
'format': module_format, 'format': module_format,
'due': get_extended_due_date(section_module), 'due': section_module.due,
'graded': graded, 'graded': graded,
}) })
...@@ -480,7 +484,7 @@ def manual_transaction(): ...@@ -480,7 +484,7 @@ def manual_transaction():
transaction.commit() 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: """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. (student, gradeset, err_msg) for every student enrolled in the course.
...@@ -498,7 +502,10 @@ def iterate_grades_for(course_id, students): ...@@ -498,7 +502,10 @@ def iterate_grades_for(course_id, students):
make up the final grade. (For display) make up the final grade. (For display)
- raw_scores: contains scores for every graded module - 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 # 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 # the request. We have to attach the correct user to the request before
...@@ -506,7 +513,7 @@ def iterate_grades_for(course_id, students): ...@@ -506,7 +513,7 @@ def iterate_grades_for(course_id, students):
request = RequestFactory().get('/') request = RequestFactory().get('/')
for student in students: 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: try:
request.user = student request.user = student
# Grading calls problem rendering, which calls masquerading, # Grading calls problem rendering, which calls masquerading,
...@@ -523,7 +530,7 @@ def iterate_grades_for(course_id, students): ...@@ -523,7 +530,7 @@ def iterate_grades_for(course_id, students):
'Cannot grade student %s (%s) in course %s because of exception: %s', 'Cannot grade student %s (%s) in course %s because of exception: %s',
student.username, student.username,
student.id, student.id,
course_id, course.id,
exc.message exc.message
) )
yield student, {}, exc.message yield student, {}, exc.message
...@@ -18,7 +18,9 @@ from django.db import models ...@@ -18,7 +18,9 @@ from django.db import models
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.dispatch import receiver 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): class StudentModule(models.Model):
...@@ -35,8 +37,7 @@ class StudentModule(models.Model): ...@@ -35,8 +37,7 @@ class StudentModule(models.Model):
('course', 'course'), ('course', 'course'),
('chapter', 'Section'), ('chapter', 'Section'),
('sequential', 'Subsection'), ('sequential', 'Subsection'),
('library_content', 'Library Content'), ('library_content', 'Library Content'))
)
## These three are the key for the object ## These three are the key for the object
module_type = models.CharField(max_length=32, choices=MODULE_TYPES, default='problem', db_index=True) module_type = models.CharField(max_length=32, choices=MODULE_TYPES, default='problem', db_index=True)
...@@ -86,7 +87,7 @@ class StudentModule(models.Model): ...@@ -86,7 +87,7 @@ class StudentModule(models.Model):
return 'StudentModule<%r>' % ({ return 'StudentModule<%r>' % ({
'course_id': self.course_id, 'course_id': self.course_id,
'module_type': self.module_type, 'module_type': self.module_type,
'student': self.student.username, 'student': self.student.username, # pylint: disable=no-member
'module_state_key': self.module_state_key, 'module_state_key': self.module_state_key,
'state': str(self.state)[:20], 'state': str(self.state)[:20],
},) },)
...@@ -230,3 +231,20 @@ class OfflineComputedGradeLog(models.Model): ...@@ -230,3 +231,20 @@ class OfflineComputedGradeLog(models.Model):
def __unicode__(self): def __unicode__(self):
return "[OCGLog] %s: %s" % (self.course_id.to_deprecated_string(), self.created) # pylint: disable=no-member 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 ...@@ -54,7 +54,6 @@ from opaque_keys.edx.locations import SlashSeparatedCourseKey
from xmodule.contentstore.django import contentstore from xmodule.contentstore.django import contentstore
from xmodule.modulestore.django import modulestore, ModuleI18nService from xmodule.modulestore.django import modulestore, ModuleI18nService
from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.util.duedate import get_extended_due_date
from xmodule_modifiers import ( from xmodule_modifiers import (
replace_course_urls, replace_course_urls,
replace_jump_to_id_urls, replace_jump_to_id_urls,
...@@ -72,6 +71,8 @@ from util import milestones_helpers ...@@ -72,6 +71,8 @@ from util import milestones_helpers
from util.module_utils import yield_dynamic_descriptor_descendents from util.module_utils import yield_dynamic_descriptor_descendents
from verify_student.services import ReverificationService from verify_student.services import ReverificationService
from .field_overrides import OverrideFieldData
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
...@@ -171,7 +172,7 @@ def toc_for_course(request, course, active_chapter, active_section, field_data_c ...@@ -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, sections.append({'display_name': section.display_name_with_default,
'url_name': section.url_name, 'url_name': section.url_name,
'format': section.format if section.format is not None else '', 'format': section.format if section.format is not None else '',
'due': get_extended_due_date(section), 'due': section.due,
'active': active, 'active': active,
'graded': section.graded, 'graded': section.graded,
}) })
...@@ -497,11 +498,17 @@ def get_module_system_for_user(user, field_data_cache, ...@@ -497,11 +498,17 @@ def get_module_system_for_user(user, field_data_cache,
request_token=request_token request_token=request_token
) )
# rebinds module to a different student. We'll change system, student_data, and scope_ids # 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( module.descriptor.bind_for_student(
inner_system, inner_system,
LmsFieldData(module.descriptor._field_data, inner_student_data), # pylint: disable=protected-access LmsFieldData(authored_data, inner_student_data),
real_user.id, 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 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 # now bind the module to the new ModuleSystem instance and vice-versa
module.runtime = inner_system module.runtime = inner_system
...@@ -670,13 +677,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours ...@@ -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 request_token (str): A unique token for this request, used to isolate xblock rendering
""" """
# Do not check access when it's a noauth request. (system, student_data) = get_module_system_for_user(
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(
user=user, user=user,
field_data_cache=field_data_cache, # These have implicit user bindings, the rest of args are considered not to field_data_cache=field_data_cache, # These have implicit user bindings, the rest of args are considered not to
descriptor=descriptor, descriptor=descriptor,
...@@ -691,7 +692,18 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours ...@@ -691,7 +692,18 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours
request_token=request_token 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 return descriptor
......
"""
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
"""
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'
"""
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 ...@@ -12,7 +12,13 @@ TO DO sync instructor and staff flags
import logging import logging
from django_comment_common.models import Role 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__) log = logging.getLogger(__name__)
...@@ -20,6 +26,7 @@ ROLES = { ...@@ -20,6 +26,7 @@ ROLES = {
'beta': CourseBetaTesterRole, 'beta': CourseBetaTesterRole,
'instructor': CourseInstructorRole, 'instructor': CourseInstructorRole,
'staff': CourseStaffRole, 'staff': CourseStaffRole,
'ccx_coach': CourseCcxCoachRole,
} }
......
...@@ -81,7 +81,6 @@ def student_grades(student, request, course, keep_raw_scores=False, use_offline= ...@@ -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 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. as use_offline. If use_offline is True then this will look for an offline computed gradeset in the DB.
''' '''
if not use_offline: if not use_offline:
return grades.grade(student, request, course, keep_raw_scores=keep_raw_scores) return grades.grade(student, request, course, keep_raw_scores=keep_raw_scores)
......
...@@ -50,6 +50,9 @@ from xmodule.modulestore import ModuleStoreEnum ...@@ -50,6 +50,9 @@ from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.fields import Date
from courseware.models import StudentFieldOverride
import instructor_task.api import instructor_task.api
import instructor.views.api import instructor.views.api
...@@ -61,8 +64,8 @@ from instructor_task.api_helper import AlreadyRunningError ...@@ -61,8 +64,8 @@ from instructor_task.api_helper import AlreadyRunningError
from openedx.core.djangoapps.course_groups.cohorts import set_course_cohort_settings from openedx.core.djangoapps.course_groups.cohorts import set_course_cohort_settings
from .test_tools import msk_from_problem_urlname from .test_tools import msk_from_problem_urlname
from ..views.tools import get_extended_due
DATE_FIELD = Date()
EXPECTED_CSV_HEADER = ( EXPECTED_CSV_HEADER = (
'"code","redeem_code_url","course_id","company_name","created_by","redeemed_by","invoice_id","purchaser",' '"code","redeem_code_url","course_id","company_name","created_by","redeemed_by","invoice_id","purchaser",'
'"customer_reference_number","internal_reference"' '"customer_reference_number","internal_reference"'
...@@ -3114,6 +3117,23 @@ class TestInstructorAPIHelpers(TestCase): ...@@ -3114,6 +3117,23 @@ class TestInstructorAPIHelpers(TestCase):
msk_from_problem_urlname(*args) 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): class TestDueDateExtensions(ModuleStoreTestCase, LoginEnrollmentTestCase):
""" """
Test data dumps for reporting. Test data dumps for reporting.
......
...@@ -3,15 +3,15 @@ Tests for views/tools.py. ...@@ -3,15 +3,15 @@ Tests for views/tools.py.
""" """
import datetime import datetime
import functools
import mock import mock
import json import json
import unittest import unittest
from django.utils.timezone import utc from django.utils.timezone import utc
from django.test.utils import override_settings
from courseware.models import StudentModule from courseware.field_overrides import OverrideFieldData # pylint: disable=import-error
from student.tests.factories import UserFactory from student.tests.factories import UserFactory # pylint: disable=import-error
from xmodule.fields import Date from xmodule.fields import Date
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
...@@ -180,6 +180,10 @@ class TestTitleOrUrl(unittest.TestCase): ...@@ -180,6 +180,10 @@ class TestTitleOrUrl(unittest.TestCase):
self.assertEquals(tools.title_or_url(unit), 'test:hello') self.assertEquals(tools.title_or_url(unit), 'test:hello')
@override_settings(
FIELD_OVERRIDE_PROVIDERS=(
'courseware.student_field_overrides.IndividualStudentOverrideProvider',),
)
class TestSetDueDateExtension(ModuleStoreTestCase): class TestSetDueDateExtension(ModuleStoreTestCase):
""" """
Test the set_due_date_extensions function. Test the set_due_date_extensions function.
...@@ -189,53 +193,60 @@ class TestSetDueDateExtension(ModuleStoreTestCase): ...@@ -189,53 +193,60 @@ class TestSetDueDateExtension(ModuleStoreTestCase):
Fixtures. Fixtures.
""" """
super(TestSetDueDateExtension, self).setUp() 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() course = CourseFactory.create()
week1 = ItemFactory.create(due=due, parent=course) week1 = ItemFactory.create(due=due, parent=course)
week2 = ItemFactory.create(due=due, parent=course) week2 = ItemFactory.create(due=due, parent=course)
week3 = ItemFactory.create(parent=course) week3 = ItemFactory.create(parent=course)
homework = ItemFactory.create(parent=week1)
homework = ItemFactory.create( assignment = ItemFactory.create(parent=homework, due=due)
parent=week1,
due=due
)
user = UserFactory.create() 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.course = course
self.week1 = week1 self.week1 = week1
self.homework = homework self.homework = homework
self.assignment = assignment
self.week2 = week2 self.week2 = week2
self.week3 = week3 self.week3 = week3
self.user = user self.user = user
self.extended_due = functools.partial( # Apparently the test harness doesn't use LmsFieldStorage, and I'm not
tools.get_extended_due, course, student=user) # 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): def test_set_due_date_extension(self):
extended = datetime.datetime(2013, 12, 25, 0, 0, tzinfo=utc) 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, extended)
self.assertEqual(self.extended_due(self.week1), extended) self._clear_field_data_cache()
self.assertEqual(self.extended_due(self.homework), extended) 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) extended = datetime.datetime(2013, 12, 25, 0, 0, tzinfo=utc)
user = UserFactory.create() # No student modules for this user with self.assertNumQueries(4):
tools.set_due_date_extension(self.course, self.week1, user, extended) tools.set_due_date_extension(self.course, self.week1, self.user, extended)
extended_due = functools.partial(tools.get_extended_due, self.course, student=user) self._clear_field_data_cache()
self.assertEqual(extended_due(self.week1), extended)
self.assertEqual(extended_due(self.homework), extended)
def test_set_due_date_extension_invalid_date(self): def test_set_due_date_extension_invalid_date(self):
extended = datetime.datetime(2009, 1, 1, 0, 0, tzinfo=utc) extended = datetime.datetime(2009, 1, 1, 0, 0, tzinfo=utc)
...@@ -251,8 +262,7 @@ class TestSetDueDateExtension(ModuleStoreTestCase): ...@@ -251,8 +262,7 @@ class TestSetDueDateExtension(ModuleStoreTestCase):
extended = datetime.datetime(2013, 12, 25, 0, 0, tzinfo=utc) 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, extended)
tools.set_due_date_extension(self.course, self.week1, self.user, None) tools.set_due_date_extension(self.course, self.week1, self.user, None)
self.assertEqual(self.extended_due(self.week1), None) self.assertEqual(self.week1.due, self.due)
self.assertEqual(self.extended_due(self.homework), None)
class TestDataDumps(ModuleStoreTestCase): class TestDataDumps(ModuleStoreTestCase):
...@@ -270,7 +280,6 @@ class TestDataDumps(ModuleStoreTestCase): ...@@ -270,7 +280,6 @@ class TestDataDumps(ModuleStoreTestCase):
course = CourseFactory.create() course = CourseFactory.create()
week1 = ItemFactory.create(due=due, parent=course) week1 = ItemFactory.create(due=due, parent=course)
week2 = ItemFactory.create(due=due, parent=course) week2 = ItemFactory.create(due=due, parent=course)
week3 = ItemFactory.create(due=due, parent=course)
homework = ItemFactory.create( homework = ItemFactory.create(
parent=week1, parent=week1,
...@@ -278,51 +287,7 @@ class TestDataDumps(ModuleStoreTestCase): ...@@ -278,51 +287,7 @@ class TestDataDumps(ModuleStoreTestCase):
) )
user1 = UserFactory.create() 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() 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.course = course
self.week1 = week1 self.week1 = week1
self.homework = homework self.homework = homework
......
...@@ -73,7 +73,7 @@ from instructor.enrollment import ( ...@@ -73,7 +73,7 @@ from instructor.enrollment import (
send_beta_role_email, send_beta_role_email,
unenroll_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 from instructor.offline_gradecalc import student_grades
import instructor_analytics.basic import instructor_analytics.basic
import instructor_analytics.distributions import instructor_analytics.distributions
...@@ -679,7 +679,7 @@ def bulk_beta_modify_access(request, course_id): ...@@ -679,7 +679,7 @@ def bulk_beta_modify_access(request, course_id):
@common_exceptions_400 @common_exceptions_400
@require_query_params( @require_query_params(
unique_student_identifier="email or username of user to change access", 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'" action="'allow' or 'revoke'"
) )
def modify_access(request, course_id): def modify_access(request, course_id):
...@@ -691,7 +691,7 @@ def modify_access(request, course_id): ...@@ -691,7 +691,7 @@ def modify_access(request, course_id):
Query parameters: Query parameters:
unique_student_identifer is the target user's username or email 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'] action is one of ['allow', 'revoke']
""" """
course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id)
...@@ -720,10 +720,10 @@ def modify_access(request, course_id): ...@@ -720,10 +720,10 @@ def modify_access(request, course_id):
rolename = request.GET.get('rolename') rolename = request.GET.get('rolename')
action = request.GET.get('action') action = request.GET.get('action')
if rolename not in ['instructor', 'staff', 'beta']: if rolename not in ROLES:
return HttpResponseBadRequest(strip_tags( error = strip_tags("unknown rolename '{}'".format(rolename))
"unknown rolename '{}'".format(rolename) log.error(error)
)) return HttpResponseBadRequest(error)
# disallow instructors from removing their own instructor access. # disallow instructors from removing their own instructor access.
if rolename == 'instructor' and user == request.user and action != 'allow': if rolename == 'instructor' and user == request.user and action != 'allow':
...@@ -762,7 +762,7 @@ def list_course_role_members(request, course_id): ...@@ -762,7 +762,7 @@ def list_course_role_members(request, course_id):
List instructors and staff. List instructors and staff.
Requires instructor access. Requires instructor access.
rolename is one of ['instructor', 'staff', 'beta'] rolename is one of ['instructor', 'staff', 'beta', 'ccx_coach']
Returns JSON of the form { Returns JSON of the form {
"course_id": "some/course/id", "course_id": "some/course/id",
...@@ -783,7 +783,7 @@ def list_course_role_members(request, course_id): ...@@ -783,7 +783,7 @@ def list_course_role_members(request, course_id):
rolename = request.GET.get('rolename') rolename = request.GET.get('rolename')
if rolename not in ['instructor', 'staff', 'beta']: if rolename not in ROLES:
return HttpResponseBadRequest() return HttpResponseBadRequest()
def extract_user_info(user): def extract_user_info(user):
......
...@@ -10,7 +10,13 @@ from django.http import HttpResponseBadRequest ...@@ -10,7 +10,13 @@ from django.http import HttpResponseBadRequest
from django.utils.timezone import utc from django.utils.timezone import utc
from django.utils.translation import ugettext as _ 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.fields import Date
from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -175,22 +181,6 @@ def title_or_url(node): ...@@ -175,22 +181,6 @@ def title_or_url(node):
return title 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): def set_due_date_extension(course, unit, student, due_date):
""" """
Sets a due date extension. Raises DashboardError if the unit or extended 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): ...@@ -198,56 +188,22 @@ def set_due_date_extension(course, unit, student, due_date):
""" """
if due_date: if due_date:
# Check that the new due date is valid: # 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: if not original_due_date:
raise DashboardError(_("Unit {0} has no due date to extend.").format(unit.location)) raise DashboardError(_("Unit {0} has no due date to extend.").format(unit.location))
if due_date < original_due_date: if due_date < original_due_date:
raise DashboardError(_("An extended due date must be later than the 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: else:
# We are deleting a due date extension. Check that it exists: # 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.")) raise DashboardError(_("No due date extension is set for that student and unit."))
def set_due_date(node): clear_override_for_user(student, unit, 'due')
"""
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)
def dump_module_extensions(course, unit): def dump_module_extensions(course, unit):
...@@ -257,20 +213,17 @@ def dump_module_extensions(course, unit): ...@@ -257,20 +213,17 @@ def dump_module_extensions(course, unit):
""" """
data = [] data = []
header = [_("Username"), _("Full Name"), _("Extended Due Date")] header = [_("Username"), _("Full Name"), _("Extended Due Date")]
query = StudentModule.objects.filter( query = StudentFieldOverride.objects.filter(
course_id=course.id, course_id=course.id,
module_state_key=unit.location) location=unit.location,
for module in query: field='due')
state = json.loads(module.state) for override in query:
extended_due = state.get("extended_due") due = DATE_FIELD.from_json(json.loads(override.value))
if not extended_due: due = due.strftime("%Y-%m-%d %H:%M")
continue fullname = override.student.profile.name
extended_due = DATE_FIELD.from_json(extended_due)
extended_due = extended_due.strftime("%Y-%m-%d %H:%M")
fullname = module.student.profile.name
data.append(dict(zip( data.append(dict(zip(
header, header,
(module.student.username, fullname, extended_due)))) (override.student.username, fullname, due))))
data.sort(key=lambda x: x[header[0]]) data.sort(key=lambda x: x[header[0]])
return { return {
"header": header, "header": header,
...@@ -288,23 +241,19 @@ def dump_student_extensions(course, student): ...@@ -288,23 +241,19 @@ def dump_student_extensions(course, student):
data = [] data = []
header = [_("Unit"), _("Extended Due Date")] header = [_("Unit"), _("Extended Due Date")]
units = get_units_with_due_date(course) units = get_units_with_due_date(course)
units = dict([(u.location, u) for u in units]) units = {u.location: u for u in units}
query = StudentModule.objects.filter( query = StudentFieldOverride.objects.filter(
course_id=course.id, course_id=course.id,
student_id=student.id) student=student,
for module in query: field='due')
state = json.loads(module.state) for override in query:
# temporary hack: module_state_key is missing the run but units are not. fix module_state_key location = override.location.replace(course_key=course.id)
module_loc = module.module_state_key.map_into_course(module.course_id) if location not in units:
if module_loc not in units:
continue
extended_due = state.get("extended_due")
if not extended_due:
continue continue
extended_due = DATE_FIELD.from_json(extended_due) due = DATE_FIELD.from_json(json.loads(override.value))
extended_due = extended_due.strftime("%Y-%m-%d %H:%M") due = due.strftime("%Y-%m-%d %H:%M")
title = title_or_url(units[module_loc]) title = title_or_url(units[location])
data.append(dict(zip(header, (title, extended_due)))) data.append(dict(zip(header, (title, due))))
return { return {
"header": header, "header": header,
"title": _("Due date extensions for {0} {1} ({2})").format( "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 ...@@ -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') 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 ############### ############################## SECURE AUTH ITEMS ###############
# Secret things: passwords, access keys, etc. # Secret things: passwords, access keys, etc.
...@@ -572,3 +576,17 @@ ONLOAD_BEACON_SAMPLE_RATE = ENV_TOKENS.get('ONLOAD_BEACON_SAMPLE_RATE', ONLOAD_B ...@@ -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_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_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) 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 = { ...@@ -208,8 +208,15 @@ FEATURES = {
'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True, 'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True,
# Enable instructor to assign individual due dates # 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, 'INDIVIDUAL_DUE_DATES': False,
# Enable Custom Courses for EdX
'CUSTOM_COURSES_EDX': False,
# Enable legacy instructor dashboard # Enable legacy instructor dashboard
'ENABLE_INSTRUCTOR_LEGACY_DASHBOARD': True, 'ENABLE_INSTRUCTOR_LEGACY_DASHBOARD': True,
...@@ -1196,6 +1203,9 @@ reverify_js = [ ...@@ -1196,6 +1203,9 @@ reverify_js = [
'js/verify_student/incourse_reverify.js', 'js/verify_student/incourse_reverify.js',
] ]
ccx_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/ccx/**/*.js'))
PIPELINE_CSS = { PIPELINE_CSS = {
'style-vendor': { 'style-vendor': {
'source_filenames': [ 'source_filenames': [
...@@ -1389,6 +1399,10 @@ PIPELINE_JS = { ...@@ -1389,6 +1399,10 @@ PIPELINE_JS = {
'reverify': { 'reverify': {
'source_filenames': reverify_js, 'source_filenames': reverify_js,
'output_filename': 'js/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 ...@@ -2216,3 +2230,9 @@ ECOMMERCE_API_TIMEOUT = 5
# Reverification checkpoint name pattern # Reverification checkpoint name pattern
CHECKPOINT_PATTERN = r'(?P<checkpoint_name>\w+)' 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 = { ...@@ -445,7 +445,6 @@ MONGODB_LOG = {
'db': 'xlog', 'db': 'xlog',
} }
# Enable EdxNotes for tests. # Enable EdxNotes for tests.
FEATURES['ENABLE_EDXNOTES'] = True FEATURES['ENABLE_EDXNOTES'] = True
...@@ -469,3 +468,7 @@ FACEBOOK_API_VERSION = "v2.2" ...@@ -469,3 +468,7 @@ FACEBOOK_API_VERSION = "v2.2"
# Certificates Views # Certificates Views
FEATURES['CERTIFICATES_HTML_VIEW'] = True FEATURES['CERTIFICATES_HTML_VIEW'] = True
######### custom courses #########
INSTALLED_APPS += ('ccx',)
MIDDLEWARE_CLASSES += ('ccx.overrides.CcxMiddleware',)
...@@ -77,5 +77,8 @@ ...@@ -77,5 +77,8 @@
@import "course/instructor/email"; @import "course/instructor/email";
@import "xmodule/descriptors/css/module-styles.scss"; @import "xmodule/descriptors/css/module-styles.scss";
// course - discussion // course - ccx_coach
@import "course/ccx_coach/dashboard";
// discussion
@import "course/discussion/form-wmd-toolbar"; @import "course/discussion/form-wmd-toolbar";
.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;
}
}
<%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>
<%! 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>
<%! 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
)}
<%! from django.utils.translation import ugettext as _ %>
${_("You have been invited to register for {course_name}").format(
course_name=course.display_name
)}
<%! 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
)}
<%! from django.utils.translation import ugettext as _ %>
${_("You have been enrolled in {course_name}").format(
course_name=course.display_name
)}
<%! 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>
<%! 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>
<%! 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>
<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">&nbsp;<%= 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>
<%! 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>
<%! 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
)}
<%! 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
)}
<%! 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 @@ ...@@ -94,6 +94,13 @@
<% course_requirements = courses_requirements_not_met.get(course.id) %> <% 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" /> <%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 % 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> </ul>
% else: % else:
<section class="empty-dashboard-message"> <section class="empty-dashboard-message">
......
...@@ -243,5 +243,18 @@ ...@@ -243,5 +243,18 @@
data-add-button-label="${_("Add Community TA")}" data-add-button-label="${_("Add Community TA")}"
></div> ></div>
%endif %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> </div>
...@@ -13,6 +13,7 @@ from status.status import get_site_status_msg ...@@ -13,6 +13,7 @@ from status.status import get_site_status_msg
<%! from microsite_configuration import microsite %> <%! from microsite_configuration import microsite %>
<%! from microsite_configuration.templatetags.microsite import platform_name %> <%! 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. ## Provide a hook for themes to inject branding on top.
<%block name="navigation_top" /> <%block name="navigation_top" />
...@@ -47,7 +48,16 @@ site_status_msg = get_site_status_msg(course_id) ...@@ -47,7 +48,16 @@ site_status_msg = get_site_status_msg(course_id)
</h1> </h1>
% if course: % 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 % endif
% if user.is_authenticated(): % if user.is_authenticated():
......
...@@ -343,6 +343,26 @@ if settings.COURSEWARE_ENABLED: ...@@ -343,6 +343,26 @@ if settings.COURSEWARE_ENABLED:
# For the instructor # For the instructor
url(r'^courses/{}/instructor$'.format(settings.COURSE_ID_PATTERN), url(r'^courses/{}/instructor$'.format(settings.COURSE_ID_PATTERN),
'instructor.views.instructor_dashboard.instructor_dashboard_2', name="instructor_dashboard"), '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), 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"), 'instructor.views.instructor_dashboard.set_course_mode_price', name="set_course_mode_price"),
url(r'^courses/{}/instructor/api/'.format(settings.COURSE_ID_PATTERN), url(r'^courses/{}/instructor/api/'.format(settings.COURSE_ID_PATTERN),
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment