diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index e879ee9fe0..608534e482 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -143,6 +143,7 @@ class CourseMode(models.Model): AUDIT = 'audit' NO_ID_PROFESSIONAL_MODE = 'no-id-professional' CREDIT_MODE = 'credit' + MASTERS = 'masters' DEFAULT_MODE = Mode( settings.COURSE_MODE_DEFAULTS['slug'], @@ -157,13 +158,13 @@ class CourseMode(models.Model): ) DEFAULT_MODE_SLUG = settings.COURSE_MODE_DEFAULTS['slug'] - ALL_MODES = [AUDIT, CREDIT_MODE, HONOR, NO_ID_PROFESSIONAL_MODE, PROFESSIONAL, VERIFIED, ] + ALL_MODES = [AUDIT, CREDIT_MODE, HONOR, NO_ID_PROFESSIONAL_MODE, PROFESSIONAL, VERIFIED, MASTERS, ] # Modes utilized for audit/free enrollments AUDIT_MODES = [AUDIT, HONOR] # Modes that allow a student to pursue a verified certificate - VERIFIED_MODES = [VERIFIED, PROFESSIONAL] + VERIFIED_MODES = [VERIFIED, PROFESSIONAL, MASTERS] # Modes that allow a student to pursue a non-verified certificate NON_VERIFIED_MODES = [HONOR, AUDIT, NO_ID_PROFESSIONAL_MODE] @@ -174,6 +175,9 @@ class CourseMode(models.Model): # Modes that are eligible to purchase credit CREDIT_ELIGIBLE_MODES = [VERIFIED, PROFESSIONAL, NO_ID_PROFESSIONAL_MODE] + # Modes for which certificates/programs may need to be updated + CERTIFICATE_RELEVANT_MODES = CREDIT_MODES + CREDIT_ELIGIBLE_MODES + [MASTERS] + # Modes that are allowed to upsell UPSELL_TO_VERIFIED_MODES = [HONOR, AUDIT] diff --git a/common/djangoapps/enrollment/management/commands/enroll_user_in_course.py b/common/djangoapps/enrollment/management/commands/enroll_user_in_course.py index 033ed26565..3f393c71c0 100644 --- a/common/djangoapps/enrollment/management/commands/enroll_user_in_course.py +++ b/common/djangoapps/enrollment/management/commands/enroll_user_in_course.py @@ -12,9 +12,10 @@ class Command(BaseCommand): Enroll a user into a course """ help = """ - This enrolls a user into a given course with the default mode (e.g., 'honor', 'audit', etc). + This enrolls a user into a given course User email and course ID are required. + Mode is optional. It defaults to the default mode (e.g., 'honor', 'audit', etc). example: # Enroll a user test@example.com into the demo course @@ -35,7 +36,14 @@ class Command(BaseCommand): '-c', '--course', nargs=1, required=True, - help='course ID to enroll the user in') + help='course ID to enroll the user in' + ) + parser.add_argument( + '-m', '--mode', + required=False, + default=None, + help='course mode to enroll the user in' + ) def handle(self, *args, **options): """ @@ -43,10 +51,11 @@ class Command(BaseCommand): """ email = options['email'][0] course = options['course'][0] + mode = options['mode'] user = User.objects.get(email=email) try: - add_enrollment(user.username, course) + add_enrollment(user.username, course, mode=mode) except CourseEnrollmentExistsError: # If the user is already enrolled in the course, do nothing. pass diff --git a/lms/djangoapps/courseware/rules.py b/lms/djangoapps/courseware/rules.py new file mode 100644 index 0000000000..ce019ca574 --- /dev/null +++ b/lms/djangoapps/courseware/rules.py @@ -0,0 +1,25 @@ +""" +django-rules for courseware related features +""" +from __future__ import absolute_import + +from opaque_keys.edx.keys import CourseKey +from student.models import CourseEnrollment + +import rules + + +@rules.predicate +def is_verified_or_masters_track_exam(user, exam): + """ + Returns whether the user is in a verified or master's track + """ + course_id = CourseKey.from_string(exam['course_id']) + mode, is_active = CourseEnrollment.enrollment_mode_for_user(user, course_id) + return is_active and mode in ('verified', 'masters') + + +# The edx_proctoring.api uses this permission to gate access to the +# proctored experience +can_take_proctored_exam = is_verified_or_masters_track_exam +rules.set_perm('edx_proctoring.can_take_proctored_exam', is_verified_or_masters_track_exam) diff --git a/lms/djangoapps/courseware/tests/test_rules.py b/lms/djangoapps/courseware/tests/test_rules.py new file mode 100644 index 0000000000..cd36ad0115 --- /dev/null +++ b/lms/djangoapps/courseware/tests/test_rules.py @@ -0,0 +1,48 @@ +""" +Tests for permissions defined in courseware.rules +""" +import ddt + +from django.test import TestCase + +from course_modes.tests.factories import CourseModeFactory + +from opaque_keys.edx.locator import CourseLocator +from student.models import CourseEnrollment +from student.tests.factories import UserFactory + + +@ddt.ddt +class PermissionTests(TestCase): + """ + Tests for permissions defined in courseware.rules + """ + def setUp(self): + super(PermissionTests, self).setUp() + self.user = UserFactory() + + self.course_id = CourseLocator('MITx', '000', 'Perm_course') + CourseModeFactory(mode_slug='verified', course_id=self.course_id) + CourseModeFactory(mode_slug='masters', course_id=self.course_id) + + def tearDown(self): + super(PermissionTests, self).tearDown() + self.user.delete() + + @ddt.data( + (None, False), + ('audit', False), + ('verified', True), + ('masters', True), + ) + @ddt.unpack + def test_proctoring_perm(self, mode, should_have_perm): + """ + Test that the user has the edx_proctoring.can_take_proctored_exam permission + """ + if mode is not None: + CourseEnrollment.enroll(self.user, self.course_id, mode=mode) + else: + CourseEnrollment.unenroll(self.user, self.course_id) + has_perm = self.user.has_perm('edx_proctoring.can_take_proctored_exam', {'course_id': unicode(self.course_id)}) + assert has_perm == should_have_perm diff --git a/lms/envs/common.py b/lms/envs/common.py index eead193a3f..859fce1d93 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -3384,6 +3384,12 @@ COURSE_ENROLLMENT_MODES = { "display_name": _("Honor"), "min_price": 0 }, + "masters": { + "id": 7, + "slug": "masters", + "display_name": _("Master's"), + "min_price": 0 + }, } CONTENT_TYPE_GATE_GROUP_IDS = { diff --git a/openedx/core/djangoapps/credentials/signals.py b/openedx/core/djangoapps/credentials/signals.py index 15ced6872c..d1c3309843 100644 --- a/openedx/core/djangoapps/credentials/signals.py +++ b/openedx/core/djangoapps/credentials/signals.py @@ -17,7 +17,7 @@ log = getLogger(__name__) # "interesting" here means "credentials will want to know about it" -INTERESTING_MODES = CourseMode.CREDIT_ELIGIBLE_MODES + CourseMode.CREDIT_MODES +INTERESTING_MODES = CourseMode.CERTIFICATE_RELEVANT_MODES INTERESTING_STATUSES = [ CertificateStatuses.notpassing, CertificateStatuses.downloadable, diff --git a/openedx/core/djangoapps/credentials/tests/test_signals.py b/openedx/core/djangoapps/credentials/tests/test_signals.py index 94e334a91b..b96aa5fdd1 100644 --- a/openedx/core/djangoapps/credentials/tests/test_signals.py +++ b/openedx/core/djangoapps/credentials/tests/test_signals.py @@ -43,6 +43,8 @@ class TestCredentialsSignalsSendGrade(TestCase): [True, 'no-id-professional', 'downloadable'], [True, 'credit', 'downloadable'], [True, 'verified', 'notpassing'], + [True, 'masters', 'downloadable'], + [True, 'masters', 'notpassing'], [False, 'audit', 'downloadable'], [False, 'professional', 'generating'], [False, 'no-id-professional', 'generating'], diff --git a/openedx/core/djangoapps/programs/tasks/v1/tasks.py b/openedx/core/djangoapps/programs/tasks/v1/tasks.py index a5e7b30022..fda5c633ff 100644 --- a/openedx/core/djangoapps/programs/tasks/v1/tasks.py +++ b/openedx/core/djangoapps/programs/tasks/v1/tasks.py @@ -304,7 +304,7 @@ def award_course_certificate(self, username, course_run_key): username ) return - if certificate.mode in CourseMode.CREDIT_ELIGIBLE_MODES + CourseMode.CREDIT_MODES: + if certificate.mode in CourseMode.CERTIFICATE_RELEVANT_MODES: try: course_overview = CourseOverview.get_from_id(course_key) except (CourseOverview.DoesNotExist, IOError):