diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index c1b78748e4f8d2e6873af4e278d15cf3818ca0ec..c94789641ffbbaa2c4f22e576d055d39398d3639 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 d4b986d3b65131bdc4c522b9066e874426f33b78..66285df67d961b0a0275729577afe6241d31877c 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 923827e84d58826f72d16f7a064eab9aeefbcefb..9e258988cb7ce899ec1c9e7b3530299203199f53 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 168814256ea94bcec4547875113cab8ed6ad3ded..d39fca0e1a8f24ff59f9900e3174f9906ee39e67 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 1610a796c14d05da75a4299b96fa614a810a2cad..29d0a5a1d77e457e53269b6e8c757af5f6358d1e 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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/pocs/migrations/0001_initial.py b/lms/djangoapps/pocs/migrations/0001_initial.py new file mode 100644 index 0000000000000000000000000000000000000000..4572bf899164a946e1ade6abf9fb63254150ba47 --- /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 0000000000000000000000000000000000000000..9e650b7526a4a4defb452a6f48903f9f2e36ac00 --- /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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/pocs/models.py b/lms/djangoapps/pocs/models.py new file mode 100644 index 0000000000000000000000000000000000000000..da06a9fc7bcf88b2a2ab3f77886bbdd0db9c50a1 --- /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 0000000000000000000000000000000000000000..5af4d799c961125a2da815a5fdc8b04424d49970 --- /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 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/pocs/tests/test_overrides.py b/lms/djangoapps/pocs/tests/test_overrides.py new file mode 100644 index 0000000000000000000000000000000000000000..4a8178768f33b01e9bdd544924c77af4a23f962a --- /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 0000000000000000000000000000000000000000..feaa83107fd44ff73998677250070c228a0344a3 --- /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 0000000000000000000000000000000000000000..6890bc18c65f6cc4a8087eef2e7dd6d6b7d6284b --- /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 0000000000000000000000000000000000000000..89c53f4e2f0c157195e555bed4e21380fe2a6c32 --- /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 080e40baed2300a6a8670ad663945f0e1e12fde4..36271ee723d2ab019b3e9d8d5db30e01758b81d1 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 42044969889352c878bca6a80557b631fa446b13..35d1c5285cac06b3acef17a087c668286f5bcc5a 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 1151a55aa1ba6b9ad0b09b910a5f0fc4c1def399..d401e5786f8971a6324f7fb4b978224f4b1576cb 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 0000000000000000000000000000000000000000..e6bebb5e7a78c2cc6cc1c4544e013ddf9d020f81 --- /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 d7ef87af5d2e21bde8dc53961f43d9d9079bd67e..489211d9eeca0aea6d93f9041a69e13188446543 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 0000000000000000000000000000000000000000..3ad742619d00fe532db290745fea5c249650a9ad --- /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 0000000000000000000000000000000000000000..4315b038d0f964bfe1aafe65abbbf13892b69927 --- /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 0000000000000000000000000000000000000000..0a390361404bba6df2a191cae0a2aed875ff4631 --- /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 0000000000000000000000000000000000000000..b24f5a4a36ec1d67750e15a1f2a3a48cd6e87e3f --- /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 0000000000000000000000000000000000000000..dc84c3f0a8b5d1b2fedae6709cf37178c2ca68d0 --- /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 0000000000000000000000000000000000000000..01071f7116d44fb06f88f552584dcc11ab5bab8e --- /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 0000000000000000000000000000000000000000..330070f085757d58c5e442af3fffdb3ed64e6959 --- /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 0000000000000000000000000000000000000000..6eca90b8d5d4998a2c2b2ddd1c65b73b2d11d8a2 --- /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 0000000000000000000000000000000000000000..38770886c08b994a5e8fce5126b651e691ab5c9c --- /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 0000000000000000000000000000000000000000..4b971dcb35458bdecc5629d380bf8a27404c7ce0 --- /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 512cb159ba1fdc1a02fc6f232e32f2001731813b..cc824c638cbb3c9b0a14e430da764304f10a97da 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),