406 lines
18 KiB
Python
406 lines
18 KiB
Python
import datetime
|
|
import hashlib
|
|
|
|
import ddt
|
|
import factory
|
|
import pytz
|
|
from django.contrib.auth.models import AnonymousUser
|
|
from django.core.cache import cache
|
|
from django.db.models import signals
|
|
from django.db.models.functions import Lower
|
|
from django.test import TestCase
|
|
from opaque_keys.edx.keys import CourseKey
|
|
|
|
from course_modes.models import CourseMode
|
|
from course_modes.tests.factories import CourseModeFactory
|
|
from lms.djangoapps.courseware.models import DynamicUpgradeDeadlineConfiguration
|
|
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
|
from openedx.core.djangoapps.schedules.models import Schedule
|
|
from openedx.core.djangoapps.schedules.tests.factories import ScheduleFactory
|
|
from openedx.core.djangolib.testing.utils import skip_unless_lms
|
|
from student.models import (
|
|
ALLOWEDTOENROLL_TO_ENROLLED,
|
|
AccountRecovery,
|
|
CourseEnrollment,
|
|
CourseEnrollmentAllowed,
|
|
ManualEnrollmentAudit,
|
|
PendingEmailChange,
|
|
PendingNameChange
|
|
)
|
|
from student.tests.factories import AccountRecoveryFactory, CourseEnrollmentFactory, UserFactory
|
|
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
|
from xmodule.modulestore.tests.factories import CourseFactory
|
|
|
|
|
|
@ddt.ddt
|
|
class CourseEnrollmentTests(SharedModuleStoreTestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super(CourseEnrollmentTests, cls).setUpClass()
|
|
cls.course = CourseFactory()
|
|
|
|
def setUp(self):
|
|
super(CourseEnrollmentTests, self).setUp()
|
|
self.user = UserFactory()
|
|
self.user_2 = UserFactory()
|
|
|
|
def test_enrollment_status_hash_cache_key(self):
|
|
username = 'test-user'
|
|
user = UserFactory(username=username)
|
|
expected = 'enrollment_status_hash_' + username
|
|
self.assertEqual(CourseEnrollment.enrollment_status_hash_cache_key(user), expected)
|
|
|
|
def assert_enrollment_status_hash_cached(self, user, expected_value):
|
|
self.assertEqual(cache.get(CourseEnrollment.enrollment_status_hash_cache_key(user)), expected_value)
|
|
|
|
def test_generate_enrollment_status_hash(self):
|
|
""" Verify the method returns a hash of a user's current enrollments. """
|
|
# Return None for anonymous users
|
|
self.assertIsNone(CourseEnrollment.generate_enrollment_status_hash(AnonymousUser()))
|
|
|
|
# No enrollments
|
|
expected = hashlib.md5(self.user.username.encode('utf-8')).hexdigest()
|
|
self.assertEqual(CourseEnrollment.generate_enrollment_status_hash(self.user), expected)
|
|
self.assert_enrollment_status_hash_cached(self.user, expected)
|
|
|
|
# No active enrollments
|
|
enrollment_mode = 'verified'
|
|
course_id = self.course.id # pylint: disable=no-member
|
|
enrollment = CourseEnrollmentFactory.create(user=self.user, course_id=course_id, mode=enrollment_mode,
|
|
is_active=False)
|
|
self.assertEqual(CourseEnrollment.generate_enrollment_status_hash(self.user), expected)
|
|
self.assert_enrollment_status_hash_cached(self.user, expected)
|
|
|
|
# One active enrollment
|
|
enrollment.is_active = True
|
|
enrollment.save()
|
|
expected = '{username}&{course_id}={mode}'.format(
|
|
username=self.user.username, course_id=str(course_id).lower(), mode=enrollment_mode.lower()
|
|
)
|
|
expected = hashlib.md5(expected.encode('utf-8')).hexdigest()
|
|
self.assertEqual(CourseEnrollment.generate_enrollment_status_hash(self.user), expected)
|
|
self.assert_enrollment_status_hash_cached(self.user, expected)
|
|
|
|
# Multiple enrollments
|
|
CourseEnrollmentFactory.create(user=self.user)
|
|
enrollments = CourseEnrollment.enrollments_for_user(self.user).order_by(Lower('course_id'))
|
|
hash_elements = [self.user.username]
|
|
hash_elements += [
|
|
'{course_id}={mode}'.format(course_id=str(enrollment.course_id).lower(), mode=enrollment.mode.lower()) for
|
|
enrollment in enrollments]
|
|
expected = hashlib.md5('&'.join(hash_elements).encode('utf-8')).hexdigest()
|
|
self.assertEqual(CourseEnrollment.generate_enrollment_status_hash(self.user), expected)
|
|
self.assert_enrollment_status_hash_cached(self.user, expected)
|
|
|
|
def test_save_deletes_cached_enrollment_status_hash(self):
|
|
""" Verify the method deletes the cached enrollment status hash for the user. """
|
|
# There should be no cached value for a new user with no enrollments.
|
|
self.assertIsNone(cache.get(CourseEnrollment.enrollment_status_hash_cache_key(self.user)))
|
|
|
|
# Generating a status hash should cache the generated value.
|
|
status_hash = CourseEnrollment.generate_enrollment_status_hash(self.user)
|
|
self.assert_enrollment_status_hash_cached(self.user, status_hash)
|
|
|
|
# Modifying enrollments should delete the cached value.
|
|
CourseEnrollmentFactory.create(user=self.user)
|
|
self.assertIsNone(cache.get(CourseEnrollment.enrollment_status_hash_cache_key(self.user)))
|
|
|
|
def test_users_enrolled_in_active_only(self):
|
|
"""CourseEnrollment.users_enrolled_in should return only Users with active enrollments when
|
|
`include_inactive` has its default value (False)."""
|
|
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, is_active=True)
|
|
CourseEnrollmentFactory.create(user=self.user_2, course_id=self.course.id, is_active=False)
|
|
|
|
active_enrolled_users = list(CourseEnrollment.objects.users_enrolled_in(self.course.id))
|
|
self.assertEqual([self.user], active_enrolled_users)
|
|
|
|
def test_users_enrolled_in_all(self):
|
|
"""CourseEnrollment.users_enrolled_in should return active and inactive users when
|
|
`include_inactive` is True."""
|
|
CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, is_active=True)
|
|
CourseEnrollmentFactory.create(user=self.user_2, course_id=self.course.id, is_active=False)
|
|
|
|
all_enrolled_users = list(
|
|
CourseEnrollment.objects.users_enrolled_in(self.course.id, include_inactive=True)
|
|
)
|
|
self.assertListEqual([self.user, self.user_2], all_enrolled_users)
|
|
|
|
@skip_unless_lms
|
|
# NOTE: We mute the post_save signal to prevent Schedules from being created for new enrollments
|
|
@factory.django.mute_signals(signals.post_save)
|
|
def test_upgrade_deadline(self):
|
|
""" The property should use either the CourseMode or related Schedule to determine the deadline. """
|
|
course = CourseFactory(self_paced=True)
|
|
course_mode = CourseModeFactory(
|
|
course_id=course.id,
|
|
mode_slug=CourseMode.VERIFIED,
|
|
# This must be in the future to ensure it is returned by downstream code.
|
|
expiration_datetime=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=1)
|
|
)
|
|
enrollment = CourseEnrollmentFactory(course_id=course.id, mode=CourseMode.AUDIT)
|
|
self.assertEqual(Schedule.objects.all().count(), 0)
|
|
self.assertEqual(enrollment.upgrade_deadline, course_mode.expiration_datetime)
|
|
|
|
@skip_unless_lms
|
|
# NOTE: We mute the post_save signal to prevent Schedules from being created for new enrollments
|
|
@factory.django.mute_signals(signals.post_save)
|
|
def test_upgrade_deadline_with_schedule(self):
|
|
""" The property should use either the CourseMode or related Schedule to determine the deadline. """
|
|
course = CourseFactory(self_paced=True)
|
|
CourseModeFactory(
|
|
course_id=course.id,
|
|
mode_slug=CourseMode.VERIFIED,
|
|
# This must be in the future to ensure it is returned by downstream code.
|
|
expiration_datetime=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=30),
|
|
)
|
|
course_overview = CourseOverview.load_from_module_store(course.id)
|
|
enrollment = CourseEnrollmentFactory(
|
|
course_id=course.id,
|
|
mode=CourseMode.AUDIT,
|
|
course=course_overview,
|
|
)
|
|
|
|
# The schedule's upgrade deadline should be used if a schedule exists
|
|
DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True)
|
|
schedule = ScheduleFactory(enrollment=enrollment)
|
|
self.assertEqual(enrollment.upgrade_deadline, schedule.upgrade_deadline)
|
|
|
|
@skip_unless_lms
|
|
@ddt.data(*(set(CourseMode.ALL_MODES) - set(CourseMode.AUDIT_MODES)))
|
|
def test_upgrade_deadline_for_non_upgradeable_enrollment(self, mode):
|
|
""" The property should return None if an upgrade cannot be upgraded. """
|
|
enrollment = CourseEnrollmentFactory(course_id=self.course.id, mode=mode)
|
|
self.assertIsNone(enrollment.upgrade_deadline)
|
|
|
|
@skip_unless_lms
|
|
def test_upgrade_deadline_instructor_paced(self):
|
|
course = CourseFactory(self_paced=False)
|
|
course_upgrade_deadline = datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=1)
|
|
CourseModeFactory(
|
|
course_id=course.id,
|
|
mode_slug=CourseMode.VERIFIED,
|
|
# This must be in the future to ensure it is returned by downstream code.
|
|
expiration_datetime=course_upgrade_deadline
|
|
)
|
|
enrollment = CourseEnrollmentFactory(course_id=course.id, mode=CourseMode.AUDIT)
|
|
DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True)
|
|
ScheduleFactory(enrollment=enrollment)
|
|
self.assertIsNotNone(enrollment.schedule)
|
|
self.assertEqual(enrollment.upgrade_deadline, course_upgrade_deadline)
|
|
|
|
@skip_unless_lms
|
|
def test_upgrade_deadline_with_schedule_and_professional_mode(self):
|
|
"""
|
|
Deadline should be None for courses with professional mode.
|
|
|
|
Regression test for EDUCATOR-2419.
|
|
"""
|
|
course = CourseFactory(self_paced=True)
|
|
CourseModeFactory(
|
|
course_id=course.id,
|
|
mode_slug=CourseMode.PROFESSIONAL,
|
|
)
|
|
enrollment = CourseEnrollmentFactory(course_id=course.id, mode=CourseMode.AUDIT)
|
|
DynamicUpgradeDeadlineConfiguration.objects.create(enabled=True)
|
|
ScheduleFactory(enrollment=enrollment)
|
|
self.assertIsNotNone(enrollment.schedule)
|
|
self.assertIsNone(enrollment.upgrade_deadline)
|
|
|
|
@skip_unless_lms
|
|
# NOTE: We mute the post_save signal to prevent Schedules from being created for new enrollments
|
|
@factory.django.mute_signals(signals.post_save)
|
|
def test_enrollments_not_deleted(self):
|
|
""" Recreating a CourseOverview with an outdated version should not delete the associated enrollment. """
|
|
course = CourseFactory(self_paced=True)
|
|
CourseModeFactory(
|
|
course_id=course.id,
|
|
mode_slug=CourseMode.VERIFIED,
|
|
# This must be in the future to ensure it is returned by downstream code.
|
|
expiration_datetime=datetime.datetime.now(pytz.UTC) + datetime.timedelta(days=30),
|
|
)
|
|
|
|
# Create a CourseOverview with an outdated version
|
|
course_overview = CourseOverview.load_from_module_store(course.id)
|
|
course_overview.version = CourseOverview.VERSION - 1
|
|
course_overview.save()
|
|
|
|
# Create an inactive enrollment with this course overview
|
|
enrollment = CourseEnrollmentFactory(
|
|
user=self.user,
|
|
course_id=course.id,
|
|
mode=CourseMode.AUDIT,
|
|
course=course_overview,
|
|
)
|
|
|
|
# Re-fetch the CourseOverview record.
|
|
# As a side effect, this will recreate the record, and update the version.
|
|
course_overview_new = CourseOverview.get_from_id(course.id)
|
|
self.assertEqual(course_overview_new.version, CourseOverview.VERSION)
|
|
|
|
# Ensure that the enrollment record was unchanged during this re-creation
|
|
enrollment_refetched = CourseEnrollment.objects.filter(id=enrollment.id)
|
|
self.assertTrue(enrollment_refetched.exists())
|
|
self.assertEqual(enrollment_refetched.all()[0], enrollment)
|
|
|
|
|
|
class PendingNameChangeTests(SharedModuleStoreTestCase):
|
|
"""
|
|
Tests the deletion of PendingNameChange records
|
|
"""
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super(PendingNameChangeTests, cls).setUpClass()
|
|
cls.user = UserFactory()
|
|
cls.user2 = UserFactory()
|
|
|
|
def setUp(self):
|
|
self.name_change, _ = PendingNameChange.objects.get_or_create(
|
|
user=self.user,
|
|
new_name='New Name PII',
|
|
rationale='for testing!'
|
|
)
|
|
self.assertEqual(1, len(PendingNameChange.objects.all()))
|
|
|
|
def test_delete_by_user_removes_pending_name_change(self):
|
|
record_was_deleted = PendingNameChange.delete_by_user_value(self.user, field='user')
|
|
self.assertTrue(record_was_deleted)
|
|
self.assertEqual(0, len(PendingNameChange.objects.all()))
|
|
|
|
def test_delete_by_user_no_effect_for_user_with_no_name_change(self):
|
|
record_was_deleted = PendingNameChange.delete_by_user_value(self.user2, field='user')
|
|
self.assertFalse(record_was_deleted)
|
|
self.assertEqual(1, len(PendingNameChange.objects.all()))
|
|
|
|
|
|
class PendingEmailChangeTests(SharedModuleStoreTestCase):
|
|
"""
|
|
Tests the deletion of PendingEmailChange records.
|
|
"""
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super(PendingEmailChangeTests, cls).setUpClass()
|
|
cls.user = UserFactory()
|
|
cls.user2 = UserFactory()
|
|
|
|
def setUp(self):
|
|
self.email_change, _ = PendingEmailChange.objects.get_or_create(
|
|
user=self.user,
|
|
new_email='new@example.com',
|
|
activation_key='a' * 32
|
|
)
|
|
|
|
def test_delete_by_user_removes_pending_email_change(self):
|
|
record_was_deleted = PendingEmailChange.delete_by_user_value(self.user, field='user')
|
|
self.assertTrue(record_was_deleted)
|
|
self.assertEqual(0, len(PendingEmailChange.objects.all()))
|
|
|
|
def test_delete_by_user_no_effect_for_user_with_no_email_change(self):
|
|
record_was_deleted = PendingEmailChange.delete_by_user_value(self.user2, field='user')
|
|
self.assertFalse(record_was_deleted)
|
|
self.assertEqual(1, len(PendingEmailChange.objects.all()))
|
|
|
|
|
|
class TestCourseEnrollmentAllowed(TestCase):
|
|
|
|
def setUp(self):
|
|
super(TestCourseEnrollmentAllowed, self).setUp()
|
|
self.email = 'learner@example.com'
|
|
self.course_key = CourseKey.from_string("course-v1:edX+DemoX+Demo_Course")
|
|
self.user = UserFactory.create()
|
|
self.allowed_enrollment = CourseEnrollmentAllowed.objects.create(
|
|
email=self.email,
|
|
course_id=self.course_key,
|
|
user=self.user
|
|
)
|
|
|
|
def test_retiring_user_deletes_record(self):
|
|
is_successful = CourseEnrollmentAllowed.delete_by_user_value(
|
|
value=self.email,
|
|
field='email'
|
|
)
|
|
self.assertTrue(is_successful)
|
|
user_search_results = CourseEnrollmentAllowed.objects.filter(
|
|
email=self.email
|
|
)
|
|
self.assertFalse(user_search_results)
|
|
|
|
def test_retiring_nonexistent_user_doesnt_modify_records(self):
|
|
is_successful = CourseEnrollmentAllowed.delete_by_user_value(
|
|
value='nonexistentlearner@example.com',
|
|
field='email'
|
|
)
|
|
self.assertFalse(is_successful)
|
|
user_search_results = CourseEnrollmentAllowed.objects.filter(
|
|
email=self.email
|
|
)
|
|
self.assertTrue(user_search_results.exists())
|
|
|
|
|
|
class TestManualEnrollmentAudit(SharedModuleStoreTestCase):
|
|
"""
|
|
Tests for the ManualEnrollmentAudit model.
|
|
"""
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super(TestManualEnrollmentAudit, cls).setUpClass()
|
|
cls.course = CourseFactory()
|
|
cls.other_course = CourseFactory()
|
|
cls.user = UserFactory()
|
|
cls.instructor = UserFactory(username='staff', is_staff=True)
|
|
|
|
def test_retirement(self):
|
|
"""
|
|
Tests that calling the retirement method for a specific enrollment retires
|
|
the enrolled_email and reason columns of each row associated with that
|
|
enrollment.
|
|
"""
|
|
enrollment = CourseEnrollment.enroll(self.user, self.course.id)
|
|
other_enrollment = CourseEnrollment.enroll(self.user, self.other_course.id)
|
|
ManualEnrollmentAudit.create_manual_enrollment_audit(
|
|
self.instructor, self.user.email, ALLOWEDTOENROLL_TO_ENROLLED,
|
|
'manually enrolling unenrolled user', enrollment
|
|
)
|
|
ManualEnrollmentAudit.create_manual_enrollment_audit(
|
|
self.instructor, self.user.email, ALLOWEDTOENROLL_TO_ENROLLED,
|
|
'manually enrolling unenrolled user again', enrollment
|
|
)
|
|
ManualEnrollmentAudit.create_manual_enrollment_audit(
|
|
self.instructor, self.user.email, ALLOWEDTOENROLL_TO_ENROLLED,
|
|
'manually enrolling unenrolled user', other_enrollment
|
|
)
|
|
ManualEnrollmentAudit.create_manual_enrollment_audit(
|
|
self.instructor, self.user.email, ALLOWEDTOENROLL_TO_ENROLLED,
|
|
'manually enrolling unenrolled user again', other_enrollment
|
|
)
|
|
self.assertTrue(ManualEnrollmentAudit.objects.filter(enrollment=enrollment).exists())
|
|
# retire the ManualEnrollmentAudit objects associated with the above enrollments
|
|
ManualEnrollmentAudit.retire_manual_enrollments(user=self.user, retired_email="xxx")
|
|
self.assertTrue(ManualEnrollmentAudit.objects.filter(enrollment=enrollment).exists())
|
|
self.assertFalse(ManualEnrollmentAudit.objects.filter(enrollment=enrollment).exclude(
|
|
enrolled_email="xxx"
|
|
))
|
|
self.assertFalse(ManualEnrollmentAudit.objects.filter(enrollment=enrollment).exclude(
|
|
reason=""
|
|
))
|
|
|
|
|
|
class TestAccountRecovery(TestCase):
|
|
"""
|
|
Tests for the AccountRecovery Model
|
|
"""
|
|
|
|
def test_retire_recovery_email(self):
|
|
"""
|
|
Assert that Account Record for a given user is deleted when `retire_recovery_email` is called
|
|
"""
|
|
# Create user and associated recovery email record
|
|
user = UserFactory()
|
|
AccountRecoveryFactory(user=user)
|
|
assert len(AccountRecovery.objects.filter(user_id=user.id)) == 1
|
|
|
|
# Retire recovery email
|
|
AccountRecovery.retire_recovery_email(user_id=user.id)
|
|
|
|
# Assert that there is no longer an AccountRecovery record for this user
|
|
assert len(AccountRecovery.objects.filter(user_id=user.id)) == 0
|