From a2cb7fd276c9200e1b2b1753cf9a51cffd7fece8 Mon Sep 17 00:00:00 2001 From: Chris Rossi Date: Thu, 13 Nov 2014 13:29:34 -0500 Subject: [PATCH] MIT: CCX. Implement Custom Courses for Edx. This feature provides the ability to designate a "coach" who can create customized runs of an existing course, invite students to participate, and manage students through the run of the course. In this squashed commit we implement the initial scifi, add the 'POC Coach' course role, refine the scifi, add migrations for models, create POCs, enforce POC Coach role, provide panels for Coach Dashboard, set up rudimentary display of course outline, add and remove units, show/hide all units, and save schedule changes, set dates when adding units, edit dates on units already added and provide some tests. We also provide mechanisms for invitation and enrollment in a POC (to become CCX) and control the display of blocks to students in a POC. --- common/djangoapps/student/roles.py | 8 + common/lib/xmodule/xmodule/tabs.py | 26 + lms/djangoapps/courseware/module_render.py | 11 +- lms/djangoapps/instructor/access.py | 9 +- lms/djangoapps/instructor/views/api.py | 18 +- lms/djangoapps/pocs/__init__.py | 0 .../pocs/migrations/0001_initial.py | 116 ++++ ...bership__add_field_pocmembership_active.py | 100 ++++ lms/djangoapps/pocs/migrations/__init__.py | 0 lms/djangoapps/pocs/models.py | 44 ++ lms/djangoapps/pocs/overrides.py | 86 +++ lms/djangoapps/pocs/tests/__init__.py | 0 lms/djangoapps/pocs/tests/test_overrides.py | 112 ++++ lms/djangoapps/pocs/tests/test_views.py | 166 ++++++ lms/djangoapps/pocs/utils.py | 213 ++++++++ lms/djangoapps/pocs/views.py | 244 +++++++++ lms/envs/common.py | 3 + lms/envs/test.py | 4 +- lms/static/sass/course.scss.mako | 5 +- .../sass/course/poc_coach/_dashboard.scss | 57 ++ .../instructor_dashboard_2/membership.html | 15 +- lms/templates/pocs/coach_dashboard.html | 81 +++ .../pocs/enroll_email_allowedmessage.txt | 43 ++ .../pocs/enroll_email_allowedsubject.txt | 5 + .../pocs/enroll_email_enrolledmessage.txt | 20 + .../pocs/enroll_email_enrolledsubject.txt | 5 + lms/templates/pocs/enrollment.html | 78 +++ lms/templates/pocs/schedule.html | 504 ++++++++++++++++++ .../pocs/unenroll_email_allowedmessage.txt | 13 + .../pocs/unenroll_email_enrolledmessage.txt | 17 + lms/templates/pocs/unenroll_email_subject.txt | 5 + lms/urls.py | 9 + 32 files changed, 2003 insertions(+), 14 deletions(-) create mode 100644 lms/djangoapps/pocs/__init__.py create mode 100644 lms/djangoapps/pocs/migrations/0001_initial.py create mode 100644 lms/djangoapps/pocs/migrations/0002_auto__add_pocfuturemembership__add_field_pocmembership_active.py create mode 100644 lms/djangoapps/pocs/migrations/__init__.py create mode 100644 lms/djangoapps/pocs/models.py create mode 100644 lms/djangoapps/pocs/overrides.py create mode 100644 lms/djangoapps/pocs/tests/__init__.py create mode 100644 lms/djangoapps/pocs/tests/test_overrides.py create mode 100644 lms/djangoapps/pocs/tests/test_views.py create mode 100644 lms/djangoapps/pocs/utils.py create mode 100644 lms/djangoapps/pocs/views.py create mode 100644 lms/static/sass/course/poc_coach/_dashboard.scss create mode 100644 lms/templates/pocs/coach_dashboard.html create mode 100644 lms/templates/pocs/enroll_email_allowedmessage.txt create mode 100644 lms/templates/pocs/enroll_email_allowedsubject.txt create mode 100644 lms/templates/pocs/enroll_email_enrolledmessage.txt create mode 100644 lms/templates/pocs/enroll_email_enrolledsubject.txt create mode 100644 lms/templates/pocs/enrollment.html create mode 100644 lms/templates/pocs/schedule.html create mode 100644 lms/templates/pocs/unenroll_email_allowedmessage.txt create mode 100644 lms/templates/pocs/unenroll_email_enrolledmessage.txt create mode 100644 lms/templates/pocs/unenroll_email_subject.txt diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index c1b78748e4..c94789641f 100644 --- a/common/djangoapps/student/roles.py +++ b/common/djangoapps/student/roles.py @@ -267,6 +267,14 @@ class LibraryUserRole(CourseRole): super(LibraryUserRole, self).__init__(self.ROLE, *args, **kwargs) +class CoursePocCoachRole(CourseRole): + """A POC Coach""" + ROLE = 'poc_coach' + + def __init__(self, *args, **kwargs): + super(CoursePocCoachRole, self).__init__(self.ROLE, *args, **kwargs) + + class OrgStaffRole(OrgRole): """An organization staff member""" def __init__(self, *args, **kwargs): diff --git a/common/lib/xmodule/xmodule/tabs.py b/common/lib/xmodule/xmodule/tabs.py index d4b986d3b6..66285df67d 100644 --- a/common/lib/xmodule/xmodule/tabs.py +++ b/common/lib/xmodule/xmodule/tabs.py @@ -193,6 +193,7 @@ class CourseTab(object): 'edxnotes': EdxNotesTab, 'syllabus': SyllabusTab, 'instructor': InstructorTab, # not persisted + 'poc_coach': PocCoachTab, # not persisted } tab_type = tab_dict.get('type') @@ -733,6 +734,28 @@ class InstructorTab(StaffTab): ) +class PocCoachTab(CourseTab): + """ + A tab for the personal online course coaches. + """ + type = 'poc_coach' + + def __init__(self, tab_dict=None): # pylint: disable=unused-argument + super(PocCoachTab, self).__init__( + name=_('POC Coach'), + tab_id=self.type, + link_func=link_reverse_func('poc_coach_dashboard'), + ) + + def can_display(self, course, settings, *args, **kw): + # TODO Check that user actually has 'poc_coach' role on course + # this is difficult to do because the user isn't passed in. + # We need either a hack or an architectural realignment. + return ( + settings.FEATURES.get('PERSONAL_ONLINE_COURSES', False) and + super(PocCoachTab, self).can_display(course, settings, *args, **kw)) + + class CourseTabList(List): """ An XBlock field class that encapsulates a collection of Tabs in a course. @@ -833,6 +856,9 @@ class CourseTabList(List): instructor_tab = InstructorTab() if instructor_tab.can_display(course, settings, is_user_authenticated, is_user_staff, is_user_enrolled): yield instructor_tab + poc_coach_tab = PocCoachTab() + if poc_coach_tab.can_display(course, settings, is_user_authenticated, is_user_staff, is_user_enrolled): + yield poc_coach_tab @staticmethod def iterate_displayable_cms( diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index 923827e84d..9e258988cb 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -681,7 +681,7 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours if not has_access(user, 'load', descriptor, course_id): return None - (system, field_data) = get_module_system_for_user( + (system, student_data) = get_module_system_for_user( user=user, field_data_cache=field_data_cache, # These have implicit user bindings, the rest of args are considered not to descriptor=descriptor, @@ -699,6 +699,15 @@ def get_module_for_descriptor_internal(user, descriptor, field_data_cache, cours authored_data = OverrideFieldData.wrap(user, descriptor._field_data) # pylint: disable=protected-access descriptor.bind_for_student(system, LmsFieldData(authored_data, field_data), user.id) descriptor.scope_ids = descriptor.scope_ids._replace(user_id=user.id) # pylint: disable=protected-access + + # 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 diff --git a/lms/djangoapps/instructor/access.py b/lms/djangoapps/instructor/access.py index 168814256e..d39fca0e1a 100644 --- a/lms/djangoapps/instructor/access.py +++ b/lms/djangoapps/instructor/access.py @@ -12,7 +12,13 @@ TO DO sync instructor and staff flags import logging from django_comment_common.models import Role -from student.roles import CourseBetaTesterRole, CourseInstructorRole, CourseStaffRole +from student.roles import ( + CourseBetaTesterRole, + CourseInstructorRole, + CoursePocCoachRole, + CourseStaffRole, +) + log = logging.getLogger(__name__) @@ -20,6 +26,7 @@ ROLES = { 'beta': CourseBetaTesterRole, 'instructor': CourseInstructorRole, 'staff': CourseStaffRole, + 'poc_coach': CoursePocCoachRole, } diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 1610a796c1..29d0a5a1d7 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -73,7 +73,7 @@ from instructor.enrollment import ( send_beta_role_email, unenroll_email, ) -from instructor.access import list_with_level, allow_access, revoke_access, update_forum_role +from instructor.access import list_with_level, allow_access, revoke_access, ROLES, update_forum_role from instructor.offline_gradecalc import student_grades import instructor_analytics.basic import instructor_analytics.distributions @@ -679,7 +679,7 @@ def bulk_beta_modify_access(request, course_id): @common_exceptions_400 @require_query_params( unique_student_identifier="email or username of user to change access", - rolename="'instructor', 'staff', or 'beta'", + rolename="'instructor', 'staff', 'beta', or 'poc_coach'", action="'allow' or 'revoke'" ) def modify_access(request, course_id): @@ -691,7 +691,7 @@ def modify_access(request, course_id): Query parameters: unique_student_identifer is the target user's username or email - rolename is one of ['instructor', 'staff', 'beta'] + rolename is one of ['instructor', 'staff', 'beta', 'poc_coach'] action is one of ['allow', 'revoke'] """ course_id = SlashSeparatedCourseKey.from_deprecated_string(course_id) @@ -720,10 +720,10 @@ def modify_access(request, course_id): rolename = request.GET.get('rolename') action = request.GET.get('action') - if rolename not in ['instructor', 'staff', 'beta']: - return HttpResponseBadRequest(strip_tags( - "unknown rolename '{}'".format(rolename) - )) + if not rolename in ROLES: + error = strip_tags("unknown rolename '{}'".format(rolename)) + log.error(error) + return HttpResponseBadRequest(error) # disallow instructors from removing their own instructor access. if rolename == 'instructor' and user == request.user and action != 'allow': @@ -762,7 +762,7 @@ def list_course_role_members(request, course_id): List instructors and staff. Requires instructor access. - rolename is one of ['instructor', 'staff', 'beta'] + rolename is one of ['instructor', 'staff', 'beta', 'poc_coach'] Returns JSON of the form { "course_id": "some/course/id", @@ -783,7 +783,7 @@ def list_course_role_members(request, course_id): rolename = request.GET.get('rolename') - if rolename not in ['instructor', 'staff', 'beta']: + if not rolename in ROLES: return HttpResponseBadRequest() def extract_user_info(user): diff --git a/lms/djangoapps/pocs/__init__.py b/lms/djangoapps/pocs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/pocs/migrations/0001_initial.py b/lms/djangoapps/pocs/migrations/0001_initial.py new file mode 100644 index 0000000000..4572bf8991 --- /dev/null +++ b/lms/djangoapps/pocs/migrations/0001_initial.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'PersonalOnlineCourse' + db.create_table('pocs_personalonlinecourse', ( + ('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('pocs', ['PersonalOnlineCourse']) + + # Adding model 'PocMembership' + db.create_table('pocs_pocmembership', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('poc', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['pocs.PersonalOnlineCourse'])), + ('student', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + )) + db.send_create_signal('pocs', ['PocMembership']) + + # Adding model 'PocFieldOverride' + db.create_table('pocs_pocfieldoverride', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('poc', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['pocs.PersonalOnlineCourse'])), + ('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('pocs', ['PocFieldOverride']) + + # Adding unique constraint on 'PocFieldOverride', fields ['poc', 'location', 'field'] + db.create_unique('pocs_pocfieldoverride', ['poc_id', 'location', 'field']) + + + def backwards(self, orm): + # Removing unique constraint on 'PocFieldOverride', fields ['poc', 'location', 'field'] + db.delete_unique('pocs_pocfieldoverride', ['poc_id', 'location', 'field']) + + # Deleting model 'PersonalOnlineCourse' + db.delete_table('pocs_personalonlinecourse') + + # Deleting model 'PocMembership' + db.delete_table('pocs_pocmembership') + + # Deleting model 'PocFieldOverride' + db.delete_table('pocs_pocfieldoverride') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'pocs.personalonlinecourse': { + 'Meta': {'object_name': 'PersonalOnlineCourse'}, + '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'}) + }, + 'pocs.pocfieldoverride': { + 'Meta': {'unique_together': "(('poc', 'location', 'field'),)", 'object_name': 'PocFieldOverride'}, + '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'}), + 'poc': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['pocs.PersonalOnlineCourse']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'pocs.pocmembership': { + 'Meta': {'object_name': 'PocMembership'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poc': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['pocs.PersonalOnlineCourse']"}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['pocs'] \ No newline at end of file diff --git a/lms/djangoapps/pocs/migrations/0002_auto__add_pocfuturemembership__add_field_pocmembership_active.py b/lms/djangoapps/pocs/migrations/0002_auto__add_pocfuturemembership__add_field_pocmembership_active.py new file mode 100644 index 0000000000..9e650b7526 --- /dev/null +++ b/lms/djangoapps/pocs/migrations/0002_auto__add_pocfuturemembership__add_field_pocmembership_active.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'PocFutureMembership' + db.create_table('pocs_pocfuturemembership', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('poc', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['pocs.PersonalOnlineCourse'])), + ('email', self.gf('django.db.models.fields.CharField')(max_length=255)), + )) + db.send_create_signal('pocs', ['PocFutureMembership']) + + # Adding field 'PocMembership.active' + db.add_column('pocs_pocmembership', 'active', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + + def backwards(self, orm): + # Deleting model 'PocFutureMembership' + db.delete_table('pocs_pocfuturemembership') + + # Deleting field 'PocMembership.active' + db.delete_column('pocs_pocmembership', 'active') + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'pocs.personalonlinecourse': { + 'Meta': {'object_name': 'PersonalOnlineCourse'}, + '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'}) + }, + 'pocs.pocfieldoverride': { + 'Meta': {'unique_together': "(('poc', 'location', 'field'),)", 'object_name': 'PocFieldOverride'}, + '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'}), + 'poc': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['pocs.PersonalOnlineCourse']"}), + 'value': ('django.db.models.fields.TextField', [], {'default': "'null'"}) + }, + 'pocs.pocfuturemembership': { + 'Meta': {'object_name': 'PocFutureMembership'}, + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poc': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['pocs.PersonalOnlineCourse']"}) + }, + 'pocs.pocmembership': { + 'Meta': {'object_name': 'PocMembership'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'poc': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['pocs.PersonalOnlineCourse']"}), + 'student': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['pocs'] \ No newline at end of file diff --git a/lms/djangoapps/pocs/migrations/__init__.py b/lms/djangoapps/pocs/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/pocs/models.py b/lms/djangoapps/pocs/models.py new file mode 100644 index 0000000000..da06a9fc7b --- /dev/null +++ b/lms/djangoapps/pocs/models.py @@ -0,0 +1,44 @@ +from django.contrib.auth.models import User +from django.db import models + +from xmodule_django.models import CourseKeyField, LocationKeyField + + +class PersonalOnlineCourse(models.Model): + """ + A Personal Online 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 PocMembership(models.Model): + """ + Which students are in a POC? + """ + poc = models.ForeignKey(PersonalOnlineCourse, db_index=True) + student = models.ForeignKey(User, db_index=True) + active = models.BooleanField(default=False) + + +class PocFutureMembership(models.Model): + """ + Which emails for non-users are waiting to be added to POC on registration + """ + poc = models.ForeignKey(PersonalOnlineCourse, db_index=True) + email = models.CharField(max_length=255) + + +class PocFieldOverride(models.Model): + """ + Field overrides for personal online courses. + """ + poc = models.ForeignKey(PersonalOnlineCourse, db_index=True) + location = LocationKeyField(max_length=255, db_index=True) + field = models.CharField(max_length=255) + + class Meta: + unique_together = (('poc', 'location', 'field'),) + + value = models.TextField(default='null') diff --git a/lms/djangoapps/pocs/overrides.py b/lms/djangoapps/pocs/overrides.py new file mode 100644 index 0000000000..5af4d799c9 --- /dev/null +++ b/lms/djangoapps/pocs/overrides.py @@ -0,0 +1,86 @@ +""" +API related to providing field overrides for individual students. This is used +by the individual due dates feature. +""" +import json + +from courseware.field_overrides import FieldOverrideProvider + +from .models import PocMembership, PocFieldOverride + + +class PersonalOnlineCoursesOverrideProvider(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): + poc = get_current_poc(self.user) + if poc: + return get_override_for_poc(poc, block, name, default) + return default + + +def get_current_poc(user): + """ + TODO Needs to look in user's session + """ + # Temporary implementation. Final implementation will need to look in + # user's session so user can switch between (potentially multiple) POC and + # MOOC views. See courseware.courses.get_request_for_thread for idea to + # get at the request object. + try: + membership = PocMembership.objects.get(student=user, active=True) + return membership.poc + except PocMembership.DoesNotExist: + return None + + +def get_override_for_poc(poc, block, name, default=None): + """ + Gets the value of the overridden field for the `poc`. `block` and `name` + specify the block and the name of the field. If the field is not + overridden for the given poc, returns `default`. + """ + try: + override = PocFieldOverride.objects.get( + poc=poc, + location=block.location, + field=name) + field = block.fields[name] + return field.from_json(json.loads(override.value)) + except PocFieldOverride.DoesNotExist: + pass + return default + + +def override_field_for_poc(poc, block, name, value): + """ + Overrides a field for the `poc`. `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, created = PocFieldOverride.objects.get_or_create( + poc=poc, + location=block.location, + field=name) + field = block.fields[name] + override.value = json.dumps(field.to_json(value)) + override.save() + + +def clear_override_for_poc(poc, block, name): + """ + Clears a previously set field override for the `poc`. `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: + PocFieldOverride.objects.get( + poc=poc, + location=block.location, + field=name).delete() + except PocFieldOverride.DoesNotExist: + pass diff --git a/lms/djangoapps/pocs/tests/__init__.py b/lms/djangoapps/pocs/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/pocs/tests/test_overrides.py b/lms/djangoapps/pocs/tests/test_overrides.py new file mode 100644 index 0000000000..4a8178768f --- /dev/null +++ b/lms/djangoapps/pocs/tests/test_overrides.py @@ -0,0 +1,112 @@ +import datetime +import mock +import pytz + +from courseware.field_overrides import OverrideFieldData +from django.test.utils import override_settings +from student.tests.factories import AdminFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +from ..models import PersonalOnlineCourse +from ..overrides import override_field_for_poc + + +@override_settings(FIELD_OVERRIDE_PROVIDERS=( + 'pocs.overrides.PersonalOnlineCoursesOverrideProvider',)) +class TestFieldOverrides(ModuleStoreTestCase): + """ + Make sure field overrides behave in the expected manner. + """ + def setUp(self): + """ + Set up tests + """ + 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([ + [ItemFactory.create(parent=vertical) for _ in xrange(2)] + for vertical in verticals]) + + self.poc = poc = PersonalOnlineCourse( + course_id=course.id, + display_name='Test POC', + coach=AdminFactory.create()) + poc.save() + + patch = mock.patch('pocs.overrides.get_current_poc') + self.get_poc = get_poc = patch.start() + get_poc.return_value = poc + 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 test_override_start(self): + """ + Test that overriding start date on a chapter works. + """ + poc_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC) + chapter = self.course.get_children()[0] + override_field_for_poc(self.poc, chapter, 'start', poc_start) + self.assertEquals(chapter.start, poc_start) + + def test_override_is_inherited(self): + """ + Test that sequentials inherit overridden start date from chapter. + """ + poc_start = datetime.datetime(2014, 12, 25, 00, 00, tzinfo=pytz.UTC) + chapter = self.course.get_children()[0] + override_field_for_poc(self.poc, chapter, 'start', poc_start) + self.assertEquals(chapter.get_children()[0].start, poc_start) + self.assertEquals(chapter.get_children()[1].start, poc_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. + """ + poc_due = datetime.datetime(2015, 1, 1, 00, 00, tzinfo=pytz.UTC) + chapter = self.course.get_children()[0] + chapter.display_name = 'itsme!' + override_field_for_poc(self.poc, chapter, 'due', poc_due) + vertical = chapter.get_children()[0].get_children()[0] + self.assertEqual(vertical.due, poc_due) + + +def flatten(seq): + """ + For [[1, 2], [3, 4]] returns [1, 2, 3, 4]. Does not recurse. + """ + return [x for sub in seq for x in sub] + + +def iter_blocks(course): + """ + Returns an iterator over all of the blocks in a course. + """ + def visit(block): + yield block + for child in block.get_children(): + for descendant in visit(child): # wish they'd backport yield from + yield descendant + return visit(course) diff --git a/lms/djangoapps/pocs/tests/test_views.py b/lms/djangoapps/pocs/tests/test_views.py new file mode 100644 index 0000000000..feaa83107f --- /dev/null +++ b/lms/djangoapps/pocs/tests/test_views.py @@ -0,0 +1,166 @@ +import datetime +import json +import re +import pytz +from mock import patch + +from courseware.tests.helpers import LoginEnrollmentTestCase +from django.core.urlresolvers import reverse +from edxmako.shortcuts import render_to_response +from student.roles import CoursePocCoachRole +from student.tests.factories import AdminFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +from ..models import PersonalOnlineCourse +from ..overrides import get_override_for_poc + + +def intercept_renderer(path, context): + """ + Intercept calls to `render_to_response` and attach the context dict to the + response for examination in unit tests. + """ + # I think Django already does this for you in their TestClient, except + # we're bypassing that by using edxmako. Probably edxmako should be + # integrated better with Django's rendering and event system. + response = render_to_response(path, context) + response.mako_context = context + response.mako_template = path + return response + + +class TestCoachDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Tests for Personal Online Courses views. + """ + def setUp(self): + """ + Set up tests + """ + self.course = course = CourseFactory.create() + + # Create instructor account + self.coach = coach = AdminFactory.create() + self.client.login(username=coach.username, password="test") + + # Create a course outline + self.mooc_start = start = datetime.datetime( + 2010, 5, 12, 2, 42, tzinfo=pytz.UTC) + self.mooc_due = due = datetime.datetime( + 2010, 7, 7, 0, 0, tzinfo=pytz.UTC) + chapters = [ItemFactory.create(start=start, parent=course) + for _ in xrange(2)] + sequentials = flatten([ + [ItemFactory.create(parent=chapter) for _ in xrange(2)] + for chapter in chapters]) + verticals = flatten([ + [ItemFactory.create(due=due, parent=sequential) for _ in xrange(2)] + for sequential in sequentials]) + blocks = flatten([ + [ItemFactory.create(parent=vertical) for _ in xrange(2)] + for vertical in verticals]) + + def make_coach(self): + role = CoursePocCoachRole(self.course.id) + role.add_users(self.coach) + + def tearDown(self): + """ + Undo patches. + """ + patch.stopall() + + def test_not_a_coach(self): + """ + User is not a coach, should get Forbidden response. + """ + url = reverse( + 'poc_coach_dashboard', + kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_no_poc_created(self): + """ + No POC is created, coach should see form to add a POC. + """ + self.make_coach() + url = reverse( + 'poc_coach_dashboard', + kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertTrue(re.search( + ' + + diff --git a/lms/templates/pocs/enroll_email_allowedmessage.txt b/lms/templates/pocs/enroll_email_allowedmessage.txt new file mode 100644 index 0000000000..4315b038d0 --- /dev/null +++ b/lms/templates/pocs/enroll_email_allowedmessage.txt @@ -0,0 +1,43 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("Dear student,")} + +${_("You have been invited to join {course_name} at {site_name} by a " + "member of the course staff.").format( + course_name=course.display_name, + site_name=site_name + )} +% if is_shib_course: +% if auto_enroll: + +${_("To access the course visit {course_url} and login.").format(course_url=course_url)} +% elif course_about_url is not None: + +${_("To access the course visit {course_about_url} and register for the course.").format( + course_about_url=course_about_url)} +% endif +% else: + +${_("To finish your registration, please visit {registration_url} and fill " + "out the registration form making sure to use {email_address} in the E-mail field.").format( + registration_url=registration_url, + email_address=email_address + )} +% if auto_enroll: +${_("Once you have registered and activated your account, you will see " + "{course_name} listed on your dashboard.").format( + course_name=course.display_name + )} +% elif course_about_url is not None: +${_("Once you have registered and activated your account, visit {course_about_url} " + "to join the course.").format(course_about_url=course_about_url)} +% else: +${_("You can then enroll in {course_name}.").format(course_name=course.display_name)} +% endif +% endif + +---- +${_("This email was automatically sent from {site_name} to " + "{email_address}").format( + site_name=site_name, email_address=email_address + )} diff --git a/lms/templates/pocs/enroll_email_allowedsubject.txt b/lms/templates/pocs/enroll_email_allowedsubject.txt new file mode 100644 index 0000000000..0a39036140 --- /dev/null +++ b/lms/templates/pocs/enroll_email_allowedsubject.txt @@ -0,0 +1,5 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("You have been invited to register for {course_name}").format( + course_name=course.display_name + )} diff --git a/lms/templates/pocs/enroll_email_enrolledmessage.txt b/lms/templates/pocs/enroll_email_enrolledmessage.txt new file mode 100644 index 0000000000..b24f5a4a36 --- /dev/null +++ b/lms/templates/pocs/enroll_email_enrolledmessage.txt @@ -0,0 +1,20 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("Dear {full_name}").format(full_name=full_name)} + +${_("You have been enrolled in {course_name} at {site_name} by a member " + "of the course staff. The course should now appear on your {site_name} " + "dashboard.").format( + course_name=course.display_name, + site_name=site_name + )} + +${_("To start accessing course materials, please visit {course_url}").format( + course_url=course_url + )} + +---- +${_("This email was automatically sent from {site_name} to " + "{full_name}").format( + site_name=site_name, full_name=full_name + )} diff --git a/lms/templates/pocs/enroll_email_enrolledsubject.txt b/lms/templates/pocs/enroll_email_enrolledsubject.txt new file mode 100644 index 0000000000..dc84c3f0a8 --- /dev/null +++ b/lms/templates/pocs/enroll_email_enrolledsubject.txt @@ -0,0 +1,5 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("You have been enrolled in {course_name}").format( + course_name=course.display_name + )} diff --git a/lms/templates/pocs/enrollment.html b/lms/templates/pocs/enrollment.html new file mode 100644 index 0000000000..01071f7116 --- /dev/null +++ b/lms/templates/pocs/enrollment.html @@ -0,0 +1,78 @@ +<%! from django.utils.translation import ugettext as _ %> + +
+
+ +

${_("Batch Enrollment")}

+

+ + +

+ +
+ + +
+ +

+ ${_("If this option is checked, users who have not yet registered for {platform_name} will be automatically enrolled.").format(platform_name=settings.PLATFORM_NAME)} + ${_("If this option is left unchecked, 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)} +

+ ${_("Checking this box has no effect if 'Unenroll' is selected.")} +

+
+
+ +
+ + + +
+ +
+ + +
+
+
+
+
+ +
+
+
+
+

${_("Student List Management")}

+ + + + + + + + + + %for member in poc_members: + + + + + + %endfor + +
UsernameEmailRevoke access
${member.student}${member.student.email}
Revoke access
+
+
+ + +
+
+
+
diff --git a/lms/templates/pocs/schedule.html b/lms/templates/pocs/schedule.html new file mode 100644 index 0000000000..330070f085 --- /dev/null +++ b/lms/templates/pocs/schedule.html @@ -0,0 +1,504 @@ +<%! from django.utils.translation import ugettext as _ %> + + + +
+
+
+ + + +
+
+

${_('Save changes')}

+
+

${_("You have unsaved changes.")}

+
+
+ +
+
+
+
+

${_('Error')}

+

${_("There was an error saving changes.")}

+
+
+

${_('Schedule a Unit')}

+
+
+ ${_('Chapter')}
+ +
+
+ ${_('Sequential')}
+ +
+
+ ${_('Vertical')}
+ +
+
+ ${_('Start Date')}
+ + +
+
+ ${_('Due Date')} ${_('(Optional)')}
+ + +
+
+
+ +
+
+
+ +
+
+
+ ${_("All units have been added.")} +
+
+
+ + diff --git a/lms/templates/pocs/unenroll_email_allowedmessage.txt b/lms/templates/pocs/unenroll_email_allowedmessage.txt new file mode 100644 index 0000000000..6eca90b8d5 --- /dev/null +++ b/lms/templates/pocs/unenroll_email_allowedmessage.txt @@ -0,0 +1,13 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("Dear Student,")} + +${_("You have been un-enrolled from course {course_name} by a member " + "of the course staff. Please disregard the invitation " + "previously sent.").format(course_name=course.display_name)} + +---- +${_("This email was automatically sent from {site_name} " + "to {email_address}").format( + site_name=site_name, email_address=email_address + )} diff --git a/lms/templates/pocs/unenroll_email_enrolledmessage.txt b/lms/templates/pocs/unenroll_email_enrolledmessage.txt new file mode 100644 index 0000000000..38770886c0 --- /dev/null +++ b/lms/templates/pocs/unenroll_email_enrolledmessage.txt @@ -0,0 +1,17 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("Dear {full_name}").format(full_name=full_name)} + +${_("You have been un-enrolled in {course_name} at {site_name} by a member " + "of the course staff. The course will no longer appear on your " + "{site_name} dashboard.").format( + course_name=course.display_name, site_name=site_name + )} + +${_("Your other courses have not been affected.")} + +---- +${_("This email was automatically sent from {site_name} to " + "{full_name}").format( + full_name=full_name, site_name=site_name + )} diff --git a/lms/templates/pocs/unenroll_email_subject.txt b/lms/templates/pocs/unenroll_email_subject.txt new file mode 100644 index 0000000000..4b971dcb35 --- /dev/null +++ b/lms/templates/pocs/unenroll_email_subject.txt @@ -0,0 +1,5 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("You have been un-enrolled from {course_name}").format( + course_name=course.display_name +)} diff --git a/lms/urls.py b/lms/urls.py index 512cb159ba..cc824c638c 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -343,6 +343,15 @@ if settings.COURSEWARE_ENABLED: # For the instructor url(r'^courses/{}/instructor$'.format(settings.COURSE_ID_PATTERN), 'instructor.views.instructor_dashboard.instructor_dashboard_2', name="instructor_dashboard"), + url(r'^courses/{}/poc_coach$'.format(settings.COURSE_ID_PATTERN), + 'pocs.views.dashboard', name='poc_coach_dashboard'), + url(r'^courses/{}/create_poc$'.format(settings.COURSE_ID_PATTERN), + 'pocs.views.create_poc', name='create_poc'), + url(r'^courses/{}/save_poc$'.format(settings.COURSE_ID_PATTERN), + 'pocs.views.save_poc', name='save_poc'), + url(r'^courses/{}/poc_invite$'.format(settings.COURSE_ID_PATTERN), + 'pocs.views.poc_invite', name='poc_invite'), + url(r'^courses/{}/set_course_mode_price$'.format(settings.COURSE_ID_PATTERN), 'instructor.views.instructor_dashboard.set_course_mode_price', name="set_course_mode_price"), url(r'^courses/{}/instructor/api/'.format(settings.COURSE_ID_PATTERN),