503 lines
20 KiB
Python
503 lines
20 KiB
Python
"""
|
|
Tests for student enrollment.
|
|
"""
|
|
|
|
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
import ddt
|
|
import pytest
|
|
from django.conf import settings
|
|
from django.urls import reverse
|
|
from edx_toggles.toggles.testutils import override_waffle_flag
|
|
|
|
from common.djangoapps.course_modes.models import CourseMode
|
|
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
|
|
from common.djangoapps.student.models import (
|
|
SCORE_RECALCULATION_DELAY_ON_ENROLLMENT_UPDATE,
|
|
CourseEnrollment,
|
|
CourseFullError,
|
|
EnrollmentClosedError
|
|
)
|
|
from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole
|
|
from common.djangoapps.student.tests.factories import CourseEnrollmentAllowedFactory, UserFactory
|
|
from common.djangoapps.util.testing import UrlResetMixin
|
|
from lms.djangoapps.courseware.toggles import COURSEWARE_PROCTORING_IMPROVEMENTS
|
|
from openedx.core.djangoapps.embargo.test_utils import restrict_course
|
|
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
|
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
|
|
|
|
|
@ddt.ddt
|
|
@override_waffle_flag(COURSEWARE_PROCTORING_IMPROVEMENTS, active=True)
|
|
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True})
|
|
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
|
class EnrollmentTest(UrlResetMixin, SharedModuleStoreTestCase):
|
|
"""
|
|
Test student enrollment, especially with different course modes.
|
|
"""
|
|
|
|
USERNAME = "Bob"
|
|
EMAIL = "bob@example.com"
|
|
PASSWORD = "edx"
|
|
URLCONF_MODULES = ['openedx.core.djangoapps.embargo']
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
cls.course = CourseFactory.create()
|
|
cls.course_limited = CourseFactory.create()
|
|
cls.proctored_course = CourseFactory(
|
|
enable_proctored_exams=True, enable_timed_exams=True
|
|
)
|
|
cls.proctored_course_no_exam = CourseFactory(
|
|
enable_proctored_exams=True, enable_timed_exams=True
|
|
)
|
|
|
|
@patch.dict(settings.FEATURES, {'EMBARGO': True})
|
|
def setUp(self):
|
|
""" Create a course and user, then log in. """
|
|
super().setUp()
|
|
self.user = UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD)
|
|
self.client.login(username=self.USERNAME, password=self.PASSWORD)
|
|
self.course_limited.max_student_enrollments_allowed = 1
|
|
self.store.update_item(self.course_limited, self.user.id)
|
|
self.urls = [
|
|
reverse('course_modes_choose', kwargs={'course_id': str(self.course.id)})
|
|
]
|
|
# Set up proctored exam
|
|
self._create_proctored_exam(self.proctored_course)
|
|
|
|
def _create_proctored_exam(self, course):
|
|
"""
|
|
Helper function to create a proctored exam for a given course
|
|
"""
|
|
chapter = ItemFactory.create(
|
|
parent=course, category='chapter', display_name='Test Section', publish_item=True
|
|
)
|
|
ItemFactory.create(
|
|
parent=chapter, category='sequential', display_name='Test Proctored Exam',
|
|
graded=True, is_time_limited=True, default_time_limit_minutes=10,
|
|
is_proctored_exam=True, publish_item=True
|
|
)
|
|
|
|
@ddt.data(
|
|
# Default (no course modes in the database)
|
|
# Expect that we're redirected to the dashboard
|
|
# and automatically enrolled
|
|
([], '', CourseMode.DEFAULT_MODE_SLUG),
|
|
|
|
# Audit / Verified
|
|
# We should always go to the "choose your course" page.
|
|
# We should also be enrolled as the default mode.
|
|
(['verified', 'audit'], 'course_modes_choose', CourseMode.DEFAULT_MODE_SLUG),
|
|
|
|
# Audit / Verified / Honor
|
|
# We should always go to the "choose your course" page.
|
|
# We should also be enrolled as the honor mode.
|
|
# Since honor and audit are currently offered together this precedence must
|
|
# be maintained.
|
|
(['honor', 'verified', 'audit'], 'course_modes_choose', CourseMode.HONOR),
|
|
|
|
# Professional ed
|
|
# Expect that we're sent to the "choose your track" page
|
|
# (which will, in turn, redirect us to a page where we can verify/pay)
|
|
# We should NOT be auto-enrolled, because that would be giving
|
|
# away an expensive course for free :)
|
|
(['professional'], 'course_modes_choose', None),
|
|
(['no-id-professional'], 'course_modes_choose', None),
|
|
)
|
|
@ddt.unpack
|
|
def test_enroll(self, course_modes, next_url, enrollment_mode):
|
|
# Create the course modes (if any) required for this test case
|
|
for mode_slug in course_modes:
|
|
CourseModeFactory.create(
|
|
course_id=self.course.id,
|
|
mode_slug=mode_slug,
|
|
mode_display_name=mode_slug,
|
|
)
|
|
|
|
# Reverse the expected next URL, if one is provided
|
|
# (otherwise, use an empty string, which the JavaScript client
|
|
# interprets as a redirect to the dashboard)
|
|
full_url = (
|
|
reverse(next_url, kwargs={'course_id': str(self.course.id)})
|
|
if next_url else next_url
|
|
)
|
|
|
|
# Enroll in the course and verify the URL we get sent to
|
|
resp = self._change_enrollment('enroll')
|
|
assert resp.status_code == 200
|
|
assert resp.content.decode('utf-8') == full_url
|
|
|
|
# If we're not expecting to be enrolled, verify that this is the case
|
|
if enrollment_mode is None:
|
|
assert not CourseEnrollment.is_enrolled(self.user, self.course.id)
|
|
|
|
# Otherwise, verify that we're enrolled with the expected course mode
|
|
else:
|
|
assert CourseEnrollment.is_enrolled(self.user, self.course.id)
|
|
course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id)
|
|
assert is_active
|
|
assert course_mode == enrollment_mode
|
|
|
|
def test_unenroll(self):
|
|
# Enroll the student in the course
|
|
CourseEnrollment.enroll(self.user, self.course.id, mode="honor")
|
|
|
|
# Attempt to unenroll the student
|
|
resp = self._change_enrollment('unenroll')
|
|
assert resp.status_code == 200
|
|
|
|
# Expect that we're no longer enrolled
|
|
assert not CourseEnrollment.is_enrolled(self.user, self.course.id)
|
|
|
|
@ddt.data(-1, 0, 1)
|
|
def test_external_course_updates_signal(self, value):
|
|
"""Confirm that we send the external updates experiment bucket with the activation signal"""
|
|
with patch('openedx.core.djangoapps.schedules.config.set_up_external_updates_for_enrollment',
|
|
return_value=value):
|
|
with patch('common.djangoapps.student.models.segment') as mock_segment:
|
|
CourseEnrollment.enroll(self.user, self.course.id)
|
|
|
|
assert mock_segment.track.call_count == 1
|
|
assert mock_segment.track.call_args[0][1] == 'edx.course.enrollment.activated'
|
|
assert mock_segment.track.call_args[0][2]['external_course_updates'] == value
|
|
|
|
def test_enrollment_properties_in_segment_traits(self):
|
|
with patch('common.djangoapps.student.models.segment') as mock_segment:
|
|
enrollment = CourseEnrollment.enroll(self.user, self.course.id)
|
|
assert mock_segment.track.call_count == 1
|
|
assert mock_segment.track.call_args[0][1] == 'edx.course.enrollment.activated'
|
|
traits = mock_segment.track.call_args[1]['traits']
|
|
assert traits['course_title'] == self.course.display_name
|
|
assert traits['mode'] == 'audit'
|
|
assert traits['email'] == self.EMAIL
|
|
|
|
with patch('common.djangoapps.student.models.segment') as mock_segment:
|
|
enrollment.update_enrollment(mode='verified')
|
|
assert mock_segment.track.call_count == 1
|
|
assert mock_segment.track.call_args[0][1] == 'edx.course.enrollment.mode_changed'
|
|
traits = mock_segment.track.call_args[1]['traits']
|
|
assert traits['course_title'] == self.course.display_name
|
|
assert traits['mode'] == 'verified'
|
|
assert traits['email'] == self.EMAIL
|
|
|
|
@patch.dict(settings.FEATURES, {'ENABLE_MKTG_EMAIL_OPT_IN': True})
|
|
@patch('openedx.core.djangoapps.user_api.preferences.api.update_email_opt_in')
|
|
@ddt.data(
|
|
([], 'true'),
|
|
([], 'false'),
|
|
([], None),
|
|
(['honor', 'verified'], 'true'),
|
|
(['honor', 'verified'], 'false'),
|
|
(['honor', 'verified'], None),
|
|
(['professional'], 'true'),
|
|
(['professional'], 'false'),
|
|
(['professional'], None),
|
|
(['no-id-professional'], 'true'),
|
|
(['no-id-professional'], 'false'),
|
|
(['no-id-professional'], None),
|
|
)
|
|
@ddt.unpack
|
|
def test_enroll_with_email_opt_in(self, course_modes, email_opt_in, mock_update_email_opt_in):
|
|
# Create the course modes (if any) required for this test case
|
|
for mode_slug in course_modes:
|
|
CourseModeFactory.create(
|
|
course_id=self.course.id,
|
|
mode_slug=mode_slug,
|
|
mode_display_name=mode_slug,
|
|
)
|
|
|
|
# Enroll in the course
|
|
self._change_enrollment('enroll', email_opt_in=email_opt_in)
|
|
|
|
# Verify that the profile API has been called as expected
|
|
if email_opt_in is not None:
|
|
opt_in = email_opt_in == 'true'
|
|
mock_update_email_opt_in.assert_called_once_with(self.user, self.course.org, opt_in)
|
|
else:
|
|
assert not mock_update_email_opt_in.called
|
|
|
|
@ddt.data(
|
|
('honor', False),
|
|
('audit', False),
|
|
('verified', True),
|
|
('masters', True),
|
|
('professional', True),
|
|
('no-id-professional', False),
|
|
('credit', False),
|
|
('executive-education', True)
|
|
)
|
|
@ddt.unpack
|
|
def test_enroll_in_proctored_course(self, mode, email_sent):
|
|
"""
|
|
When enrolling in a proctoring-enabled course in a verified mode, an email with proctoring
|
|
requirements should be sent. The email should not be sent for non-verified modes.
|
|
"""
|
|
with patch(
|
|
'common.djangoapps.student.models.send_proctoring_requirements_email',
|
|
return_value=None
|
|
) as mock_send_email:
|
|
# First enroll in a non-proctored course. This should not trigger the email.
|
|
CourseEnrollment.enroll(self.user, self.course.id, mode)
|
|
assert not mock_send_email.called
|
|
# Then, enroll in a proctored course, and assert that the email is sent only when
|
|
# enrolling in a verified mode.
|
|
CourseEnrollment.enroll(self.user, self.proctored_course.id, mode) # pylint: disable=no-member
|
|
assert email_sent == mock_send_email.called
|
|
|
|
def test_enroll_in_proctored_course_no_exam(self):
|
|
"""
|
|
If a verified learner enrolls in a course that has proctoring enabled, but does not have
|
|
any proctored exams, they should not receive a proctoring requirements email.
|
|
"""
|
|
with patch(
|
|
'common.djangoapps.student.models.send_proctoring_requirements_email',
|
|
return_value=None
|
|
) as mock_send_email:
|
|
CourseEnrollment.enroll(
|
|
self.user, self.proctored_course_no_exam.id, 'verified' # pylint: disable=no-member
|
|
)
|
|
assert not mock_send_email.called
|
|
|
|
@ddt.data('verified', 'masters', 'professional', 'executive-education')
|
|
def test_upgrade_proctoring_enrollment(self, mode):
|
|
"""
|
|
When upgrading from audit in a course with proctored exams, an email with proctoring requirements
|
|
should be sent.
|
|
"""
|
|
with patch(
|
|
'common.djangoapps.student.models.send_proctoring_requirements_email',
|
|
return_value=None
|
|
) as mock_send_email:
|
|
enrollment = CourseEnrollment.enroll(
|
|
self.user, self.proctored_course.id, 'audit' # pylint: disable=no-member
|
|
)
|
|
enrollment.update_enrollment(mode=mode)
|
|
assert mock_send_email.called
|
|
|
|
@patch.dict(
|
|
'django.conf.settings.PROCTORING_BACKENDS', {'test_provider_honor_mode': {'allow_honor_mode': True}}
|
|
)
|
|
def test_enroll_in_proctored_course_honor_mode_allowed(self):
|
|
"""
|
|
If the proctoring provider allows honor mode, send proctoring requirements email when learners
|
|
enroll in honor mode for a course with proctored exams.
|
|
"""
|
|
with patch(
|
|
'common.djangoapps.student.models.send_proctoring_requirements_email',
|
|
return_value=None
|
|
) as mock_send_email:
|
|
course_honor_mode = CourseFactory(
|
|
enable_proctored_exams=True,
|
|
enable_timed_exams=True,
|
|
proctoring_provider='test_provider_honor_mode',
|
|
)
|
|
self._create_proctored_exam(course_honor_mode)
|
|
CourseEnrollment.enroll(self.user, course_honor_mode.id, 'honor') # pylint: disable=no-member
|
|
assert mock_send_email.called
|
|
|
|
@patch.dict(settings.FEATURES, {'EMBARGO': True})
|
|
def test_embargo_restrict(self):
|
|
# When accessing the course from an embargoed country,
|
|
# we should be blocked.
|
|
with restrict_course(self.course.id) as redirect_url:
|
|
response = self._change_enrollment('enroll')
|
|
assert response.status_code == 200
|
|
assert response.content.decode('utf-8') == redirect_url
|
|
|
|
# Verify that we weren't enrolled
|
|
is_enrolled = CourseEnrollment.is_enrolled(self.user, self.course.id)
|
|
assert not is_enrolled
|
|
|
|
@patch.dict(settings.FEATURES, {'EMBARGO': True})
|
|
def test_embargo_allow(self):
|
|
response = self._change_enrollment('enroll')
|
|
assert response.status_code == 200
|
|
assert response.content.decode('utf-8') == ''
|
|
|
|
# Verify that we were enrolled
|
|
is_enrolled = CourseEnrollment.is_enrolled(self.user, self.course.id)
|
|
assert is_enrolled
|
|
|
|
def test_user_not_authenticated(self):
|
|
# Log out, so we're no longer authenticated
|
|
self.client.logout()
|
|
|
|
# Try to enroll, expecting a forbidden response
|
|
resp = self._change_enrollment('enroll')
|
|
assert resp.status_code == 403
|
|
|
|
def test_missing_course_id_param(self):
|
|
resp = self.client.post(
|
|
reverse('change_enrollment'),
|
|
{'enrollment_action': 'enroll'}
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
def test_unenroll_not_enrolled_in_course(self):
|
|
# Try unenroll without first enrolling in the course
|
|
resp = self._change_enrollment('unenroll')
|
|
assert resp.status_code == 400
|
|
|
|
def test_invalid_enrollment_action(self):
|
|
resp = self._change_enrollment('not_an_action')
|
|
assert resp.status_code == 400
|
|
|
|
def test_with_invalid_course_id(self):
|
|
CourseEnrollment.enroll(self.user, self.course.id, mode="honor")
|
|
resp = self._change_enrollment('unenroll', course_id="edx/")
|
|
assert resp.status_code == 400
|
|
|
|
def test_enrollment_limit(self):
|
|
"""
|
|
Assert that in a course with max student limit set to 1, we can enroll staff and instructor along with
|
|
student. To make sure course full check excludes staff and instructors.
|
|
"""
|
|
assert self.course_limited.max_student_enrollments_allowed == 1
|
|
user1 = UserFactory.create(username="tester1", email="tester1@e.com", password="test")
|
|
user2 = UserFactory.create(username="tester2", email="tester2@e.com", password="test")
|
|
|
|
# create staff on course.
|
|
staff = UserFactory.create(username="staff", email="staff@e.com", password="test")
|
|
role = CourseStaffRole(self.course_limited.id)
|
|
role.add_users(staff)
|
|
|
|
# create instructor on course.
|
|
instructor = UserFactory.create(username="instructor", email="instructor@e.com", password="test")
|
|
role = CourseInstructorRole(self.course_limited.id)
|
|
role.add_users(instructor)
|
|
|
|
CourseEnrollment.enroll(staff, self.course_limited.id, check_access=True)
|
|
CourseEnrollment.enroll(instructor, self.course_limited.id, check_access=True)
|
|
|
|
assert CourseEnrollment.objects.filter(course_id=self.course_limited.id, user=staff).exists()
|
|
|
|
assert CourseEnrollment.objects.filter(course_id=self.course_limited.id, user=instructor).exists()
|
|
|
|
CourseEnrollment.enroll(user1, self.course_limited.id, check_access=True)
|
|
assert CourseEnrollment.objects.filter(course_id=self.course_limited.id, user=user1).exists()
|
|
|
|
with pytest.raises(CourseFullError):
|
|
CourseEnrollment.enroll(user2, self.course_limited.id, check_access=True)
|
|
|
|
assert not CourseEnrollment.objects.filter(course_id=self.course_limited.id, user=user2).exists()
|
|
|
|
def _change_enrollment(self, action, course_id=None, email_opt_in=None):
|
|
"""Change the student's enrollment status in a course.
|
|
|
|
Args:
|
|
action (str): The action to perform (either "enroll" or "unenroll")
|
|
|
|
Keyword Args:
|
|
course_id (unicode): If provided, use this course ID. Otherwise, use the
|
|
course ID created in the setup for this test.
|
|
email_opt_in (unicode): If provided, pass this value along as
|
|
an additional GET parameter.
|
|
|
|
Returns:
|
|
Response
|
|
|
|
"""
|
|
if course_id is None:
|
|
course_id = str(self.course.id)
|
|
|
|
params = {
|
|
'enrollment_action': action,
|
|
'course_id': course_id
|
|
}
|
|
|
|
if email_opt_in:
|
|
params['email_opt_in'] = email_opt_in
|
|
|
|
return self.client.post(reverse('change_enrollment'), params)
|
|
|
|
def test_cea_enrolls_only_one_user(self):
|
|
"""
|
|
Tests that a CourseEnrollmentAllowed can be used by just one user.
|
|
If the user changes e-mail and then a second user tries to enroll with the same accepted e-mail,
|
|
the second enrollment should fail.
|
|
However, the original user can reuse the CEA many times.
|
|
"""
|
|
|
|
cea = CourseEnrollmentAllowedFactory(
|
|
email='allowed@edx.org',
|
|
course_id=self.course.id,
|
|
auto_enroll=False,
|
|
)
|
|
# Still unlinked
|
|
assert cea.user is None
|
|
|
|
user1 = UserFactory.create(username="tester1", email="tester1@e.com", password="test")
|
|
user2 = UserFactory.create(username="tester2", email="tester2@e.com", password="test")
|
|
|
|
assert not CourseEnrollment.objects.filter(course_id=self.course.id, user=user1).exists()
|
|
|
|
user1.email = 'allowed@edx.org'
|
|
user1.save()
|
|
|
|
CourseEnrollment.enroll(user1, self.course.id, check_access=True)
|
|
|
|
assert CourseEnrollment.objects.filter(course_id=self.course.id, user=user1).exists()
|
|
|
|
# The CEA is now linked
|
|
cea.refresh_from_db()
|
|
assert cea.user == user1
|
|
|
|
# user2 wants to enroll too, (ab)using the same allowed e-mail, but cannot
|
|
user1.email = 'my_other_email@edx.org'
|
|
user1.save()
|
|
user2.email = 'allowed@edx.org'
|
|
user2.save()
|
|
with pytest.raises(EnrollmentClosedError):
|
|
CourseEnrollment.enroll(user2, self.course.id, check_access=True)
|
|
|
|
# CEA still linked to user1. Also after unenrolling
|
|
cea.refresh_from_db()
|
|
assert cea.user == user1
|
|
|
|
CourseEnrollment.unenroll(user1, self.course.id)
|
|
|
|
cea.refresh_from_db()
|
|
assert cea.user == user1
|
|
|
|
# Enroll user1 again. Because it's the original owner of the CEA, the enrollment is allowed
|
|
CourseEnrollment.enroll(user1, self.course.id, check_access=True)
|
|
|
|
# Still same
|
|
cea.refresh_from_db()
|
|
assert cea.user == user1
|
|
|
|
def test_score_recalculation_on_enrollment_update(self):
|
|
"""
|
|
Test that an update in enrollment cause score recalculation.
|
|
Note:
|
|
Score recalculation task must be called with a delay of SCORE_RECALCULATION_DELAY_ON_ENROLLMENT_UPDATE
|
|
"""
|
|
course_modes = ['verified', 'audit']
|
|
|
|
for mode_slug in course_modes:
|
|
CourseModeFactory.create(
|
|
course_id=self.course.id,
|
|
mode_slug=mode_slug,
|
|
mode_display_name=mode_slug,
|
|
)
|
|
CourseEnrollment.enroll(self.user, self.course.id, mode="audit")
|
|
|
|
local_task_args = dict(
|
|
user_id=self.user.id,
|
|
course_key=str(self.course.id)
|
|
)
|
|
|
|
with patch(
|
|
'lms.djangoapps.grades.tasks.recalculate_course_and_subsection_grades_for_user.apply_async',
|
|
return_value=None
|
|
) as mock_task_apply:
|
|
CourseEnrollment.enroll(self.user, self.course.id, mode="verified")
|
|
mock_task_apply.assert_called_once_with(
|
|
countdown=SCORE_RECALCULATION_DELAY_ON_ENROLLMENT_UPDATE,
|
|
kwargs=local_task_args
|
|
)
|