Finish implementing CCX coach as staff

parent 8533fd97
...@@ -647,9 +647,14 @@ class CcxListTest(CcxRestApiTest): ...@@ -647,9 +647,14 @@ class CcxListTest(CcxRestApiTest):
list_staff_ccx_course = list_with_level(course_ccx, 'staff') list_staff_ccx_course = list_with_level(course_ccx, 'staff')
list_instructor_ccx_course = list_with_level(course_ccx, 'instructor') list_instructor_ccx_course = list_with_level(course_ccx, 'instructor')
self.assertEqual(len(list_staff_master_course), len(list_staff_ccx_course)) # The "Coach" in the parent course becomes "Staff" on the CCX, so the CCX should have 1 "Staff"
for course_user, ccx_user in izip(sorted(list_staff_master_course), sorted(list_staff_ccx_course)): # user more than the parent course
self.assertEqual(course_user, ccx_user) 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)) 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)): for course_user, ccx_user in izip(sorted(list_instructor_master_course), sorted(list_instructor_ccx_course)):
self.assertEqual(course_user, ccx_user) self.assertEqual(course_user, ccx_user)
......
...@@ -766,8 +766,8 @@ class CCXDetailView(GenericAPIView): ...@@ -766,8 +766,8 @@ class CCXDetailView(GenericAPIView):
email_students=True, email_students=True,
email_params=email_params, email_params=email_params,
) )
# enroll the coach to the newly created ccx # make the new coach staff on the CCX
assign_coach_role_to_ccx(ccx_course_key, coach, master_course_object.id) assign_staff_role_to_ccx(ccx_course_key, coach, master_course_object.id)
# using CCX object as sender here. # using CCX object as sender here.
responses = SignalHandler.course_published.send( responses = SignalHandler.course_published.send(
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals from __future__ import unicode_literals
import logging
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 django.contrib.auth.models import User
from django.db import migrations
from instructor.access import allow_access, revoke_access
from ccx_keys.locator import CCXLocator from ccx_keys.locator import CCXLocator
from instructor.access import allow_access, revoke_access
from lms.djangoapps.ccx.utils import ccx_course from lms.djangoapps.ccx.utils import ccx_course
log = logging.getLogger("edx.ccx")
def change_existing_ccx_coaches_to_staff(apps, schema_editor): 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 Modify all coaches of CCX courses so that they have the staff role on the
...@@ -23,6 +24,10 @@ def change_existing_ccx_coaches_to_staff(apps, schema_editor): ...@@ -23,6 +24,10 @@ def change_existing_ccx_coaches_to_staff(apps, schema_editor):
""" """
CustomCourseForEdX = apps.get_model('ccx', 'CustomCourseForEdX') CustomCourseForEdX = apps.get_model('ccx', 'CustomCourseForEdX')
db_alias = schema_editor.connection.alias 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() list_ccx = CustomCourseForEdX.objects.using(db_alias).all()
for ccx in list_ccx: for ccx in list_ccx:
ccx_locator = CCXLocator.from_course_locator(ccx.course_id, unicode(ccx.id)) ccx_locator = CCXLocator.from_course_locator(ccx.course_id, unicode(ccx.id))
...@@ -30,6 +35,10 @@ def change_existing_ccx_coaches_to_staff(apps, schema_editor): ...@@ -30,6 +35,10 @@ def change_existing_ccx_coaches_to_staff(apps, schema_editor):
coach = User.objects.get(id=ccx.coach.id) coach = User.objects.get(id=ccx.coach.id)
allow_access(course, coach, 'staff', send_email=False) allow_access(course, coach, 'staff', send_email=False)
revoke_access(course, coach, 'ccx_coach', 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): def revert_ccx_staff_to_coaches(apps, schema_editor):
""" """
...@@ -43,6 +52,8 @@ def revert_ccx_staff_to_coaches(apps, schema_editor): ...@@ -43,6 +52,8 @@ def revert_ccx_staff_to_coaches(apps, schema_editor):
""" """
CustomCourseForEdX = apps.get_model('ccx', 'CustomCourseForEdX') CustomCourseForEdX = apps.get_model('ccx', 'CustomCourseForEdX')
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
if not db_alias == 'default':
return
list_ccx = CustomCourseForEdX.objects.using(db_alias).all() list_ccx = CustomCourseForEdX.objects.using(db_alias).all()
for ccx in list_ccx: for ccx in list_ccx:
ccx_locator = CCXLocator.from_course_locator(ccx.course_id, unicode(ccx.id)) ccx_locator = CCXLocator.from_course_locator(ccx.course_id, unicode(ccx.id))
...@@ -50,6 +61,10 @@ def revert_ccx_staff_to_coaches(apps, schema_editor): ...@@ -50,6 +61,10 @@ def revert_ccx_staff_to_coaches(apps, schema_editor):
coach = User.objects.get(id=ccx.coach.id) coach = User.objects.get(id=ccx.coach.id)
allow_access(course, coach, 'ccx_coach', send_email=False) allow_access(course, coach, 'ccx_coach', send_email=False)
revoke_access(course, coach, 'staff', 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): class Migration(migrations.Migration):
...@@ -57,6 +72,7 @@ class Migration(migrations.Migration): ...@@ -57,6 +72,7 @@ class Migration(migrations.Migration):
('ccx', '0001_initial'), ('ccx', '0001_initial'),
('ccx', '0002_customcourseforedx_structure_json'), ('ccx', '0002_customcourseforedx_structure_json'),
('ccx', '0003_add_master_course_staff_in_ccx'), ('ccx', '0003_add_master_course_staff_in_ccx'),
('ccx', '0004_seed_forum_roles_in_ccx_courses'),
] ]
operations = [ operations = [
......
""" """
Models for the custom course feature Models for the custom course feature
""" """
from __future__ import unicode_literals
import json import json
import logging import logging
from datetime import datetime from datetime import datetime
...@@ -8,8 +9,9 @@ from datetime import datetime ...@@ -8,8 +9,9 @@ from datetime import datetime
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db import models from django.db import models
from pytz import utc from pytz import utc
from lazy import lazy from lazy import lazy
from ccx_keys.locator import CCXLocator
from openedx.core.lib.time_zone_utils import get_time_zone_abbr from openedx.core.lib.time_zone_utils import get_time_zone_abbr
from xmodule_django.models import CourseKeyField, LocationKeyField from xmodule_django.models import CourseKeyField, LocationKeyField
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
...@@ -121,6 +123,16 @@ class CustomCourseForEdX(models.Model): ...@@ -121,6 +123,16 @@ class CustomCourseForEdX(models.Model):
return json.loads(self.structure_json) return json.loads(self.structure_json)
return None 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): class CcxFieldOverride(models.Model):
""" """
......
...@@ -12,7 +12,10 @@ from student.tests.factories import ( ...@@ -12,7 +12,10 @@ from student.tests.factories import (
AdminFactory, AdminFactory,
) )
from util.tests.test_date_utils import fake_ugettext 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 ( from xmodule.modulestore.tests.factories import (
CourseFactory, CourseFactory,
check_mongo_calls check_mongo_calls
...@@ -30,6 +33,8 @@ class TestCCX(ModuleStoreTestCase): ...@@ -30,6 +33,8 @@ class TestCCX(ModuleStoreTestCase):
"""Unit tests for the CustomCourseForEdX model """Unit tests for the CustomCourseForEdX model
""" """
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
def setUp(self): def setUp(self):
"""common setup for all tests""" """common setup for all tests"""
super(TestCCX, self).setUp() super(TestCCX, self).setUp()
...@@ -51,7 +56,7 @@ class TestCCX(ModuleStoreTestCase): ...@@ -51,7 +56,7 @@ class TestCCX(ModuleStoreTestCase):
def test_ccx_course_caching(self): def test_ccx_course_caching(self):
"""verify that caching the propery works to limit queries""" """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 # these statements are used entirely to demonstrate the
# instance-level caching of these values on CCX objects. The # instance-level caching of these values on CCX objects. The
# check_mongo_calls context is the point here. # check_mongo_calls context is the point here.
...@@ -77,7 +82,7 @@ class TestCCX(ModuleStoreTestCase): ...@@ -77,7 +82,7 @@ class TestCCX(ModuleStoreTestCase):
"""verify that caching the start property works to limit queries""" """verify that caching the start property works to limit queries"""
now = datetime.now(utc) now = datetime.now(utc)
self.set_ccx_override('start', now) self.set_ccx_override('start', now)
with check_mongo_calls(1): with check_mongo_calls(3):
# these statements are used entirely to demonstrate the # these statements are used entirely to demonstrate the
# instance-level caching of these values on CCX objects. The # instance-level caching of these values on CCX objects. The
# check_mongo_calls context is the point here. # check_mongo_calls context is the point here.
...@@ -102,7 +107,7 @@ class TestCCX(ModuleStoreTestCase): ...@@ -102,7 +107,7 @@ class TestCCX(ModuleStoreTestCase):
"""verify that caching the due property works to limit queries""" """verify that caching the due property works to limit queries"""
expected = datetime.now(utc) expected = datetime.now(utc)
self.set_ccx_override('due', expected) self.set_ccx_override('due', expected)
with check_mongo_calls(1): with check_mongo_calls(3):
# these statements are used entirely to demonstrate the # these statements are used entirely to demonstrate the
# instance-level caching of these values on CCX objects. The # instance-level caching of these values on CCX objects. The
# check_mongo_calls context is the point here. # check_mongo_calls context is the point here.
...@@ -269,3 +274,10 @@ class TestCCX(ModuleStoreTestCase): ...@@ -269,3 +274,10 @@ class TestCCX(ModuleStoreTestCase):
) )
self.assertEqual(ccx.structure_json, json_struct) # pylint: disable=no-member self.assertEqual(ccx.structure_json, json_struct) # pylint: disable=no-member
self.assertEqual(ccx.structure, dummy_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))
...@@ -418,8 +418,8 @@ class TestCoachDashboard(CcxTestCase, LoginEnrollmentTestCase): ...@@ -418,8 +418,8 @@ class TestCoachDashboard(CcxTestCase, LoginEnrollmentTestCase):
course_enrollments = get_override_for_ccx(ccx, self.course, 'max_student_enrollments_allowed') course_enrollments = get_override_for_ccx(ccx, self.course, 'max_student_enrollments_allowed')
self.assertEqual(course_enrollments, settings.CCX_MAX_STUDENTS_ALLOWED) self.assertEqual(course_enrollments, settings.CCX_MAX_STUDENTS_ALLOWED)
# assert ccx creator has role=ccx_coach # assert ccx creator has role=staff
role = CourseCcxCoachRole(course_key) role = CourseStaffRole(course_key)
self.assertTrue(role.has_user(self.coach)) self.assertTrue(role.has_user(self.coach))
# assert that staff and instructors of master course has staff and instructor roles on ccx # assert that staff and instructors of master course has staff and instructor roles on ccx
...@@ -432,8 +432,12 @@ class TestCoachDashboard(CcxTestCase, LoginEnrollmentTestCase): ...@@ -432,8 +432,12 @@ class TestCoachDashboard(CcxTestCase, LoginEnrollmentTestCase):
with ccx_course(course_key) as course_ccx: with ccx_course(course_key) as course_ccx:
list_staff_ccx_course = list_with_level(course_ccx, 'staff') list_staff_ccx_course = list_with_level(course_ccx, 'staff')
self.assertEqual(len(list_staff_master_course), len(list_staff_ccx_course)) # The "Coach" in the parent course becomes "Staff" on the CCX, so the CCX should have 1 "Staff"
self.assertEqual(list_staff_master_course[0].email, list_staff_ccx_course[0].email) # 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') list_instructor_ccx_course = list_with_level(course_ccx, 'instructor')
self.assertEqual(len(list_instructor_ccx_course), len(list_instructor_master_course)) self.assertEqual(len(list_instructor_ccx_course), len(list_instructor_master_course))
......
...@@ -199,6 +199,8 @@ class XQueueCertInterface(object): ...@@ -199,6 +199,8 @@ class XQueueCertInterface(object):
Will change the certificate status to 'generating' or Will change the certificate status to 'generating' or
`downloadable` in case of web view certificates. `downloadable` in case of web view certificates.
The course must not be a CCX.
Certificate must be in the 'unavailable', 'error', Certificate must be in the 'unavailable', 'error',
'deleted' or 'generating' state. 'deleted' or 'generating' state.
...@@ -214,6 +216,18 @@ class XQueueCertInterface(object): ...@@ -214,6 +216,18 @@ class XQueueCertInterface(object):
Returns the newly created certificate instance 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 = [ valid_statuses = [
status.generating, status.generating,
status.unavailable, status.unavailable,
......
...@@ -77,6 +77,20 @@ class InstructorDashboardTab(CourseTab): ...@@ -77,6 +77,20 @@ class InstructorDashboardTab(CourseTab):
return bool(user and has_access(user, 'staff', course, course.id)) 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 @ensure_csrf_cookie
@cache_control(no_cache=True, no_store=True, must_revalidate=True) @cache_control(no_cache=True, no_store=True, must_revalidate=True)
def instructor_dashboard_2(request, course_id): def instructor_dashboard_2(request, course_id):
...@@ -112,7 +126,7 @@ def instructor_dashboard_2(request, course_id): ...@@ -112,7 +126,7 @@ def instructor_dashboard_2(request, course_id):
] ]
analytics_dashboard_message = None analytics_dashboard_message = None
if settings.ANALYTICS_DASHBOARD_URL: if show_analytics_dashboard_message(course_key):
# Construct a URL to the external analytics dashboard # Construct a URL to the external analytics dashboard
analytics_dashboard_url = '{0}/courses/{1}'.format(settings.ANALYTICS_DASHBOARD_URL, unicode(course_key)) 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) link_start = HTML("<a href=\"{}\" target=\"_blank\">").format(analytics_dashboard_url)
...@@ -169,7 +183,8 @@ def instructor_dashboard_2(request, course_id): ...@@ -169,7 +183,8 @@ def instructor_dashboard_2(request, course_id):
# Certificates panel # Certificates panel
# This is used to generate example certificates # This is used to generate example certificates
# and enable self-generated certificates for a course. # 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']: if certs_enabled and access['admin']:
sections.append(_section_certificates(course)) sections.append(_section_certificates(course))
...@@ -421,7 +436,7 @@ def _section_course_info(course, access): ...@@ -421,7 +436,7 @@ def _section_course_info(course, access):
if settings.FEATURES.get('DISPLAY_ANALYTICS_ENROLLMENTS'): if settings.FEATURES.get('DISPLAY_ANALYTICS_ENROLLMENTS'):
section_data['enrollment_count'] = CourseEnrollment.objects.enrollment_counts(course_key) 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 is already made safe in _get_dashboard_link
dashboard_link = _get_dashboard_link(course_key) 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 # so we can use Text() here so it's not double-escaped and rendering HTML on the front-end
......
...@@ -356,6 +356,11 @@ FEATURES = { ...@@ -356,6 +356,11 @@ FEATURES = {
# lives in the Extended table, saving the frontend from # lives in the Extended table, saving the frontend from
# making multiple queries. # making multiple queries.
'ENABLE_READING_FROM_MULTIPLE_HISTORY_TABLES': True, '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 # Ignore static asset files on import which match this pattern
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment