diff --git a/lms/djangoapps/ccx/api/v0/tests/test_views.py b/lms/djangoapps/ccx/api/v0/tests/test_views.py index a30643365655e647bbeb6eb9070f6dd1986ef209..3a5e683209435a3e37f2c509be60530aa3147a9f 100644 --- a/lms/djangoapps/ccx/api/v0/tests/test_views.py +++ b/lms/djangoapps/ccx/api/v0/tests/test_views.py @@ -647,9 +647,14 @@ class CcxListTest(CcxRestApiTest): list_staff_ccx_course = list_with_level(course_ccx, 'staff') list_instructor_ccx_course = list_with_level(course_ccx, 'instructor') - self.assertEqual(len(list_staff_master_course), len(list_staff_ccx_course)) - for course_user, ccx_user in izip(sorted(list_staff_master_course), sorted(list_staff_ccx_course)): - self.assertEqual(course_user, ccx_user) + # The "Coach" in the parent course becomes "Staff" on the CCX, so the CCX should have 1 "Staff" + # user more than the parent course + self.assertEqual(len(list_staff_master_course) + 1, len(list_staff_ccx_course)) + # Make sure all of the existing course staff are passed to the CCX + for course_user in list_staff_master_course: + self.assertIn(course_user, list_staff_ccx_course) + # Make sure the "Coach" on the parent course is "Staff" on the CCX + self.assertIn(self.coach, list_staff_ccx_course) self.assertEqual(len(list_instructor_master_course), len(list_instructor_ccx_course)) for course_user, ccx_user in izip(sorted(list_instructor_master_course), sorted(list_instructor_ccx_course)): self.assertEqual(course_user, ccx_user) diff --git a/lms/djangoapps/ccx/api/v0/views.py b/lms/djangoapps/ccx/api/v0/views.py index 85255191542a0a0358b7be2c70eb9fbe2c97b08b..74523bce71eba59bc83640e44b439f7ed0f302c5 100644 --- a/lms/djangoapps/ccx/api/v0/views.py +++ b/lms/djangoapps/ccx/api/v0/views.py @@ -766,8 +766,8 @@ class CCXDetailView(GenericAPIView): email_students=True, email_params=email_params, ) - # enroll the coach to the newly created ccx - assign_coach_role_to_ccx(ccx_course_key, coach, master_course_object.id) + # make the new coach staff on the CCX + assign_staff_role_to_ccx(ccx_course_key, coach, master_course_object.id) # using CCX object as sender here. responses = SignalHandler.course_published.send( diff --git a/lms/djangoapps/ccx/migrations/0004_change_ccx_coach_to_staff.py b/lms/djangoapps/ccx/migrations/0004_change_ccx_coach_to_staff.py deleted file mode 100644 index 43754c88ba8470b97c8352ad2731b139c16510b7..0000000000000000000000000000000000000000 --- a/lms/djangoapps/ccx/migrations/0004_change_ccx_coach_to_staff.py +++ /dev/null @@ -1,67 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models - -# We're doing something awful here, but it's necessary for the greater good: -from django.contrib.auth.models import User - -from instructor.access import allow_access, revoke_access - -from ccx_keys.locator import CCXLocator -from lms.djangoapps.ccx.utils import ccx_course - -def change_existing_ccx_coaches_to_staff(apps, schema_editor): - """ - Modify all coaches of CCX courses so that they have the staff role on the - CCX course they coach, but retain the CCX Coach role on the parent course. - - Arguments: - apps (Applications): Apps in edX platform. - schema_editor (SchemaEditor): For editing database schema (unused) - - """ - CustomCourseForEdX = apps.get_model('ccx', 'CustomCourseForEdX') - db_alias = schema_editor.connection.alias - list_ccx = CustomCourseForEdX.objects.using(db_alias).all() - for ccx in list_ccx: - ccx_locator = CCXLocator.from_course_locator(ccx.course_id, unicode(ccx.id)) - with ccx_course(ccx_locator) as course: - coach = User.objects.get(id=ccx.coach.id) - allow_access(course, coach, 'staff', send_email=False) - revoke_access(course, coach, 'ccx_coach', send_email=False) - -def revert_ccx_staff_to_coaches(apps, schema_editor): - """ - Modify all staff on CCX courses so that they no longer have the staff role - on the course that they coach. - - Arguments: - apps (Applications): Apps in edX platform. - schema_editor (SchemaEditor): For editing database schema (unused) - - """ - CustomCourseForEdX = apps.get_model('ccx', 'CustomCourseForEdX') - db_alias = schema_editor.connection.alias - list_ccx = CustomCourseForEdX.objects.using(db_alias).all() - for ccx in list_ccx: - ccx_locator = CCXLocator.from_course_locator(ccx.course_id, unicode(ccx.id)) - with ccx_course(ccx_locator) as course: - coach = User.objects.get(id=ccx.coach.id) - allow_access(course, coach, 'ccx_coach', send_email=False) - revoke_access(course, coach, 'staff', send_email=False) - -class Migration(migrations.Migration): - - dependencies = [ - ('ccx', '0001_initial'), - ('ccx', '0002_customcourseforedx_structure_json'), - ('ccx', '0003_add_master_course_staff_in_ccx'), - ] - - operations = [ - migrations.RunPython( - code=change_existing_ccx_coaches_to_staff, - reverse_code=revert_ccx_staff_to_coaches - ) - ] diff --git a/lms/djangoapps/ccx/migrations/0005_change_ccx_coach_to_staff.py b/lms/djangoapps/ccx/migrations/0005_change_ccx_coach_to_staff.py new file mode 100644 index 0000000000000000000000000000000000000000..711d493f945ce4b999be9fb9d04153817fa19fc6 --- /dev/null +++ b/lms/djangoapps/ccx/migrations/0005_change_ccx_coach_to_staff.py @@ -0,0 +1,83 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals +import logging + +from django.contrib.auth.models import User +from django.db import migrations + +from ccx_keys.locator import CCXLocator +from instructor.access import allow_access, revoke_access +from lms.djangoapps.ccx.utils import ccx_course + + +log = logging.getLogger("edx.ccx") + +def change_existing_ccx_coaches_to_staff(apps, schema_editor): + """ + Modify all coaches of CCX courses so that they have the staff role on the + CCX course they coach, but retain the CCX Coach role on the parent course. + + Arguments: + apps (Applications): Apps in edX platform. + schema_editor (SchemaEditor): For editing database schema (unused) + + """ + CustomCourseForEdX = apps.get_model('ccx', 'CustomCourseForEdX') + db_alias = schema_editor.connection.alias + if not db_alias == 'default': + # This migration is not intended to run against the student_module_history database and + # will fail if it does. Ensure that it'll only run against the default database. + return + list_ccx = CustomCourseForEdX.objects.using(db_alias).all() + for ccx in list_ccx: + ccx_locator = CCXLocator.from_course_locator(ccx.course_id, unicode(ccx.id)) + with ccx_course(ccx_locator) as course: + coach = User.objects.get(id=ccx.coach.id) + allow_access(course, coach, 'staff', send_email=False) + revoke_access(course, coach, 'ccx_coach', send_email=False) + log.info( + 'The CCX coach of CCX %s has been switched from "CCX Coach" to "Staff".', + unicode(ccx_locator) + ) + +def revert_ccx_staff_to_coaches(apps, schema_editor): + """ + Modify all staff on CCX courses so that they no longer have the staff role + on the course that they coach. + + Arguments: + apps (Applications): Apps in edX platform. + schema_editor (SchemaEditor): For editing database schema (unused) + + """ + CustomCourseForEdX = apps.get_model('ccx', 'CustomCourseForEdX') + db_alias = schema_editor.connection.alias + if not db_alias == 'default': + return + list_ccx = CustomCourseForEdX.objects.using(db_alias).all() + for ccx in list_ccx: + ccx_locator = CCXLocator.from_course_locator(ccx.course_id, unicode(ccx.id)) + with ccx_course(ccx_locator) as course: + coach = User.objects.get(id=ccx.coach.id) + allow_access(course, coach, 'ccx_coach', send_email=False) + revoke_access(course, coach, 'staff', send_email=False) + log.info( + 'The CCX coach of CCX %s has been switched from "Staff" to "CCX Coach".', + unicode(ccx_locator) + ) + +class Migration(migrations.Migration): + + dependencies = [ + ('ccx', '0001_initial'), + ('ccx', '0002_customcourseforedx_structure_json'), + ('ccx', '0003_add_master_course_staff_in_ccx'), + ('ccx', '0004_seed_forum_roles_in_ccx_courses'), + ] + + operations = [ + migrations.RunPython( + code=change_existing_ccx_coaches_to_staff, + reverse_code=revert_ccx_staff_to_coaches + ) + ] diff --git a/lms/djangoapps/ccx/models.py b/lms/djangoapps/ccx/models.py index 5c6e8dd103264893a7dd72c7d6eeec0815c13805..00c660d7925cbd8e5d2894670c62017af90ffd6e 100644 --- a/lms/djangoapps/ccx/models.py +++ b/lms/djangoapps/ccx/models.py @@ -1,6 +1,7 @@ """ Models for the custom course feature """ +from __future__ import unicode_literals import json import logging from datetime import datetime @@ -8,8 +9,9 @@ from datetime import datetime from django.contrib.auth.models import User from django.db import models from pytz import utc - from lazy import lazy + +from ccx_keys.locator import CCXLocator from openedx.core.lib.time_zone_utils import get_time_zone_abbr from xmodule_django.models import CourseKeyField, LocationKeyField from xmodule.error_module import ErrorDescriptor @@ -121,6 +123,16 @@ class CustomCourseForEdX(models.Model): return json.loads(self.structure_json) return None + @property + def locator(self): + """ + Helper property that gets a corresponding CCXLocator for this CCX. + + Returns: + The CCXLocator corresponding to this CCX. + """ + return CCXLocator.from_course_locator(self.course_id, unicode(self.id)) + class CcxFieldOverride(models.Model): """ diff --git a/lms/djangoapps/ccx/tests/test_models.py b/lms/djangoapps/ccx/tests/test_models.py index c756b61de00b0424dcf2a011e92460b5dc81ebfe..f4091e4210b201f95fbb10196542abe77494c41e 100644 --- a/lms/djangoapps/ccx/tests/test_models.py +++ b/lms/djangoapps/ccx/tests/test_models.py @@ -12,7 +12,10 @@ from student.tests.factories import ( AdminFactory, ) from util.tests.test_date_utils import fake_ugettext -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, + TEST_DATA_SPLIT_MODULESTORE +) from xmodule.modulestore.tests.factories import ( CourseFactory, check_mongo_calls @@ -30,6 +33,8 @@ class TestCCX(ModuleStoreTestCase): """Unit tests for the CustomCourseForEdX model """ + MODULESTORE = TEST_DATA_SPLIT_MODULESTORE + def setUp(self): """common setup for all tests""" super(TestCCX, self).setUp() @@ -51,7 +56,7 @@ class TestCCX(ModuleStoreTestCase): def test_ccx_course_caching(self): """verify that caching the propery works to limit queries""" - with check_mongo_calls(1): + with check_mongo_calls(3): # these statements are used entirely to demonstrate the # instance-level caching of these values on CCX objects. The # check_mongo_calls context is the point here. @@ -77,7 +82,7 @@ class TestCCX(ModuleStoreTestCase): """verify that caching the start property works to limit queries""" now = datetime.now(utc) self.set_ccx_override('start', now) - with check_mongo_calls(1): + with check_mongo_calls(3): # these statements are used entirely to demonstrate the # instance-level caching of these values on CCX objects. The # check_mongo_calls context is the point here. @@ -102,7 +107,7 @@ class TestCCX(ModuleStoreTestCase): """verify that caching the due property works to limit queries""" expected = datetime.now(utc) self.set_ccx_override('due', expected) - with check_mongo_calls(1): + with check_mongo_calls(3): # these statements are used entirely to demonstrate the # instance-level caching of these values on CCX objects. The # check_mongo_calls context is the point here. @@ -269,3 +274,10 @@ class TestCCX(ModuleStoreTestCase): ) self.assertEqual(ccx.structure_json, json_struct) # pylint: disable=no-member self.assertEqual(ccx.structure, dummy_struct) # pylint: disable=no-member + + def test_locator_property(self): + """ + Verify that the locator helper property returns a correct CCXLocator + """ + locator = self.ccx.locator # pylint: disable=no-member + self.assertEqual(self.ccx.id, long(locator.ccx)) diff --git a/lms/djangoapps/ccx/tests/test_views.py b/lms/djangoapps/ccx/tests/test_views.py index 16fe73aee7d442e4bb9401a4d87632599edc522e..6b8e7bc2e7215e7f13126ee72c7f313d1d7160d4 100644 --- a/lms/djangoapps/ccx/tests/test_views.py +++ b/lms/djangoapps/ccx/tests/test_views.py @@ -418,8 +418,8 @@ class TestCoachDashboard(CcxTestCase, LoginEnrollmentTestCase): course_enrollments = get_override_for_ccx(ccx, self.course, 'max_student_enrollments_allowed') self.assertEqual(course_enrollments, settings.CCX_MAX_STUDENTS_ALLOWED) - # assert ccx creator has role=ccx_coach - role = CourseCcxCoachRole(course_key) + # assert ccx creator has role=staff + role = CourseStaffRole(course_key) self.assertTrue(role.has_user(self.coach)) # assert that staff and instructors of master course has staff and instructor roles on ccx @@ -432,8 +432,12 @@ class TestCoachDashboard(CcxTestCase, LoginEnrollmentTestCase): with ccx_course(course_key) as course_ccx: list_staff_ccx_course = list_with_level(course_ccx, 'staff') - self.assertEqual(len(list_staff_master_course), len(list_staff_ccx_course)) - self.assertEqual(list_staff_master_course[0].email, list_staff_ccx_course[0].email) + # The "Coach" in the parent course becomes "Staff" on the CCX, so the CCX should have 1 "Staff" + # user more than the parent course + self.assertEqual(len(list_staff_master_course) + 1, len(list_staff_ccx_course)) + self.assertIn(list_staff_master_course[0].email, [ccx_staff.email for ccx_staff in list_staff_ccx_course]) + # Make sure the "Coach" on the parent course is "Staff" on the CCX + self.assertIn(self.coach, list_staff_ccx_course) list_instructor_ccx_course = list_with_level(course_ccx, 'instructor') self.assertEqual(len(list_instructor_ccx_course), len(list_instructor_master_course)) diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 638b34d4ecc7d220ce9f0767221821c39b25e9eb..2018a88574b79c3538d3757e00012d2eb9ea323c 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -199,6 +199,8 @@ class XQueueCertInterface(object): Will change the certificate status to 'generating' or `downloadable` in case of web view certificates. + The course must not be a CCX. + Certificate must be in the 'unavailable', 'error', 'deleted' or 'generating' state. @@ -214,6 +216,18 @@ class XQueueCertInterface(object): Returns the newly created certificate instance """ + if hasattr(course_id, 'ccx'): + LOGGER.warning( + ( + u"Cannot create certificate generation task for user %s " + u"in the course '%s'; " + u"certificates are not allowed for CCX courses." + ), + student.id, + unicode(course_id) + ) + return None + valid_statuses = [ status.generating, status.unavailable, diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index 0cce7263555cbeed361019f036e92cc413a9cffd..261e19456cf70b1d464b5c5698d4591bd6e04c84 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -77,6 +77,20 @@ class InstructorDashboardTab(CourseTab): return bool(user and has_access(user, 'staff', course, course.id)) +def show_analytics_dashboard_message(course_key): + """ + Defines whether or not the analytics dashboard URL should be displayed. + + Arguments: + course_key (CourseLocator): The course locator to display the analytics dashboard message on. + """ + if hasattr(course_key, 'ccx'): + ccx_analytics_enabled = settings.FEATURES.get('ENABLE_CCX_ANALYTICS_DASHBOARD_URL', False) + return settings.ANALYTICS_DASHBOARD_URL and ccx_analytics_enabled + + return settings.ANALYTICS_DASHBOARD_URL + + @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) def instructor_dashboard_2(request, course_id): @@ -112,7 +126,7 @@ def instructor_dashboard_2(request, course_id): ] analytics_dashboard_message = None - if settings.ANALYTICS_DASHBOARD_URL: + if show_analytics_dashboard_message(course_key): # Construct a URL to the external analytics dashboard analytics_dashboard_url = '{0}/courses/{1}'.format(settings.ANALYTICS_DASHBOARD_URL, unicode(course_key)) link_start = HTML("<a href=\"{}\" target=\"_blank\">").format(analytics_dashboard_url) @@ -169,7 +183,8 @@ def instructor_dashboard_2(request, course_id): # Certificates panel # This is used to generate example certificates # and enable self-generated certificates for a course. - certs_enabled = CertificateGenerationConfiguration.current().enabled + # Note: This is hidden for all CCXs + certs_enabled = CertificateGenerationConfiguration.current().enabled and not hasattr(course_key, 'ccx') if certs_enabled and access['admin']: sections.append(_section_certificates(course)) @@ -421,7 +436,7 @@ def _section_course_info(course, access): if settings.FEATURES.get('DISPLAY_ANALYTICS_ENROLLMENTS'): section_data['enrollment_count'] = CourseEnrollment.objects.enrollment_counts(course_key) - if settings.ANALYTICS_DASHBOARD_URL: + if show_analytics_dashboard_message(course_key): # dashboard_link is already made safe in _get_dashboard_link dashboard_link = _get_dashboard_link(course_key) # so we can use Text() here so it's not double-escaped and rendering HTML on the front-end diff --git a/lms/envs/common.py b/lms/envs/common.py index 47adc782f98420a5e2a7845135f101464dbd5f89..23dde90842636d087ced2b8ed7fb94a86e2867ac 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -356,6 +356,11 @@ FEATURES = { # lives in the Extended table, saving the frontend from # making multiple queries. 'ENABLE_READING_FROM_MULTIPLE_HISTORY_TABLES': True, + + # Display the 'Analytics' tab in the instructor dashboard for CCX courses. + # Note: This has no effect unless ANALYTICS_DASHBOARD_URL is already set, + # because without that setting, the tab does not show up for any courses. + 'ENABLE_CCX_ANALYTICS_DASHBOARD_URL': False, } # Ignore static asset files on import which match this pattern