Skip to content
Snippets Groups Projects
test_enrollment.py 20.42 KiB
"""
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
            )