Files
edx-platform/common/djangoapps/student/tests/test_enrollment.py
2021-03-19 15:30:01 +05:00

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
)