From a2cb7fd276c9200e1b2b1753cf9a51cffd7fece8 Mon Sep 17 00:00:00 2001 From: Chris Rossi <chris@archimedeanco.com> 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 c1b78748e4f..c94789641ff 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 d4b986d3b65..66285df67d9 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 923827e84d5..9e258988cb7 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 168814256ea..d39fca0e1a8 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 1610a796c14..29d0a5a1d77 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 00000000000..e69de29bb2d diff --git a/lms/djangoapps/pocs/migrations/0001_initial.py b/lms/djangoapps/pocs/migrations/0001_initial.py new file mode 100644 index 00000000000..4572bf89916 --- /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 00000000000..9e650b7526a --- /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 00000000000..e69de29bb2d diff --git a/lms/djangoapps/pocs/models.py b/lms/djangoapps/pocs/models.py new file mode 100644 index 00000000000..da06a9fc7bc --- /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 00000000000..5af4d799c96 --- /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 00000000000..e69de29bb2d diff --git a/lms/djangoapps/pocs/tests/test_overrides.py b/lms/djangoapps/pocs/tests/test_overrides.py new file mode 100644 index 00000000000..4a8178768f3 --- /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 00000000000..feaa83107fd --- /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( + '<form action=".+create_poc"', + response.content)) + + def test_create_poc(self): + """ + Create POC. Follow redirect to coach dashboard, confirm we see + the coach dashboard for the new POC. + """ + self.make_coach() + url = reverse( + 'create_poc', + kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.post(url, {'name': 'New POC'}) + self.assertEqual(response.status_code, 302) + url = response.get('location') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertTrue(re.search('id="poc-schedule"', response.content)) + + @patch('pocs.views.render_to_response', intercept_renderer) + @patch('pocs.views.today') + def test_edit_schedule(self, today): + """ + Get POC schedule, modify it, save it. + """ + today.return_value = datetime.datetime(2014, 11, 25, tzinfo=pytz.UTC) + self.test_create_poc() + url = reverse( + 'poc_coach_dashboard', + kwargs={'course_id': self.course.id.to_deprecated_string()}) + response = self.client.get(url) + schedule = json.loads(response.mako_context['schedule']) + self.assertEqual(len(schedule), 2) + self.assertEqual(schedule[0]['hidden'], True) + self.assertEqual(schedule[0]['start'], None) + self.assertEqual(schedule[0]['children'][0]['start'], None) + self.assertEqual(schedule[0]['due'], None) + self.assertEqual(schedule[0]['children'][0]['due'], None) + self.assertEqual( + schedule[0]['children'][0]['children'][0]['due'], None + ) + + url = reverse( + 'save_poc', + kwargs={'course_id': self.course.id.to_deprecated_string()}) + + schedule[0]['hidden'] = False + schedule[0]['start'] = u'2014-11-20 00:00' + schedule[0]['children'][0]['due'] = u'2014-12-25 00:00' # what a jerk! + response = self.client.post( + url, json.dumps(schedule), content_type='application/json' + ) + + schedule = json.loads(response.content) + self.assertEqual(schedule[0]['hidden'], False) + self.assertEqual(schedule[0]['start'], u'2014-11-20 00:00') + self.assertEqual( + schedule[0]['children'][0]['due'], u'2014-12-25 00:00' + ) + + # Make sure start date set on course, follows start date of earliest + # scheduled chapter + poc = PersonalOnlineCourse.objects.get() + course_start = get_override_for_poc(poc, self.course, 'start') + self.assertEqual(str(course_start)[:-9], u'2014-11-20 00:00') + + +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] diff --git a/lms/djangoapps/pocs/utils.py b/lms/djangoapps/pocs/utils.py new file mode 100644 index 00000000000..6890bc18c65 --- /dev/null +++ b/lms/djangoapps/pocs/utils.py @@ -0,0 +1,213 @@ +""" +POC Enrollment operations for use by Coach APIs. + +Does not include any access control, be sure to check access before calling. +""" + +import json +from django.contrib.auth.models import User +from django.conf import settings +from django.core.urlresolvers import reverse +from django.core.mail import send_mail + +from edxmako.shortcuts import render_to_string + +from microsite_configuration import microsite + +from pocs.models import ( + PersonalOnlineCourse, + PocMembership, + PocFutureMembership, +) + + +class EmailEnrollmentState(object): + """ Store the complete enrollment state of an email in a class """ + def __init__(self, poc, email): + exists_user = User.objects.filter(email=email).exists() + if exists_user: + user = User.objects.get(email=email) + poc_member = PocMembership.objects.filter(poc=poc, student=user) + in_poc = poc_member.exists() + full_name = user.profile.name + else: + in_poc = False + full_name = None + self.user = exists_user + self.member = user + self.full_name = full_name + self.in_poc = in_poc + + def __repr__(self): + return "{}(user={}, member={}, in_poc={}".format( + self.__class__.__name__, + self.user, + self.member, + self.in_poc, + ) + + def to_dict(self): + return { + 'user': self.user, + 'member': self.member, + 'in_poc': self.in_poc, + } + + +def enroll_email(poc, student_email, auto_enroll=False, email_students=False, email_params=None): + if email_params is None: + email_params = get_email_params(poc, True) + previous_state = EmailEnrollmentState(poc, student_email) + + if previous_state.user: + if not previous_state.in_poc: + user = User.objects.get(email=student_email) + membership = PocMembership(poc=poc, student=user) + membership.save() + if email_students: + email_params['message'] = 'enrolled_enroll' + email_params['email_address'] = student_email + email_params['full_name'] = previous_state.full_name + send_mail_to_student(student_email, email_params) + else: + membership = PocFutureMembership(poc=poc, email=student_email) + membership.save() + if email_students: + email_params['message'] = 'allowed_enroll' + email_params['email_address'] = student_email + send_mail_to_student(student_email, email_params) + + after_state = EmailEnrollmentState(poc, student_email) + + return previous_state, after_state + + +def unenroll_email(poc, student_email, email_students=False, email_params=None): + if email_params is None: + email_params = get_email_params(poc, True) + previous_state = EmailEnrollmentState(poc, student_email) + + if previous_state.in_poc: + PocMembership.objects.get( + poc=poc, student=previous_state.member + ).delete() + if email_students: + email_params['message'] = 'enrolled_unenroll' + email_params['email_address'] = student_email + email_params['full_name'] = previous_state.full_name + send_mail_to_student(student_email, email_params) + else: + if PocFutureMembership.objects.filter( + poc=poc, email=student_email + ).exists(): + PocFutureMembership.get(poc=poc, email=student_email).delete() + if email_students: + email_params['message'] = 'allowed_unenroll' + email_params['email_address'] = student_email + send_mail_to_student(student_email, email_params) + + after_state = EmailEnrollmentState(poc, student_email) + + return previous_state, after_state + + +def get_email_params(poc, auto_enroll, secure=True): + protocol = 'https' if secure else 'http' + course_id = poc.course_id + + stripped_site_name = microsite.get_value( + 'SITE_NAME', + settings.SITE_NAME + ) + registration_url = u'{proto}://{site}{path}'.format( + proto=protocol, + site=stripped_site_name, + path=reverse('student.views.register_user') + ) + course_url = u'{proto}://{site}{path}'.format( + proto=protocol, + site=stripped_site_name, + path=reverse( + 'course_root', + kwargs={'course_id': course_id.to_deprecated_string()} + ) + ) + + course_about_url = None + if not settings.FEATURES.get('ENABLE_MKTG_SITE', False): + course_about_url = u'{proto}://{site}{path}'.format( + proto=protocol, + site=stripped_site_name, + path=reverse( + 'about_course', + kwargs={'course_id': course_id.to_deprecated_string()} + ) + ) + + email_params = { + 'site_name': stripped_site_name, + 'registration_url': registration_url, + 'course': poc, + 'auto_enroll': auto_enroll, + 'course_url': course_url, + 'course_about_url': course_about_url, + } + return email_params + + +def send_mail_to_student(student, param_dict): + if 'course' in param_dict: + param_dict['course_name'] = param_dict['course'].display_name + + param_dict['site_name'] = microsite.get_value( + 'SITE_NAME', + param_dict['site_name'] + ) + + subject = None + message = None + + message_type = param_dict['message'] + + email_template_dict = { + 'allowed_enroll': ( + 'pocs/enroll_email_allowedsubject.txt', + 'pocs/enroll_email_allowedmessage.txt' + ), + 'enrolled_enroll': ( + 'pocs/enroll_email_enrolledsubject.txt', + 'pocs/enroll_email_enrolledmessage.txt' + ), + 'allowed_unenroll': ( + 'pocs/unenroll_email_subject.txt', + 'pocs/unenroll_email_allowedmessage.txt' + ), + 'enrolled_unenroll': ( + 'pocs/unenroll_email_subject.txt', + 'pocs/unenroll_email_enrolledmessage.txt' + ), + } + + subject_template, message_template = email_template_dict.get( + message_type, (None, None) + ) + if subject_template is not None and message_template is not None: + subject = render_to_string(subject_template, param_dict) + message = render_to_string(message_template, param_dict) + + if subject and message: + message = message.strip() + + subject = ''.join(subject.splitlines()) + from_address = microsite.get_value( + 'email_from_address', + settings.DEFAULT_FROM_EMAIL + ) + + send_mail( + subject, + message, + from_address, + [student], + fail_silently=False + ) diff --git a/lms/djangoapps/pocs/views.py b/lms/djangoapps/pocs/views.py new file mode 100644 index 00000000000..89c53f4e2f0 --- /dev/null +++ b/lms/djangoapps/pocs/views.py @@ -0,0 +1,244 @@ +import datetime +import functools +import json +import logging +import pytz + +from django.core.urlresolvers import reverse +from django.http import HttpResponse, HttpResponseForbidden +from django.core.exceptions import ValidationError +from django.core.validators import validate_email +from django.shortcuts import redirect +from django.utils.translation import ugettext as _ +from django.views.decorators.cache import cache_control +from django_future.csrf import ensure_csrf_cookie +from django.contrib.auth.models import User + +from courseware.courses import get_course_by_id +from courseware.field_overrides import disable_overrides +from edxmako.shortcuts import render_to_response +from opaque_keys.edx.locations import SlashSeparatedCourseKey +from student.roles import CoursePocCoachRole + +from instructor.views.api import _split_input_list +from instructor.views.tools import get_student_from_identifier + +from .models import PersonalOnlineCourse, PocMembership +from .overrides import ( + clear_override_for_poc, + get_override_for_poc, + override_field_for_poc, +) +from .utils import enroll_email, unenroll_email + +log = logging.getLogger(__name__) +today = datetime.datetime.today # for patching in tests + + +def coach_dashboard(view): + """ + View decorator which enforces that the user have the POC coach role on the + given course and goes ahead and translates the course_id from the Django + route into a course object. + """ + @functools.wraps(view) + def wrapper(request, course_id): + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) + role = CoursePocCoachRole(course_key) + if not role.has_user(request.user): + return HttpResponseForbidden( + _('You must be a POC Coach to access this view.')) + course = get_course_by_id(course_key, depth=None) + return view(request, course) + return wrapper + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@coach_dashboard +def dashboard(request, course): + """ + Display the POC Coach Dashboard. + """ + poc = get_poc_for_coach(course, request.user) + schedule = get_poc_schedule(course, poc) + context = { + 'course': course, + 'poc': poc, + 'schedule': json.dumps(schedule, indent=4), + 'save_url': reverse('save_poc', kwargs={'course_id': course.id}), + 'poc_members': PocMembership.objects.filter(poc=poc), + } + if not poc: + context['create_poc_url'] = reverse( + 'create_poc', kwargs={'course_id': course.id}) + return render_to_response('pocs/coach_dashboard.html', context) + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@coach_dashboard +def create_poc(request, course): + """ + Create a new POC + """ + name = request.POST.get('name') + poc = PersonalOnlineCourse( + course_id=course.id, + coach=request.user, + display_name=name) + poc.save() + + # Make sure start/due are overridden for entire course + start = today().replace(tzinfo=pytz.UTC) + override_field_for_poc(poc, course, 'start', start) + override_field_for_poc(poc, course, 'due', None) + + # Hide anything that can show up in the schedule + hidden = 'visible_to_staff_only' + for chapter in course.get_children(): + override_field_for_poc(poc, chapter, hidden, True) + for sequential in chapter.get_children(): + override_field_for_poc(poc, sequential, hidden, True) + for vertical in sequential.get_children(): + override_field_for_poc(poc, vertical, hidden, True) + + url = reverse('poc_coach_dashboard', kwargs={'course_id': course.id}) + return redirect(url) + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@coach_dashboard +def save_poc(request, course): + """ + Save changes to POC + """ + poc = get_poc_for_coach(course, request.user) + + def override_fields(parent, data, earliest=None): + blocks = { + str(child.location): child + for child in parent.get_children()} + for unit in data: + block = blocks[unit['location']] + override_field_for_poc( + poc, block, 'visible_to_staff_only', unit['hidden']) + start = parse_date(unit['start']) + if start: + if not earliest or start < earliest: + earliest = start + override_field_for_poc(poc, block, 'start', start) + else: + clear_override_for_poc(poc, block, 'start') + due = parse_date(unit['due']) + if due: + override_field_for_poc(poc, block, 'due', due) + else: + clear_override_for_poc(poc, block, 'due') + + children = unit.get('children', None) + if children: + override_fields(block, children, earliest) + return earliest + + earliest = override_fields(course, json.loads(request.body)) + if earliest: + override_field_for_poc(poc, course, 'start', earliest) + + return HttpResponse( + json.dumps(get_poc_schedule(course, poc)), + content_type='application/json') + + +def parse_date(s): + if s: + try: + date, time = s.split(' ') + year, month, day = map(int, date.split('-')) + hour, minute = map(int, time.split(':')) + return datetime.datetime( + year, month, day, hour, minute, tzinfo=pytz.UTC) + except: + log.warn("Unable to parse date: " + s) + + return None + + +def get_poc_for_coach(course, coach): + """ + Looks to see if user is coach of a POC for this course. Returns the POC or + None. + """ + try: + return PersonalOnlineCourse.objects.get( + course_id=course.id, + coach=coach) + except PersonalOnlineCourse.DoesNotExist: + return None + + +def get_poc_schedule(course, poc): + """ + """ + def visit(node, depth=1): + for child in node.get_children(): + start = get_override_for_poc(poc, child, 'start', None) + if start: + start = str(start)[:-9] + due = get_override_for_poc(poc, child, 'due', None) + if due: + due = str(due)[:-9] + hidden = get_override_for_poc( + poc, child, 'visible_to_staff_only', + child.visible_to_staff_only) + visited = { + 'location': str(child.location), + 'display_name': child.display_name, + 'category': child.category, + 'start': start, + 'due': due, + 'hidden': hidden, + } + if depth < 3: + children = tuple(visit(child, depth + 1)) + if children: + visited['children'] = children + yield visited + else: + yield visited + + with disable_overrides(): + return tuple(visit(course)) + + +@ensure_csrf_cookie +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@coach_dashboard +def poc_invite(request, course): + """ + Invite users to new poc + """ + poc = get_poc_for_coach(course, request.user) + action = request.POST.get('enrollment-button') + identifiers_raw = request.POST.get('student-ids') + identifiers = _split_input_list(identifiers_raw) + for identifier in identifiers: + user = None + email = None + try: + user = get_student_from_identifier(identifier) + except User.DoesNotExist: + email = identifier + else: + email = user.email + try: + validate_email(email) + if action == 'Enroll': + enroll_email(poc, email, email_students=True) + if action == "Unenroll": + unenroll_email(poc, email, email_students=True) + except ValidationError: + pass # maybe log this? + url = reverse('poc_coach_dashboard', kwargs={'course_id': course.id}) + return redirect(url) diff --git a/lms/envs/common.py b/lms/envs/common.py index 080e40baed2..36271ee723d 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -214,6 +214,9 @@ FEATURES = { # True. 'INDIVIDUAL_DUE_DATES': False, + # Enable Personal Online Courses + 'PERSONAL_ONLINE_COURSES': False, + # Enable legacy instructor dashboard 'ENABLE_INSTRUCTOR_LEGACY_DASHBOARD': True, diff --git a/lms/envs/test.py b/lms/envs/test.py index 42044969889..35d1c5285ca 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -445,7 +445,6 @@ MONGODB_LOG = { 'db': 'xlog', } - # Enable EdxNotes for tests. FEATURES['ENABLE_EDXNOTES'] = True @@ -469,3 +468,6 @@ FACEBOOK_API_VERSION = "v2.2" # Certificates Views FEATURES['CERTIFICATES_HTML_VIEW'] = True + +######### personal online courses ######### +INSTALLED_APPS += ('pocs',) diff --git a/lms/static/sass/course.scss.mako b/lms/static/sass/course.scss.mako index 1151a55aa1b..d401e5786f8 100644 --- a/lms/static/sass/course.scss.mako +++ b/lms/static/sass/course.scss.mako @@ -77,5 +77,8 @@ @import "course/instructor/email"; @import "xmodule/descriptors/css/module-styles.scss"; -// course - discussion +// course - poc_coach +@import "course/poc_coach/dashboard"; + +// discussion @import "course/discussion/form-wmd-toolbar"; diff --git a/lms/static/sass/course/poc_coach/_dashboard.scss b/lms/static/sass/course/poc_coach/_dashboard.scss new file mode 100644 index 00000000000..e6bebb5e7a7 --- /dev/null +++ b/lms/static/sass/course/poc_coach/_dashboard.scss @@ -0,0 +1,57 @@ +.poc-schedule-container { + float: left; + width: 750px; +} + +table.poc-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; + } +} + +.poc-schedule-sidebar { + float: left; + width: 295px; + margin-left: 20px; +} + +.poc-sidebar-panel { + border: 1px solid #cbcbcb; + padding: 15px; + margin-bottom: 20px; +} + +form.poc-form { + line-height: 1.5; + select { + width: 100%; + } + .field { + margin: 5px 0 5px 0; + } +} diff --git a/lms/templates/instructor/instructor_dashboard_2/membership.html b/lms/templates/instructor/instructor_dashboard_2/membership.html index d7ef87af5d2..489211d9eec 100644 --- a/lms/templates/instructor/instructor_dashboard_2/membership.html +++ b/lms/templates/instructor/instructor_dashboard_2/membership.html @@ -243,5 +243,18 @@ data-add-button-label="${_("Add Community TA")}" ></div> %endif - + + %if section_data['access']['instructor'] and settings.FEATURES.get('PERSONAL_ONLINE_COURSES', False): + <div class="auth-list-container" + data-rolename="poc_coach" + data-display-name="${_("POC Coaches")}" + data-info-text=" + ${_("POC Coaches are able to create their own Personal Online 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 POC Coach")}" + ></div> + %endif </div> diff --git a/lms/templates/pocs/coach_dashboard.html b/lms/templates/pocs/coach_dashboard.html new file mode 100644 index 00000000000..3ad742619d0 --- /dev/null +++ b/lms/templates/pocs/coach_dashboard.html @@ -0,0 +1,81 @@ +<%! 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">${_("POC Coach Dashboard")}</%block> +<%block name="nav_skip">#poc-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='poc_coach'" /> + +<section class="container"> + <div class="instructor-dashboard-wrapper-2"> + <section class="instructor-dashboard-content-2" id="poc-coach-dashboard-content"> + <h1>${_("POC Coach Dashboard")}</h1> + + %if not poc: + <section> + <form action="${create_poc_url}" method="POST"> + <input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"/> + <input name="name" placeholder="Name your POC"/><br/> + <button id="create-poc">Coach a new Personal Online Course</button> + </form> + </section> + %endif + + %if poc: + <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> + </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> + %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(); + } + } + + $(setup_tabs); + + +</script> diff --git a/lms/templates/pocs/enroll_email_allowedmessage.txt b/lms/templates/pocs/enroll_email_allowedmessage.txt new file mode 100644 index 00000000000..4315b038d0f --- /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 00000000000..0a390361404 --- /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 00000000000..b24f5a4a36e --- /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 00000000000..dc84c3f0a8b --- /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 00000000000..01071f7116d --- /dev/null +++ b/lms/templates/pocs/enrollment.html @@ -0,0 +1,78 @@ +<%! from django.utils.translation import ugettext as _ %> + +<div class="batch-enrollment" style="float:left;width:50%"> + <form method="POST" action="poc_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%"> + <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 poc_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="add-field" class="add-field" placeholder="Enter username or email" type="text"> + <input name="add" class="add" value="Add Student" type="button"> + </div> + </div> + </div> +</div> diff --git a/lms/templates/pocs/schedule.html b/lms/templates/pocs/schedule.html new file mode 100644 index 00000000000..330070f0857 --- /dev/null +++ b/lms/templates/pocs/schedule.html @@ -0,0 +1,504 @@ +<%! from django.utils.translation import ugettext as _ %> + +<script id="poc-schedule-template" type="text/x-handlerbars-template"> + <table class="poc-schedule"> + <thead> + <tr> + <th>${_('Unit')}</th> + <th>${_('Start Date')}</th> + <th>${_('Due Date')}</th> + <th><a href="#" id="remove-all"> + <i class="icon-remove-sign icon"></i> ${_('remove all')} + </a></th> + </tr> + </thead> + <tbody> + {{#each chapters}} + <tr class="chapter collapsed" data-location="{{location}}" data-depth="1"> + <td class="unit"> + <a href="#"><i class="icon-caret-right icon toggle-collapse"></i></a> + {{display_name}} + </td> + <td class="date start-date"><a>{{start}}</a></td> + <td class="date due-date"><a>{{due}}</a></td> + <td><a href="#" class="remove-unit"> + <i class="icon-remove-sign icon"></i> ${_('remove')} + </a></td> + </tr> + {{#each children}} + <tr class="sequential collapsed" data-depth="2" + data-location="{{../location}} {{location}}"> + <td class="unit"> + <a href="#"><i class="icon-caret-right icon toggle-collapse"></i></a> + {{display_name}} + </td> + <td class="date start-date"><a>{{start}}</a></td> + <td class="date due-date"><a>{{due}}</a></td> + <td><a href="#" class="remove-unit"> + <i class="icon-remove-sign icon"></i> ${_('remove')} + </a></td> + </tr> + {{#each children}} + <tr class="vertical" data-dapth="3" + data-location="{{../../location}} {{../location}} {{location}}"> + <td class="unit"> {{display_name}}</td> + <td class="date start-date"><a>{{start}}</a></td> + <td class="date due-date"><a>{{due}}</a></td> + <td><a href="#" class="remove-unit"> + <i class="icon-remove-sign icon"></i> ${_('remove')} + </a></td> + {{/each}} + {{/each}} + {{/each}} + </tbody> + </table> +</script> + +<div class="poc-schedule-container"> + <div id="poc-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="icon-remove"></i> + <span class="sr"> + ${_("Close")} + </span> + </button> + <header> + <h2></h2> + </header> + <form role="form"> + <div class="field"> + <label></label> + <input type="date" name="date"/> + <input type="time" name="time"/> + </div> + <div class="field"> + <button type="submit" class="btn btn-primary">${_('Set date')}</button> + </div> + </form> + </div> +</section> + +<div class="poc-schedule-sidebar"> + <div class="poc-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="poc-sidebar-panel" id="ajax-error"> + <h2>${_('Error')}</h2> + <p>${_("There was an error saving changes.")}</p> + </div> + <div class="poc-sidebar-panel"> + <h2>${_('Schedule a Unit')}</h2> + <form role="form" id="add-unit" name="add-unit" class="poc-form"> + <div class="field"> + <b>${_('Chapter')}</b><br/> + <select name="chapter"></select> + </div> + <div class="field"> + <b>${_('Sequential')}</b><br/> + <select name="sequential"></select> + </div> + <div class="field"> + <b>${_('Vertical')}</b><br/> + <select name="vertical"></select> + </div> + <div class="field"> + <b>${_('Start Date')}</b><br/> + <input type="date" name="start_date"/> + <input type="time" name="start_time"/> + </div> + <div class="field"> + <b>${_('Due Date')}</b> ${_('(Optional)')}<br/> + <input type="date" name="due_date"/> + <input type="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> +var poc_schedule = (function () { + var save_url = '${save_url}'; + var schedule = ${schedule}; + var template = Handlebars.compile($('#poc-schedule-template').html()); + var chapter_select = $('form#add-unit select[name="chapter"]'), + sequential_select = $('form#add-unit select[name="sequential"]'), + vertical_select = $('form#add-unit select[name="vertical"]'); + + var self = { + schedule: schedule, + dirty: false + }; + + function hide(unit) { + unit.hidden = true; + } + + function show(unit) { + unit.hidden = false; + } + + function get_datetime(which) { + var date = $('form#add-unit input[name=' + which + '_date]').val(); + var time = $('form#add-unit input[name=' + which + '_time]').val(); + if (date && time) + return date + ' ' + time; + return null; + } + + function set_datetime(which, value) { + var parts = value ? value.split(' ') : ['', ''], + date = parts[0], + time = parts[1]; + $('form#add-unit input[name=' + which + '_date]').val(date); + $('form#add-unit input[name=' + which + '_time]').val(time); + } + + /** + * Render the course tree view. + */ + self.render = function() { + this.hidden = pruned(this.schedule, function(node) { + return node.hidden || node.category !== 'vertical'}); + this.showing = pruned(this.schedule, function(node) { + return !node.hidden}); + + // Render template + $('#poc-schedule').html(template({chapters: this.showing})); + + // Start collapsed + $('table.poc-schedule .sequential,.vertical').hide(); + + // Click handlers for collapsible tree + $('table.poc-schedule .toggle-collapse').on('click', toggle_collapse); + + // Hidden hover fields for empty date fields + $('table.poc-schedule .date a').each(function() { + if (! $(this).text()) { + $(this).text('Set date').addClass('empty'); + } + }); + + // Handle date edit clicks + $('table.poc-schedule .date a').attr('href', '#enter-date-modal') + .leanModal({closeButton: '.close-modal'}); + $('table.poc-schedule .due-date a').on('click', enterNewDate('due')); + $('table.poc-schedule .start-date a').on('click', enterNewDate('start')); + + // Click handler for remove all + $('table.poc-schedule a#remove-all').on('click', function(event) { + event.preventDefault(); + schedule_apply(self.schedule, hide); + self.dirty = true; + self.render(); + }); + + // Show or hide form + if (this.hidden.length) { + // Populate chapters select, depopulate others + chapter_select.html('') + .append('<option value="none">${_("Select a chapter")}...</option>') + .append(schedule_options(this.hidden)); + sequential_select.html('').prop('disabled', true); + vertical_select.html('').prop('disabled', true); + $('form#add-unit').show(); + $('#all-units-added').hide(); + $('#add-unit-button').prop('disabled', true); + } + else { + $('form#add-unit').hide(); + $('#all-units-added').show(); + } + + // Add unit handlers + chapter_select.on('change', function(event) { + var chapter_location = chapter_select.val(); + vertical_select.html('').prop('disabled', true); + if (chapter_location !== 'none') { + chapter = find_unit(self.hidden, chapter_location); + sequential_select.html('') + .append('<option value="all">${_("All sequentials")}</option>') + .append(schedule_options(chapter.children)); + sequential_select.prop('disabled', false); + $('#add-unit-button').prop('disabled', false); + set_datetime('start', chapter.start); + set_datetime('due', chapter.due); + } + else { + sequential_select.html('').prop('disabled', true); + } + }); + + sequential_select.on('change', function(event) { + var sequential_location = sequential_select.val(); + if (sequential_location !== 'all') { + var chapter = chapter_select.val(); + sequential = find_unit(self.hidden, chapter, sequential_location); + vertical_select.html('') + .append('<option value="all">${_("All verticals")}</option>') + .append(schedule_options(sequential.children)); + vertical_select.prop('disabled', false); + set_datetime('start', sequential.start); + set_datetime('due', sequential.due); + } + else { + vertical_select.html('').prop('disabled', true); + } + }); + + vertical_select.on('change', function(event) { + var vertical_location = vertical_select.val(); + if (vertical_location !== 'all') { + var chapter = chapter_select.val(), + sequential = sequential_select.val(); + vertical = find_unit( + self.hidden, chapter, sequential, vertical_location); + set_datetime('start', vertical.start); + set_datetime('due', vertical.due); + } + }); + + // Add unit handler + $('#add-unit-button').on('click', function(event) { + event.preventDefault(); + var chapter = chapter_select.val(), + sequential = sequential_select.val(), + vertical = vertical_select.val(), + units = find_lineage(self.schedule, + chapter, + sequential == 'all' ? null : sequential, + vertical == 'all' ? null: vertical), + start = get_datetime('start'), + due = get_datetime('due'); + units.map(show); + unit = units[units.length - 1] + schedule_apply([unit], show); + if (start) unit.start = start; + if (due) unit.due = due; + self.dirty = true; + self.render(); + }); + + // Remove unit handler + $('table.poc-schedule a.remove-unit').on('click', function(event) { + var row = $(this).closest('tr'), + path = row.data('location').split(' '), + unit = find_unit(self.schedule, path[0], path[1], path[2]); + schedule_apply([unit], hide); + self.dirty = true; + self.render(); + }); + + // Show or hide save button + if (this.dirty) $('#dirty-schedule').show() + else $('#dirty-schedule').hide(); + + // Handle save button + $('#dirty-schedule #save-changes').on('click', function(event) { + event.preventDefault(); + self.save(); + }); + + $('#ajax-error').hide(); + } + + /** + * Handle date entry. + */ + function enterNewDate(what) { + return function(event) { + var row = $(this).closest('tr'); + var modal = $('#enter-date-modal') + .data('what', what) + .data('location', row.data('location')); + modal.find('h2').text( + what == 'due' ? '${_("Enter Due Date")}' : + '${_("Enter Start Date")}'); + modal.find('label').text(row.find('td:first').text()); + + var path = row.data('location').split(' '), + unit = find_unit(self.schedule, path[0], path[1], path[2]), + parts = unit[what] ? unit[what].split(' ') : ['', ''], + date = parts[0], + time = parts[1]; + + modal.find('input[name=date]').val(date); + modal.find('input[name=time]').val(time); + + modal.find('form').off('submit').on('submit', function(event) { + event.preventDefault(); + var date = $(this).find('input[name=date]').val(), + time = $(this).find('input[name=time]').val(); + unit[what] = date + ' ' + time; + modal.find('.close-modal').click(); + self.dirty = true; + self.render(); + }); + } + } + + /** + * Returns a sequence of <option> DOM elements for a particular sequence + * of schedule nodes. + */ + function schedule_options(nodes) { + return nodes.map(function(node) { + return $('<option>') + .attr('value', node.location) + .text(node.display_name)[0]; + }); + } + + /** + * Return a schedule pruned by the given filter function. + */ + function pruned(tree, filter) { + return tree.filter(filter) + .map(function(node) { + var copy = {}; + $.extend(copy, node); + if (node.children) copy.children = pruned(node.children, filter); + return copy; + }) + .filter(function(node) { + return node.children === undefined || node.children.length; + }); + } + + /** + * Get table rows that represent the children of given row in the schedule + * tree. + */ + function get_children(row) { + var depth = $(row).data('depth'); + return $(row).nextUntil( + $(row).siblings().filter(function() { + return $(this).data('depth') <= depth; + }) + ); + } + + /** + * Handle click event for expanding/collapsing nodes in the schedule tree. + */ + function toggle_collapse(event) { + event.preventDefault(); + var row = $(this).closest('tr'); + var children = get_children(row); + + if (row.is('.expanded')) { + $(this).removeClass('icon-caret-down').addClass('icon-caret-right'); + row.removeClass('expanded').addClass('collapsed'); + children.hide(); + } + + else { + $(this).removeClass('icon-caret-right').addClass('icon-caret-down'); + row.removeClass('collapsed').addClass('expanded'); + children.filter('.collapsed').each(function() { + children = children.not(get_children(this)); + }); + children.show(); + } + } + + /** + * Save changes. + */ + self.save = function() { + var button = $('#dirty-schedule #save-changes'); + button.prop('disabled', true).text('${_("Saving")}...'); + + $.ajax({ + url: save_url, + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(this.schedule), + success: function(data, textStatus, jqXHR) { + self.schedule = data; + self.dirty = false; + self.render(); + button.prop('disabled', false).text('${_("Save changes")}'); + }, + error: function(jqXHR, textStatus, error) { + console.log(jqXHR.responseText); + $('#ajax-error').show(); + $('#dirty-schedule').hide(); + $('form#add-unit select,input,button').prop('disabled', true); + } + }); + } + + /** + * Add all nodes. + */ + $('#add-all').on('click', function(event) { + event.preventDefault(); + schedule_apply(self.schedule, show); + self.dirty = true; + self.render(); + }); + + /** + * Visit every tree node, applying function. + */ + function schedule_apply(nodes, f) { + nodes.map(function(node) { + f(node); + if (node.children !== undefined) schedule_apply(node.children, f); + }); + } + + /** + * Find a unit in the tree. + */ + function find_unit(tree, chapter, sequential, vertical) { + var units = find_lineage(tree, chapter, sequential, vertical); + return units[units.length -1]; + } + + function find_lineage(tree, chapter, sequential, vertical) { + function find_in(seq, location) { + for (var i = 0; i < seq.length; i++) + if (seq[i].location === location) + return seq[i]; + } + + var units = [], + unit = find_in(tree, chapter); + units[units.length] = unit; + if (sequential) { + units[units.length] = unit = find_in(unit.children, sequential); + if (vertical) + units[units.length] = unit = find_in(unit.children, vertical); + } + + return units; + } + + return self; +})(); + +$(function() { poc_schedule.render(); }); +</script> diff --git a/lms/templates/pocs/unenroll_email_allowedmessage.txt b/lms/templates/pocs/unenroll_email_allowedmessage.txt new file mode 100644 index 00000000000..6eca90b8d5d --- /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 00000000000..38770886c08 --- /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 00000000000..4b971dcb354 --- /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 512cb159ba1..cc824c638cb 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), -- GitLab