Files
2026-01-09 12:22:23 -05:00

1231 lines
54 KiB
Python

"""
Miscellaneous tests for the student app.
"""
import logging
from datetime import datetime, timedelta
from unittest.mock import Mock, patch
from urllib.parse import quote
from zoneinfo import ZoneInfo
import ddt
from config_models.models import cache
from django.conf import settings
from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user
from django.test import TestCase, override_settings
from django.test.client import Client
from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_switch
from markupsafe import escape
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.locations import CourseLocator
from pyquery import PyQuery as pq
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from common.djangoapps.student.helpers import _cert_info, process_survey_link
from common.djangoapps.student.models import (
AnonymousUserId,
CourseEnrollment,
LinkedInAddToProfileConfiguration,
UserAttribute,
anonymous_id_for_user,
unique_id_for_user,
user_by_anonymous_id
)
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
from common.djangoapps.student.toggles import REDIRECT_TO_COURSEWARE_AFTER_ENROLLMENT
from common.djangoapps.student.views import complete_course_mode_info
from common.djangoapps.util.model_utils import USER_SETTINGS_CHANGED_EVENT_NAME
from common.djangoapps.util.testing import EventTestMixin
from lms.djangoapps.certificates.data import CertificateStatuses
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from lms.djangoapps.verify_student.tests import TestVerificationBase
from openedx.core.djangoapps.catalog.tests.factories import CourseFactory as CatalogCourseFactory
from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory, ProgramFactory, generate_course_run_key
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
from openedx.features.course_experience.url_helpers import make_learning_mfe_courseware_url
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context
log = logging.getLogger(__name__)
BETA_TESTER_METHOD = 'common.djangoapps.student.helpers.access.is_beta_tester'
@skip_unless_lms
@ddt.ddt
class CourseEndingTest(ModuleStoreTestCase):
"""Test things related to course endings: certificates, surveys, etc"""
def test_process_survey_link(self):
username = "fred"
user = Mock(username=username)
user_id = unique_id_for_user(user)
link1 = "http://www.mysurvey.com"
assert process_survey_link(link1, user) == link1
link2 = "http://www.mysurvey.com?unique={UNIQUE_ID}"
link2_expected = f"http://www.mysurvey.com?unique={user_id}"
assert process_survey_link(link2, user) == link2_expected
@patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': False})
def test_cert_info(self):
user = UserFactory.create()
survey_url = "http://a_survey.com"
course = CourseOverviewFactory.create(
end_of_course_survey_url=survey_url,
certificates_display_behavior=CertificatesDisplayBehaviors.END,
end=datetime.now(ZoneInfo("UTC")) - timedelta(days=2)
)
cert = GeneratedCertificateFactory.create(
user=user,
course_id=course.id,
status=CertificateStatuses.downloadable,
mode='honor',
grade='67',
download_url='http://s3.edx/cert'
)
enrollment = CourseEnrollmentFactory(user=user, course_id=course.id, mode=CourseMode.VERIFIED)
# LinkedIn sharing disabled as we are expecting 'linked_in_url': None in our test case
SITE_CONFIGURATION = {
'SOCIAL_SHARING_SETTINGS': {
'CERTIFICATE_LINKEDIN': False
}
}
with with_site_configuration_context(configuration=SITE_CONFIGURATION):
assert _cert_info(user, enrollment, None) == {
'status': 'processing',
'show_survey_button': False,
'can_unenroll': True,
}
cert_status = {'status': 'unavailable', 'mode': 'honor', 'uuid': None}
assert _cert_info(user, enrollment, cert_status) == {
'status': 'processing',
'show_survey_button': False,
'mode': 'honor',
'linked_in_url': None,
'can_unenroll': True,
}
cert_status = {'status': 'generating', 'grade': '0.67', 'mode': 'honor', 'uuid': None}
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as patch_persisted_grade:
patch_persisted_grade.return_value = Mock(percent=1.0)
assert _cert_info(user, enrollment, cert_status) == {
'status': 'generating',
'show_survey_button': True,
'survey_url': survey_url,
'grade': '1.0',
'mode': 'honor',
'linked_in_url': None,
'can_unenroll': False,
}
cert_status = {'status': 'generating', 'grade': '0.67', 'mode': 'honor', 'uuid': None}
assert _cert_info(user, enrollment, cert_status) == {
'status': 'generating',
'show_survey_button': True,
'survey_url': survey_url,
'grade': '0.67',
'mode': 'honor',
'linked_in_url': None,
'can_unenroll': False,
}
cert_status = {
'status': 'downloadable',
'grade': '0.67',
'download_url': cert.download_url,
'mode': 'honor',
'uuid': 'fakeuuidbutitsfine',
}
assert _cert_info(user, enrollment, cert_status) == {
'status': 'downloadable',
'download_url': cert.download_url,
'show_survey_button': True,
'survey_url': survey_url,
'grade': '0.67',
'mode': 'honor',
'linked_in_url': None,
'can_unenroll': False,
}
cert_status = {
'status': 'notpassing',
'grade': '0.67',
'download_url': cert.download_url,
'mode': 'honor',
'uuid': 'fakeuuidbutitsfine',
}
assert _cert_info(user, enrollment, cert_status) == {
'status': 'notpassing',
'show_survey_button': True,
'survey_url': survey_url,
'grade': '0.67',
'mode': 'honor',
'linked_in_url': None,
'can_unenroll': True,
}
# Test a course that doesn't have a survey specified
course2 = CourseOverviewFactory.create(
end_of_course_survey_url=None,
certificates_display_behavior='end',
)
enrollment2 = CourseEnrollmentFactory(user=user, course_id=course2.id, mode=CourseMode.VERIFIED)
cert_status = {
'status': 'notpassing', 'grade': '0.67',
'download_url': cert.download_url, 'mode': 'honor', 'uuid': 'fakeuuidbutitsfine'
}
assert _cert_info(user, enrollment2, cert_status) == {
'status': 'notpassing',
'show_survey_button': False,
'grade': '0.67',
'mode': 'honor',
'linked_in_url': None,
'can_unenroll': True
}
course3 = CourseOverviewFactory.create(
end_of_course_survey_url=None,
certificates_display_behavior='early_no_info',
)
enrollment3 = CourseEnrollmentFactory(user=user, course_id=course3.id, mode=CourseMode.VERIFIED)
# test when the display is unavailable or notpassing, we get the correct results out
course2.certificates_display_behavior = CertificatesDisplayBehaviors.EARLY_NO_INFO
cert_status = {'status': 'unavailable', 'mode': 'honor', 'uuid': None}
assert _cert_info(user, enrollment3, cert_status) == {
'status': 'processing',
'show_survey_button': False,
'can_unenroll': True
}
cert_status = {
'status': 'notpassing', 'grade': '0.67',
'download_url': cert.download_url,
'mode': 'honor',
'uuid': 'fakeuuidbutitsfine'
}
assert _cert_info(user, enrollment3, cert_status) == {
'status': 'processing',
'show_survey_button': False,
'can_unenroll': True
}
def test_cert_info_beta_tester(self):
user = UserFactory.create()
course = CourseOverviewFactory.create()
mode = CourseMode.VERIFIED
grade = '0.67'
status = CertificateStatuses.downloadable
cert = GeneratedCertificateFactory.create(
user=user,
course_id=course.id,
status=status,
mode=mode
)
enrollment = CourseEnrollmentFactory(user=user, course_id=course.id, mode=mode)
cert_status = {
'status': status,
'grade': grade,
'download_url': cert.download_url,
'mode': mode,
'uuid': 'blah',
}
with patch(BETA_TESTER_METHOD, return_value=False):
# LinkedIn sharing disabled as we are expecting 'linked_in_url': None in our test case
SITE_CONFIGURATION = {
'SOCIAL_SHARING_SETTINGS': {
'CERTIFICATE_LINKEDIN': False
}
}
with with_site_configuration_context(configuration=SITE_CONFIGURATION):
assert _cert_info(user, enrollment, cert_status) == {
'status': status,
'download_url': cert.download_url,
'show_survey_button': False,
'grade': grade,
'mode': mode,
'linked_in_url': None,
'can_unenroll': False
}
with patch(BETA_TESTER_METHOD, return_value=True):
assert _cert_info(user, enrollment, cert_status) == {
'status': 'processing',
'show_survey_button': False,
'can_unenroll': True
}
@ddt.data(
(0.70, 0.60),
(0.60, 0.70),
(None, 0.70),
(None, 0.0),
(0.70, None),
(0.0, None),
(0.70, 0.0),
(0.0, 0.70),
)
@ddt.unpack
def test_cert_grade(self, persisted_grade, cert_grade):
"""
Tests that the higher of the persisted grade and the grade
from the certs table is used on the learner dashboard.
"""
expected_grade = max(filter(lambda x: x is not None, [persisted_grade, cert_grade]))
user = UserFactory.create()
survey_url = "http://a_survey.com"
course = CourseOverviewFactory.create(
end_of_course_survey_url=survey_url,
certificates_display_behavior=CertificatesDisplayBehaviors.END,
end=datetime.now(ZoneInfo("UTC")) - timedelta(days=2),
)
enrollment = CourseEnrollmentFactory(user=user, course_id=course.id, mode=CourseMode.VERIFIED)
if cert_grade is not None:
cert_status = {'status': 'generating', 'grade': str(cert_grade), 'mode': 'honor', 'uuid': None}
else:
cert_status = {'status': 'generating', 'mode': 'honor', 'uuid': None}
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as patch_persisted_grade:
patch_persisted_grade.return_value = Mock(percent=persisted_grade)
assert _cert_info(user, enrollment, cert_status) == {'status': 'generating', 'show_survey_button': True,
'survey_url': survey_url, 'grade': str(expected_grade),
'mode': 'honor', 'linked_in_url': None,
'can_unenroll': False}
def test_cert_grade_no_grades(self):
"""
Tests that the default cert info is returned
when the learner has no persisted grade or grade
in the certs table.
"""
user = UserFactory.create()
survey_url = "http://a_survey.com"
course = CourseOverviewFactory.create(
end_of_course_survey_url=survey_url,
certificates_display_behavior=CertificatesDisplayBehaviors.END,
end=datetime.now(ZoneInfo("UTC")) - timedelta(days=2),
)
cert_status = {'status': 'generating', 'mode': 'honor', 'uuid': None}
enrollment = CourseEnrollmentFactory(user=user, course_id=course.id, mode=CourseMode.VERIFIED)
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as patch_persisted_grade:
patch_persisted_grade.return_value = None
assert _cert_info(user, enrollment, cert_status) == {'status': 'processing', 'show_survey_button': False,
'can_unenroll': True}
@ddt.ddt
class DashboardTest(ModuleStoreTestCase, TestVerificationBase):
"""
Tests for dashboard utility functions
"""
ENABLED_SIGNALS = ['course_published']
def setUp(self):
super().setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password=self.TEST_PASSWORD)
self.client = Client()
cache.clear()
@skip_unless_lms
def _check_verification_status_on(self, mode, value):
"""
Check that the css class and the status message are in the dashboard html.
"""
CourseModeFactory.create(mode_slug=mode, course_id=self.course.id)
CourseEnrollment.enroll(self.user, self.course.location.course_key, mode=mode)
if mode == 'verified':
# Simulate a successful verification attempt
attempt = self.create_and_submit_attempt_for_user(self.user)
attempt.approve()
response = self.client.get(reverse('dashboard'))
if mode in ['professional', 'no-id-professional']:
self.assertContains(response, 'class="course professional"')
else:
self.assertContains(response, f'class="course {mode}"')
self.assertContains(response, value)
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_VERIFIED_CERTIFICATES': True})
def test_verification_status_visible(self):
"""
Test that the certificate verification status for courses is visible on the dashboard.
"""
self.client.login(username="jack", password=self.TEST_PASSWORD)
self._check_verification_status_on('verified', 'You're enrolled as a verified student')
self._check_verification_status_on('honor', 'You're enrolled as an honor code student')
self._check_verification_status_off('audit', '')
self._check_verification_status_on('professional', 'You're enrolled as a professional education student')
self._check_verification_status_on(
'no-id-professional',
'You're enrolled as a professional education student',
)
@skip_unless_lms
def _check_verification_status_off(self, mode, value):
"""
Check that the css class and the status message are not in the dashboard html.
"""
CourseModeFactory.create(mode_slug=mode, course_id=self.course.id)
CourseEnrollment.enroll(self.user, self.course.location.course_key, mode=mode)
if mode == 'verified':
# Simulate a successful verification attempt
attempt = self.create_and_submit_attempt_for_user(self.user)
attempt.approve()
response = self.client.get(reverse('dashboard'))
if mode == 'audit':
# Audit mode does not have a banner. Assert no banner element.
assert pq(response.content)('.sts-enrollment').length == 0
else:
self.assertNotContains(response, f"class=\"course {mode}\"")
self.assertNotContains(response, value)
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_VERIFIED_CERTIFICATES': False})
def test_verification_status_invisible(self):
"""
Test that the certificate verification status for courses is not visible on the dashboard
if the verified certificates setting is off.
"""
self.client.login(username="jack", password=self.TEST_PASSWORD)
self._check_verification_status_off('verified', 'You\'re enrolled as a verified student')
self._check_verification_status_off('honor', 'You\'re enrolled as an honor code student')
self._check_verification_status_off('audit', '')
def test_course_mode_info(self):
verified_mode = CourseModeFactory.create(
course_id=self.course.id,
mode_slug='verified',
mode_display_name='Verified',
expiration_datetime=datetime.now(ZoneInfo("UTC")) + timedelta(days=1)
)
enrollment = CourseEnrollment.enroll(self.user, self.course.id)
course_mode_info = complete_course_mode_info(self.course.id, enrollment)
assert course_mode_info['show_upsell']
assert course_mode_info['days_for_upsell'] == 1
verified_mode.expiration_datetime = datetime.now(ZoneInfo("UTC")) + timedelta(days=-1)
verified_mode.save()
course_mode_info = complete_course_mode_info(self.course.id, enrollment)
assert not course_mode_info['show_upsell']
assert course_mode_info['days_for_upsell'] is None
@skip_unless_lms
def test_linked_in_add_to_profile_btn_not_appearing_without_config(self):
# Without linked-in config don't show Add Certificate to LinkedIn button
self.client.login(username="jack", password=self.TEST_PASSWORD)
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='verified',
mode_display_name='verified',
expiration_datetime=datetime.now(ZoneInfo("UTC")) - timedelta(days=1)
)
CourseEnrollment.enroll(self.user, self.course.id, mode='honor')
self.course.start = datetime.now(ZoneInfo("UTC")) - timedelta(days=2)
self.course.end = datetime.now(ZoneInfo("UTC")) - timedelta(days=1)
self.course.display_name = "Omega"
self.course = self.update_course(self.course, self.user.id)
download_url = 'www.edx.org'
GeneratedCertificateFactory.create(
user=self.user,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='honor',
grade='67',
download_url=download_url
)
# LinkedIn sharing disabled
# When CERTIFICATE_LINKEDIN is set to False in site configuration,
# the LinkedIn "Add to Profile" button should not be visible to users
SITE_CONFIGURATION = {
'SOCIAL_SHARING_SETTINGS': {
'CERTIFICATE_LINKEDIN': False
}
}
with with_site_configuration_context(configuration=SITE_CONFIGURATION):
response = self.client.get(reverse('dashboard'))
assert response.status_code == 200
self.assertNotContains(response, 'Add Certificate to LinkedIn')
response_url = 'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME'
self.assertNotContains(response, escape(response_url))
@skip_unless_lms
@patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': False})
def test_linked_in_add_to_profile_btn_with_certificate(self):
# If user has a certificate with valid linked-in config then Add Certificate to LinkedIn button
# should be visible. and it has URL value with valid parameters.
self.client.login(username="jack", password=self.TEST_PASSWORD)
linkedin_config = LinkedInAddToProfileConfiguration.objects.create(company_identifier='1337', enabled=True)
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='verified',
mode_display_name='verified',
expiration_datetime=datetime.now(ZoneInfo("UTC")) - timedelta(days=1)
)
CourseEnrollment.enroll(self.user, self.course.id, mode='honor')
self.course.certificate_available_date = datetime.now(ZoneInfo("UTC")) - timedelta(days=1)
self.course.start = datetime.now(ZoneInfo("UTC")) - timedelta(days=2)
self.course.end = datetime.now(ZoneInfo("UTC")) - timedelta(days=1)
self.course.display_name = 'Omega'
self.course.course_organization = 'Omega Org'
self.course = self.update_course(self.course, self.user.id)
cert = GeneratedCertificateFactory.create(
user=self.user,
course_id=self.course.id,
status=CertificateStatuses.downloadable,
mode='honor',
grade='67',
download_url='https://www.edx.org'
)
# LinkedIn sharing disabled
# When CERTIFICATE_LINKEDIN is set to False in site configuration,
# the LinkedIn "Add to Profile" button should not be visible to users
SITE_CONFIGURATION = {
'SOCIAL_SHARING_SETTINGS': {
'CERTIFICATE_LINKEDIN': True
}
}
with with_site_configuration_context(configuration=SITE_CONFIGURATION):
response = self.client.get(reverse('dashboard'))
assert response.status_code == 200
self.assertContains(response, 'Add Certificate to LinkedIn')
expected_url = (
'https://www.linkedin.com/profile/add?startTask=CERTIFICATION_NAME&'
'name={platform}+Honor+Code+Certificate+for+Omega&'
'certUrl={cert_url}&'
'organizationId={company_identifier}'
).format(
platform=quote(settings.PLATFORM_NAME.encode('utf-8')),
cert_url=quote(cert.download_url, safe=''),
company_identifier=linkedin_config.company_identifier,
)
# Single assertion for the expected LinkedIn URL
self.assertContains(response, escape(expected_url))
@skip_unless_lms
def test_dashboard_metadata_caching(self):
"""
Check that the student dashboard makes use of course metadata caching.
After creating a course, that course's metadata should be cached as a
CourseOverview. The student dashboard should never have to make calls to
the modulestore.
Note to future developers:
If you break this test so that the "check_mongo_calls(0)" fails,
please do NOT change it to "check_mongo_calls(n>=1)". Instead, change
your code to not load courses from the module store. This may
involve adding fields to CourseOverview so that loading a full
CourseBlock isn't necessary.
"""
# Create a course and log in the user.
# Creating a new course will trigger a publish event and the course will be cached
test_course = CourseFactory.create(emit_signals=True)
self.client.login(username="jack", password=self.TEST_PASSWORD)
with check_mongo_calls(0):
CourseEnrollment.enroll(self.user, test_course.id)
# Subsequent requests will only result in SQL queries to load the
# CourseOverview object that has been created.
with check_mongo_calls(0):
response_1 = self.client.get(reverse('dashboard'))
assert response_1.status_code == 200
response_2 = self.client.get(reverse('dashboard'))
assert response_2.status_code == 200
@skip_unless_lms
def test_dashboard_header_nav_has_find_courses(self):
self.client.login(username="jack", password=self.TEST_PASSWORD)
response = self.client.get(reverse("dashboard"))
# "Explore courses" is shown in the side panel
self.assertContains(response, "Explore courses")
# But other links are hidden in the navigation
self.assertNotContains(response, "How it Works")
self.assertNotContains(response, "Schools & Partners")
def test_course_mode_info_with_honor_enrollment(self):
"""It will be true only if enrollment mode is honor and course has verified mode."""
course_mode_info = self._enrollment_with_complete_course('honor')
assert course_mode_info['show_upsell']
assert course_mode_info['days_for_upsell'] == 1
@ddt.data('verified', 'credit')
def test_course_mode_info_with_different_enrollments(self, enrollment_mode):
"""If user enrollment mode is either verified or credit then show_upsell
will be always false.
"""
course_mode_info = self._enrollment_with_complete_course(enrollment_mode)
assert not course_mode_info['show_upsell']
assert course_mode_info['days_for_upsell'] is None
def _enrollment_with_complete_course(self, enrollment_mode):
""""Dry method for course enrollment."""
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='verified',
mode_display_name='Verified',
expiration_datetime=datetime.now(ZoneInfo("UTC")) + timedelta(days=1)
)
enrollment = CourseEnrollment.enroll(self.user, self.course.id, mode=enrollment_mode)
return complete_course_mode_info(self.course.id, enrollment)
@ddt.ddt
class DashboardTestsWithSiteOverrides(SiteMixin, ModuleStoreTestCase):
"""
Tests for site settings overrides used when rendering the dashboard view
"""
def setUp(self):
super().setUp()
self.org = 'fakeX'
self.course = CourseFactory.create(org=self.org)
self.user = UserFactory.create(username='jack', email='jack@fake.edx.org', password=self.TEST_PASSWORD)
CourseModeFactory.create(mode_slug='no-id-professional', course_id=self.course.id)
CourseEnrollment.enroll(self.user, self.course.location.course_key, mode='no-id-professional')
cache.clear()
@skip_unless_lms
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_VERIFIED_CERTIFICATES': False})
@ddt.data(
('testserver1.com', {'ENABLE_VERIFIED_CERTIFICATES': True}),
('testserver2.com', {'ENABLE_VERIFIED_CERTIFICATES': True, 'DISPLAY_COURSE_MODES_ON_DASHBOARD': True}),
)
@ddt.unpack
def test_course_mode_visible(self, site_domain, site_configuration_values):
"""
Test that the course mode for courses is visible on the dashboard
when settings have been overridden by site configuration.
"""
site_configuration_values.update({
'SITE_NAME': site_domain,
'course_org_filter': self.org
})
self.set_up_site(site_domain, site_configuration_values)
self.client.login(username='jack', password=self.TEST_PASSWORD)
response = self.client.get(reverse('dashboard'))
self.assertContains(response, 'class="course professional"')
@skip_unless_lms
@patch.dict("django.conf.settings.FEATURES", {'ENABLE_VERIFIED_CERTIFICATES': False})
@ddt.data(
('testserver3.com', {'ENABLE_VERIFIED_CERTIFICATES': False}),
('testserver4.com', {'DISPLAY_COURSE_MODES_ON_DASHBOARD': False}),
)
@ddt.unpack
def test_course_mode_invisible(self, site_domain, site_configuration_values):
"""
Test that the course mode for courses is invisible on the dashboard
when settings have been overridden by site configuration.
"""
site_configuration_values.update({
'SITE_NAME': site_domain,
'course_org_filter': self.org
})
self.set_up_site(site_domain, site_configuration_values)
self.client.login(username='jack', password=self.TEST_PASSWORD)
response = self.client.get(reverse('dashboard'))
self.assertNotContains(response, 'class="course professional"')
class UserSettingsEventTestMixin(EventTestMixin):
"""
Mixin for verifying that user setting events were emitted during a test.
"""
def setUp(self): # lint-amnesty, pylint: disable=arguments-differ
super().setUp('common.djangoapps.util.model_utils.tracker')
def assert_user_setting_event_emitted(self, **kwargs):
"""
Helper method to assert that we emit the expected user settings events.
Expected settings are passed in via `kwargs`.
"""
if 'truncated' not in kwargs:
kwargs['truncated'] = []
self.assert_event_emitted(
USER_SETTINGS_CHANGED_EVENT_NAME,
table=self.table,
user_id=self.user.id,
**kwargs
)
def assert_user_enrollment_occurred(self, course_key):
"""
Helper method to assert that the user is enrolled in the given course.
"""
assert CourseEnrollment.is_enrolled(self.user, CourseKey.from_string(course_key))
class EnrollmentEventTestMixin(EventTestMixin):
""" Mixin with assertions for validating enrollment events. """
def setUp(self): # lint-amnesty, pylint: disable=arguments-differ
super().setUp('common.djangoapps.student.models.course_enrollment.tracker')
segment_patcher = patch('common.djangoapps.student.models.course_enrollment.segment')
self.mock_segment_tracker = segment_patcher.start()
self.addCleanup(segment_patcher.stop)
def assert_enrollment_mode_change_event_was_emitted(self, user, course_key, mode, course, enrollment):
"""Ensures an enrollment mode change event was emitted"""
self.mock_tracker.emit.assert_called_once_with(
'edx.course.enrollment.mode_changed',
{
'course_id': str(course_key),
'user_id': user.pk,
'mode': mode
}
)
self.mock_tracker.reset_mock()
properties, traits = self._build_segment_properties_and_traits(user, course_key, course, enrollment)
self.mock_segment_tracker.track.assert_called_once_with(
user.id, 'edx.course.enrollment.mode_changed', properties, traits=traits
)
self.mock_segment_tracker.reset_mock()
def assert_enrollment_event_was_emitted(self, user, course_key, course, enrollment):
"""Ensures an enrollment event was emitted since the last event related assertion"""
self.mock_tracker.emit.assert_called_once_with(
'edx.course.enrollment.activated',
{
'course_id': str(course_key),
'user_id': user.pk,
'mode': CourseMode.DEFAULT_MODE_SLUG
}
)
self.mock_tracker.reset_mock()
properties, traits = self._build_segment_properties_and_traits(user, course_key, course, enrollment, True)
self.mock_segment_tracker.track.assert_called_once_with(
user.id, 'edx.course.enrollment.activated', properties, traits=traits
)
self.mock_segment_tracker.reset_mock()
def assert_unenrollment_event_was_emitted(self, user, course_key, course, enrollment):
"""Ensures an unenrollment event was emitted since the last event related assertion"""
self.mock_tracker.emit.assert_called_once_with(
'edx.course.enrollment.deactivated',
{
'course_id': str(course_key),
'user_id': user.pk,
'mode': CourseMode.DEFAULT_MODE_SLUG
}
)
self.mock_tracker.reset_mock()
properties, traits = self._build_segment_properties_and_traits(user, course_key, course, enrollment)
self.mock_segment_tracker.track.assert_called_once_with(
user.id, 'edx.course.enrollment.deactivated', properties, traits=traits
)
self.mock_segment_tracker.reset_mock()
def _build_segment_properties_and_traits(self, user, course_key, course, enrollment, activated=False):
""" Builds the segment properties and traits that are sent during enrollment events """
properties = {
'category': 'conversion',
'label': str(course_key),
'org': course_key.org,
'course': course_key.course,
'run': course_key.run,
'mode': enrollment.mode,
}
traits = properties.copy()
traits.update({'course_title': course.display_name, 'email': user.email})
if activated:
properties.update({
'email': user.email,
# This next property is for an experiment, see method's comments for more information
# we will just hardcode the default value while the experiment runs
'external_course_updates': -1,
'course_start': course.start,
'course_pacing': course.pacing,
})
return properties, traits
class EnrollInCourseTest(EnrollmentEventTestMixin, CacheIsolationTestCase):
"""Tests enrolling and unenrolling in courses."""
@skip_unless_lms
def test_enrollment(self):
user = UserFactory.create(username="joe", email="joe@joe.com", password="password")
course_id = CourseKey.from_string("edX/Test101/2013")
course_id_partial = CourseKey.from_string("edX/Test101/")
course = CourseOverviewFactory.create(id=course_id)
# Test basic enrollment
assert not CourseEnrollment.is_enrolled(user, course_id)
assert not CourseEnrollment.is_enrolled_by_partial(user, course_id_partial)
enrollment = CourseEnrollment.enroll(user, course_id)
assert CourseEnrollment.is_enrolled(user, course_id)
assert CourseEnrollment.is_enrolled_by_partial(user, course_id_partial)
self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment)
# Enrolling them again should be harmless
enrollment = CourseEnrollment.enroll(user, course_id)
assert CourseEnrollment.is_enrolled(user, course_id)
assert CourseEnrollment.is_enrolled_by_partial(user, course_id_partial)
self.assert_no_events_were_emitted()
# Now unenroll the user
CourseEnrollment.unenroll(user, course_id)
assert not CourseEnrollment.is_enrolled(user, course_id)
assert not CourseEnrollment.is_enrolled_by_partial(user, course_id_partial)
self.assert_unenrollment_event_was_emitted(user, course_id, course, enrollment)
# Unenrolling them again should also be harmless
CourseEnrollment.unenroll(user, course_id)
assert not CourseEnrollment.is_enrolled(user, course_id)
assert not CourseEnrollment.is_enrolled_by_partial(user, course_id_partial)
self.assert_no_events_were_emitted()
# The enrollment record should still exist, just be inactive
enrollment_record = CourseEnrollment.objects.get(
user=user,
course_id=course_id
)
assert not enrollment_record.is_active
# Make sure mode is updated properly if user unenrolls & re-enrolls
enrollment = CourseEnrollment.enroll(user, course_id, "verified")
assert enrollment.mode == 'verified'
CourseEnrollment.unenroll(user, course_id)
enrollment = CourseEnrollment.enroll(user, course_id, "audit")
assert CourseEnrollment.is_enrolled(user, course_id)
assert enrollment.mode == 'audit'
def test_enrollment_non_existent_user(self):
# Testing enrollment of newly unsaved user (i.e. no database entry)
user = UserFactory(username="rusty", email="rusty@fake.edx.org")
course_id = CourseLocator("edX", "Test101", "2013")
course = CourseOverviewFactory.create(id=course_id)
assert not CourseEnrollment.is_enrolled(user, course_id)
# Unenroll does nothing
CourseEnrollment.unenroll(user, course_id)
self.assert_no_events_were_emitted()
# Implicit save() happens on new User object when enrolling, so this
# should still work
enrollment = CourseEnrollment.enroll(user, course_id)
assert CourseEnrollment.is_enrolled(user, course_id)
self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment)
@skip_unless_lms
def test_enrollment_by_email(self):
user = UserFactory.create(username="jack", email="jack@fake.edx.org")
course_id = CourseLocator("edX", "Test101", "2013")
course = CourseOverviewFactory.create(id=course_id)
enrollment = CourseEnrollment.enroll_by_email("jack@fake.edx.org", course_id)
assert CourseEnrollment.is_enrolled(user, course_id)
self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment)
# This won't throw an exception, even though the user is not found
assert CourseEnrollment.enroll_by_email('not_jack@fake.edx.org', course_id) is None
self.assert_no_events_were_emitted()
self.assertRaises(
User.DoesNotExist,
CourseEnrollment.enroll_by_email,
"not_jack@fake.edx.org",
course_id,
ignore_errors=False
)
self.assert_no_events_were_emitted()
# Now unenroll them by email
CourseEnrollment.unenroll_by_email("jack@fake.edx.org", course_id)
assert not CourseEnrollment.is_enrolled(user, course_id)
self.assert_unenrollment_event_was_emitted(user, course_id, course, enrollment)
# Harmless second unenroll
CourseEnrollment.unenroll_by_email("jack@fake.edx.org", course_id)
assert not CourseEnrollment.is_enrolled(user, course_id)
self.assert_no_events_were_emitted()
# Unenroll on non-existent user shouldn't throw an error
CourseEnrollment.unenroll_by_email("not_jack@fake.edx.org", course_id)
self.assert_no_events_were_emitted()
@skip_unless_lms
def test_enrollment_multiple_classes(self):
user = UserFactory(username="rusty", email="rusty@fake.edx.org")
course_id1 = CourseLocator("edX", "Test101", "2013")
course_id2 = CourseLocator("MITx", "6.003z", "2012")
course1 = CourseOverviewFactory.create(id=course_id1)
course2 = CourseOverviewFactory.create(id=course_id2)
enrollment1 = CourseEnrollment.enroll(user, course_id1)
self.assert_enrollment_event_was_emitted(user, course_id1, course1, enrollment1)
enrollment2 = CourseEnrollment.enroll(user, course_id2)
self.assert_enrollment_event_was_emitted(user, course_id2, course2, enrollment2)
assert CourseEnrollment.is_enrolled(user, course_id1)
assert CourseEnrollment.is_enrolled(user, course_id2)
CourseEnrollment.unenroll(user, course_id1)
self.assert_unenrollment_event_was_emitted(user, course_id1, course1, enrollment1)
assert not CourseEnrollment.is_enrolled(user, course_id1)
assert CourseEnrollment.is_enrolled(user, course_id2)
CourseEnrollment.unenroll(user, course_id2)
self.assert_unenrollment_event_was_emitted(user, course_id2, course2, enrollment2)
assert not CourseEnrollment.is_enrolled(user, course_id1)
assert not CourseEnrollment.is_enrolled(user, course_id2)
@skip_unless_lms
def test_activation(self):
user = UserFactory.create(username="jack", email="jack@fake.edx.org")
course_id = CourseLocator("edX", "Test101", "2013")
course = CourseOverviewFactory.create(id=course_id)
assert not CourseEnrollment.is_enrolled(user, course_id)
# Creating an enrollment doesn't actually enroll a student
# (calling CourseEnrollment.enroll() would have)
enrollment = CourseEnrollment.get_or_create_enrollment(user, course_id)
assert not CourseEnrollment.is_enrolled(user, course_id)
self.assert_no_events_were_emitted()
# Until you explicitly activate it
enrollment.activate()
assert CourseEnrollment.is_enrolled(user, course_id)
self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment)
# Activating something that's already active does nothing
enrollment.activate()
assert CourseEnrollment.is_enrolled(user, course_id)
self.assert_no_events_were_emitted()
# Now deactivate
enrollment.deactivate()
assert not CourseEnrollment.is_enrolled(user, course_id)
self.assert_unenrollment_event_was_emitted(user, course_id, course, enrollment)
# Deactivating something that's already inactive does nothing
enrollment.deactivate()
assert not CourseEnrollment.is_enrolled(user, course_id)
self.assert_no_events_were_emitted()
# A deactivated enrollment should be activated if enroll() is called
# for that user/course_id combination
CourseEnrollment.enroll(user, course_id)
assert CourseEnrollment.is_enrolled(user, course_id)
self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment)
def test_change_enrollment_modes(self):
user = UserFactory.create(username="justin", email="jh@fake.edx.org")
course_id = CourseLocator("edX", "Test101", "2013")
course = CourseOverviewFactory.create(id=course_id)
enrollment = CourseEnrollment.enroll(user, course_id, "audit")
self.assert_enrollment_event_was_emitted(user, course_id, course, enrollment)
enrollment = CourseEnrollment.enroll(user, course_id, "honor")
self.assert_enrollment_mode_change_event_was_emitted(user, course_id, "honor", course, enrollment)
# same enrollment mode does not emit an event
enrollment = CourseEnrollment.enroll(user, course_id, "honor")
self.assert_no_events_were_emitted()
enrollment = CourseEnrollment.enroll(user, course_id, "audit")
self.assert_enrollment_mode_change_event_was_emitted(user, course_id, "audit", course, enrollment)
@skip_unless_lms
@ddt.ddt
class ChangeEnrollmentViewTest(ModuleStoreTestCase):
"""Tests the student.views.change_enrollment view"""
def setUp(self):
super().setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create(password=self.TEST_PASSWORD)
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
self.url = reverse('change_enrollment')
def _enroll_through_view(self, course):
""" Enroll a student in a course. """
response = self.client.post(
reverse('change_enrollment'), {
'course_id': course.id,
'enrollment_action': 'enroll'
}
)
return response
def test_enrollment_url_without_redirect(self):
with override_waffle_switch(REDIRECT_TO_COURSEWARE_AFTER_ENROLLMENT, False):
response = self._enroll_through_view(self.course)
assert response.content.decode('utf8') == ''
def test_enrollment_with_redirect(self):
with override_waffle_switch(REDIRECT_TO_COURSEWARE_AFTER_ENROLLMENT, True):
response = self._enroll_through_view(self.course)
data = make_learning_mfe_courseware_url(self.course.id)
assert response.content.decode('utf8') == data
def test_enroll_as_default(self):
"""Tests that a student can successfully enroll through this view"""
response = self._enroll_through_view(self.course)
assert response.status_code == 200
enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(
self.user, self.course.id
)
assert is_active
assert enrollment_mode == CourseMode.DEFAULT_MODE_SLUG
def test_cannot_enroll_if_already_enrolled(self):
"""
Tests that a student will not be able to enroll through this view if
they are already enrolled in the course
"""
CourseEnrollment.enroll(self.user, self.course.id)
assert CourseEnrollment.is_enrolled(self.user, self.course.id)
# now try to enroll that student
response = self._enroll_through_view(self.course)
assert response.status_code == 400
def test_change_to_default_if_verified(self):
"""
Tests that a student that is a currently enrolled verified student cannot
accidentally change their enrollment mode
"""
CourseEnrollment.enroll(self.user, self.course.id, mode='verified')
assert CourseEnrollment.is_enrolled(self.user, self.course.id)
# now try to enroll the student in the default mode:
response = self._enroll_through_view(self.course)
assert response.status_code == 400
enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(
self.user, self.course.id
)
assert is_active
assert enrollment_mode == 'verified'
def test_change_to_default_if_verified_not_active(self):
"""
Tests that one can renroll for a course if one has already unenrolled
"""
# enroll student
CourseEnrollment.enroll(self.user, self.course.id, mode='verified')
# now unenroll student:
CourseEnrollment.unenroll(self.user, self.course.id)
# check that they are verified but inactive
enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(
self.user, self.course.id
)
assert not is_active
assert enrollment_mode == 'verified'
# now enroll them through the view:
response = self._enroll_through_view(self.course)
assert response.status_code == 200
enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(
self.user, self.course.id
)
assert is_active
assert enrollment_mode == CourseMode.DEFAULT_MODE_SLUG
class AnonymousLookupTable(ModuleStoreTestCase):
"""
Tests for anonymous_id_functions
"""
def setUp(self):
super().setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create()
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='honor',
mode_display_name='Honor Code',
)
self.user2 = UserFactory.create()
patcher = patch('common.djangoapps.student.models.course_enrollment.tracker')
patcher.start()
self.addCleanup(patcher.stop)
def test_same_user_over_multiple_sessions(self):
"""
Anonymous ids are stored in AnonymousUserId model.
This tests to make sure stored value is used rather than a creating a new one
"""
anonymous_id_1 = anonymous_id_for_user(self.user, None)
delattr(self.user, "_anonymous_id") # pylint: disable=literal-used-as-attribute
anonymous_id_2 = anonymous_id_for_user(self.user, None)
assert anonymous_id_1 == anonymous_id_2
def test_diff_anonymous_id_for_diff_users(self):
anonymous_id_1 = anonymous_id_for_user(self.user, None)
anonymous_id_2 = anonymous_id_for_user(self.user2, None)
assert anonymous_id_1 != anonymous_id_2
def test_for_unregistered_user(self): # same path as for logged out user
assert anonymous_id_for_user(AnonymousUser(), self.course.id) is None
assert user_by_anonymous_id(None) is None
def test_roundtrip_for_logged_user(self):
CourseEnrollment.enroll(self.user, self.course.id)
anonymous_id = anonymous_id_for_user(self.user, self.course.id)
real_user = user_by_anonymous_id(anonymous_id)
assert self.user == real_user
assert anonymous_id == anonymous_id_for_user(self.user, self.course.id)
def test_roundtrip_with_unicode_course_id(self):
course2 = CourseFactory.create(display_name="Omega Course Ω")
CourseEnrollment.enroll(self.user, course2.id)
anonymous_id = anonymous_id_for_user(self.user, course2.id)
real_user = user_by_anonymous_id(anonymous_id)
assert self.user == real_user
assert anonymous_id == anonymous_id_for_user(self.user, course2.id)
def test_anonymous_id_secret_key_changes_do_not_change_existing_anonymous_ids(self):
"""Test that a same anonymous id is returned when the SECRET_KEY changes."""
CourseEnrollment.enroll(self.user, self.course.id)
anonymous_id = anonymous_id_for_user(self.user, self.course.id)
with override_settings(SECRET_KEY='some_new_and_totally_secret_key'):
# Recreate user object to clear cached anonymous id.
self.user = User.objects.get(pk=self.user.id)
new_anonymous_id = anonymous_id_for_user(self.user, self.course.id)
assert anonymous_id == new_anonymous_id
assert self.user == user_by_anonymous_id(anonymous_id)
assert self.user == user_by_anonymous_id(new_anonymous_id)
def test_anonymous_id_secret_key_changes_result_in_diff_values_for_same_new_user(self):
"""Test that a different anonymous id is returned when the SECRET_KEY changes."""
CourseEnrollment.enroll(self.user, self.course.id)
anonymous_id = anonymous_id_for_user(self.user, self.course.id)
with override_settings(SECRET_KEY='some_new_and_totally_secret_key'):
# Recreate user object to clear cached anonymous id.
self.user = User.objects.get(pk=self.user.id)
AnonymousUserId.objects.filter(user=self.user).filter(course_id=self.course.id).delete()
new_anonymous_id = anonymous_id_for_user(self.user, self.course.id)
assert anonymous_id != new_anonymous_id
assert self.user == user_by_anonymous_id(new_anonymous_id)
@skip_unless_lms
@patch('openedx.core.djangoapps.programs.utils.get_programs')
class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
"""Tests verifying that related programs appear on the course dashboard."""
maxDiff = None
related_programs_preface = 'Related Programs'
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user = UserFactory(password=cls.TEST_PASSWORD)
cls.course = CourseFactory()
cls.enrollment = CourseEnrollmentFactory(user=cls.user, course_id=cls.course.id) # pylint: disable=no-member
def setUp(self):
super().setUp()
self.url = reverse('dashboard')
self.create_programs_config()
self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
course_run = CourseRunFactory(key=str(self.course.id)) # pylint: disable=no-member
course = CatalogCourseFactory(course_runs=[course_run])
self.programs = [ProgramFactory(courses=[course]) for __ in range(2)]
def assert_related_programs(self, response, are_programs_present=True):
"""Assertion for verifying response contents."""
assertion = getattr(self, 'assert{}Contains'.format('' if are_programs_present else 'Not'))
for program in self.programs:
assertion(response, self.expected_link_text(program))
assertion(response, self.related_programs_preface)
def expected_link_text(self, program):
"""Construct expected dashboard link text."""
return '{title} {type}'.format(title=program['title'], type=program['type'])
def test_related_programs_listed(self, mock_get_programs):
"""Verify that related programs are listed when available."""
mock_get_programs.return_value = self.programs
response = self.client.get(self.url)
self.assert_related_programs(response)
def test_no_data_no_programs(self, mock_get_programs):
"""Verify that related programs aren't listed when none are available."""
mock_get_programs.return_value = []
response = self.client.get(self.url)
self.assert_related_programs(response, are_programs_present=False)
def test_unrelated_program_not_listed(self, mock_get_programs):
"""Verify that unrelated programs don't appear in the listing."""
nonexistent_course_run_id = generate_course_run_key()
course_run = CourseRunFactory(key=nonexistent_course_run_id)
course = CatalogCourseFactory(course_runs=[course_run])
unrelated_program = ProgramFactory(courses=[course])
mock_get_programs.return_value = self.programs + [unrelated_program]
response = self.client.get(self.url)
self.assert_related_programs(response)
self.assertNotContains(response, unrelated_program['title'])
def test_program_title_unicode(self, mock_get_programs):
"""Verify that the dashboard can deal with programs whose titles contain Unicode."""
self.programs[0]['title'] = 'Bases matemáticas para estudiar ingeniería'
mock_get_programs.return_value = self.programs
response = self.client.get(self.url)
self.assert_related_programs(response)
class UserAttributeTests(TestCase):
"""Tests for the UserAttribute model."""
def setUp(self):
super().setUp()
self.user = UserFactory()
self.name = 'test'
self.value = 'test-value'
def test_get_set_attribute(self):
assert UserAttribute.get_user_attribute(self.user, self.name) is None
UserAttribute.set_user_attribute(self.user, self.name, self.value)
assert UserAttribute.get_user_attribute(self.user, self.name) == self.value
new_value = 'new_value'
UserAttribute.set_user_attribute(self.user, self.name, new_value)
assert UserAttribute.get_user_attribute(self.user, self.name) == new_value
def test_unicode(self):
UserAttribute.set_user_attribute(self.user, self.name, self.value)
for field in (self.name, self.value, self.user.username):
assert field in str(UserAttribute.objects.get(user=self.user))