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

${_("Batch Enrollment")}

+

+ + +

+ +
+ + +
+ +

+ ${_("If this option is checked, users who have not yet registered for {platform_name} will be automatically enrolled.").format(platform_name=settings.PLATFORM_NAME)} + ${_("If this option is left unchecked, users who have not yet registered for {platform_name} will not be enrolled, but will be allowed to enroll once they make an account.").format(platform_name=settings.PLATFORM_NAME)} +

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

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

${_("Student List Management")}

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

${_('Save changes')}

+
+

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

+
+
+ +
+
+
+
+

${_('Error')}

+

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

+
+
+

${_('Schedule a Unit')}

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