Merge pull request #20460 from edx/zhancock/complete-waiting-enrollments

complete waiting enrollments
This commit is contained in:
Zachary Hancock
2019-05-10 10:57:39 -04:00
committed by GitHub
8 changed files with 367 additions and 27 deletions

View File

@@ -16,10 +16,11 @@ from rest_framework import status
from rest_framework.test import APITestCase
from six import text_type
from course_modes.models import CourseMode
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory
from lms.djangoapps.program_enrollments.api.v1.constants import CourseEnrollmentResponseStatuses as CourseStatuses
from lms.djangoapps.program_enrollments.models import ProgramEnrollment, ProgramCourseEnrollment
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL
from openedx.core.djangoapps.catalog.tests.factories import (
CourseFactory,
@@ -28,7 +29,7 @@ from openedx.core.djangoapps.catalog.tests.factories import (
)
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangolib.testing.utils import CacheIsolationMixin
from .factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
from student.tests.factories import UserFactory, CourseEnrollmentFactory
class ListViewTestMixin(object):
@@ -310,6 +311,7 @@ class BaseCourseEnrollmentTestsMixin(ProgramCacheTestCaseMixin):
course_enrollment = CourseEnrollmentFactory.create(
course_id=self.course_key,
user=program_enrollment.user,
mode=CourseMode.MASTERS
)
course_enrollment.is_active = course_status == "active"
course_enrollment.save()
@@ -324,7 +326,7 @@ class BaseCourseEnrollmentTestsMixin(ProgramCacheTestCaseMixin):
program_enrollment = self.create_program_enrollment(external_user_key, user)
return self.create_program_course_enrollment(program_enrollment, course_status=course_status)
def assert_program_course_enrollment(self, external_user_key, expected_status, has_user):
def assert_program_course_enrollment(self, external_user_key, expected_status, has_user, mode=CourseMode.MASTERS):
"""
Convenience method to assert that a ProgramCourseEnrollment exists,
and potentially that a CourseEnrollment also exists
@@ -340,6 +342,7 @@ class BaseCourseEnrollmentTestsMixin(ProgramCacheTestCaseMixin):
self.assertIsNotNone(course_enrollment)
self.assertEqual(expected_status == "active", course_enrollment.is_active)
self.assertEqual(self.course_key, course_enrollment.course_id)
self.assertEqual(mode, course_enrollment.mode)
else:
self.assertIsNone(course_enrollment)
@@ -452,13 +455,44 @@ class CourseEnrollmentPostTests(BaseCourseEnrollmentTestsMixin, APITestCase):
self.assert_program_course_enrollment("learner-3", "active", False)
self.assert_program_course_enrollment("learner-4", "inactive", False)
def test_user_already_enrolled_in_course(self):
def test_program_course_enrollment_exists(self):
"""
The program enrollments application already has a program_course_enrollment
record for this user and course
"""
self.create_program_and_course_enrollments('learner-1')
post_data = [self.learner_enrollment("learner-1")]
response = self.request(self.default_url, post_data)
self.assertEqual(422, response.status_code)
self.assertDictEqual({'learner-1': CourseStatuses.CONFLICT}, response.data)
def test_user_currently_enrolled_in_course(self):
"""
If a user is already enrolled in a course through a different method
that enrollment should be linked but not overwritten as masters.
"""
CourseEnrollmentFactory.create(
course_id=self.course_key,
user=self.student,
mode=CourseMode.VERIFIED
)
self.create_program_enrollment('learner-1', user=self.student)
post_data = [
self.learner_enrollment("learner-1", "active")
]
response = self.request(self.default_url, post_data)
self.assertEqual(200, response.status_code)
self.assertDictEqual(
{
"learner-1": "active"
},
response.data
)
self.assert_program_course_enrollment("learner-1", "active", True, mode=CourseMode.VERIFIED)
def test_207_multistatus(self):
self.create_program_enrollment('learner-1')
post_data = [self.learner_enrollment("learner-1"), self.learner_enrollment("learner-2")]

View File

@@ -560,7 +560,8 @@ class ProgramCourseEnrollmentsView(DeveloperErrorViewMixin, ProgramCourseRunSpec
"""
if program_course_enrollment:
return CourseEnrollmentResponseStatuses.CONFLICT
return ProgramCourseEnrollment.enroll(
return ProgramCourseEnrollment.create_program_course_enrollment(
program_enrollment,
self.course_key,
enrollment_request['status']

View File

@@ -24,3 +24,9 @@ class ProgramEnrollmentsConfig(AppConfig):
}
},
}
def ready(self):
"""
Connect handlers to signals.
"""
from . import signals # pylint: disable=unused-variable

View File

@@ -14,7 +14,7 @@ from lms.djangoapps.program_enrollments.api.v1.constants import CourseEnrollment
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import CourseKeyField
from simple_history.models import HistoricalRecords
from student.models import CourseEnrollment as StudentCourseEnrollment
from student.models import AlreadyEnrolledError, CourseEnrollment as StudentCourseEnrollment
logger = logging.getLogger(__name__) # pylint: disable=invalid-name
@@ -143,27 +143,19 @@ class ProgramCourseEnrollment(TimeStampedModel): # pylint: disable=model-missin
return '[ProgramCourseEnrollment id={}]'.format(self.id)
@classmethod
def enroll(cls, program_enrollment, course_key, status):
def create_program_course_enrollment(cls, program_enrollment, course_key, status):
"""
Create ProgramCourseEnrollment for the given course and program enrollment
"""
course_enrollment = None
if program_enrollment.user:
course_enrollment = StudentCourseEnrollment.enroll(
program_enrollment.user,
course_key,
mode=CourseMode.MASTERS,
check_access=True,
)
if status == CourseEnrollmentResponseStatuses.INACTIVE:
course_enrollment.deactivate()
program_course_enrollment = ProgramCourseEnrollment.objects.create(
program_enrollment=program_enrollment,
course_enrollment=course_enrollment,
course_key=course_key,
status=status,
)
if program_enrollment.user:
program_course_enrollment.enroll(program_enrollment.user)
return program_course_enrollment.status
def change_status(self, status):
@@ -196,3 +188,30 @@ class ProgramCourseEnrollment(TimeStampedModel): # pylint: disable=model-missin
))
self.save()
return self.status
def enroll(self, user):
"""
Create a StudentCourseEnrollment to enroll user in course
"""
try:
self.course_enrollment = StudentCourseEnrollment.enroll(
user,
self.course_key,
mode=CourseMode.MASTERS,
check_access=True,
)
except AlreadyEnrolledError:
course_enrollment = StudentCourseEnrollment.objects.get(
user=user,
course_id=self.course_key,
)
if course_enrollment.mode == CourseMode.AUDIT or course_enrollment.mode == CourseMode.HONOR:
course_enrollment.mode = CourseMode.MASTERS
course_enrollment.save()
self.course_enrollment = course_enrollment
message = ("Attempted to create course enrollment for user={user} and course={course}"
" but an enrollment already exists. Existing enrollment will be used instead")
logger.info(message.format(user=user.id, course=self.course_key))
if self.status == CourseEnrollmentResponseStatuses.INACTIVE:
self.course_enrollment.deactivate()
self.save()

View File

@@ -0,0 +1,50 @@
"""
Signal handlers for program enrollments
"""
import logging
from django.db.models.signals import post_save
from django.dispatch import receiver
from social_django.models import UserSocialAuth
from student.models import CourseEnrollmentException
from third_party_auth.models import SAMLProviderConfig
from lms.djangoapps.program_enrollments.models import ProgramEnrollment
logger = logging.getLogger(__name__)
@receiver(post_save, sender=UserSocialAuth)
def martriculate_learner(sender, instance, **kwargs): # pylint: disable=unused-argument
"""
Post-save signal to update any waiting program enrollments with a user,
and enroll the user in any waiting course enrollments.
In most cases this will just short-circuit. Enrollments will only be updated
for a SAML provider with a linked organization.
"""
try:
user = instance.user
provider_slug, external_user_key = instance.uid.split(':')
if not SAMLProviderConfig.objects.get(slug=provider_slug).organization:
return
except (AttributeError, ValueError, SAMLProviderConfig.DoesNotExist):
return
incomplete_enrollments = ProgramEnrollment.objects.filter(
external_user_key=external_user_key
).prefetch_related('program_course_enrollments')
incomplete_enrollments.update(user=user)
for enrollment in incomplete_enrollments:
for program_course_enrollment in enrollment.program_course_enrollments.all():
try:
program_course_enrollment.enroll(user)
except CourseEnrollmentException as e:
logger.warning(
u'Failed to enroll user=%s with waiting program_course_enrollment=%s: %s',
user.id,
program_course_enrollment.id,
e,
)
raise e

View File

@@ -6,12 +6,17 @@ from __future__ import unicode_literals
from uuid import uuid4
from testfixtures import LogCapture
import ddt
from django.test import TestCase
from opaque_keys.edx.keys import CourseKey
from course_modes.models import CourseMode
from edx_django_utils.cache import RequestCache
from lms.djangoapps.program_enrollments.models import ProgramEnrollment, ProgramCourseEnrollment
from student.models import CourseEnrollment
from student.tests.factories import UserFactory, CourseEnrollmentFactory
from openedx.core.djangoapps.catalog.tests.factories import generate_course_run_key
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
class ProgramEnrollmentModelTests(TestCase):
@@ -102,6 +107,7 @@ class ProgramEnrollmentModelTests(TestCase):
self.assertEquals(record.external_user_key, None)
@ddt.ddt
class ProgramCourseEnrollmentModelTests(TestCase):
"""
Tests for the ProgramCourseEnrollment model.
@@ -111,6 +117,7 @@ class ProgramCourseEnrollmentModelTests(TestCase):
Set up test data
"""
super(ProgramCourseEnrollmentModelTests, self).setUp()
RequestCache.clear_all_namespaces()
self.user = UserFactory.create()
self.program_uuid = uuid4()
self.program_enrollment = ProgramEnrollment.objects.create(
@@ -121,21 +128,37 @@ class ProgramCourseEnrollmentModelTests(TestCase):
status='enrolled'
)
self.course_key = CourseKey.from_string(generate_course_run_key())
self.course_enrollment = CourseEnrollmentFactory.create(
CourseOverviewFactory(id=self.course_key)
def _create_completed_program_course_enrollment(self):
""" helper function create program course enrollment """
course_enrollment = CourseEnrollmentFactory.create(
course_id=self.course_key,
user=self.user,
mode=CourseMode.MASTERS
)
self.program_course_enrollment = ProgramCourseEnrollment.objects.create(
program_course_enrollment = ProgramCourseEnrollment.objects.create(
program_enrollment=self.program_enrollment,
course_key=self.course_key,
course_enrollment=self.course_enrollment,
course_enrollment=course_enrollment,
status="active"
)
return program_course_enrollment
def _create_waiting_program_course_enrollment(self):
""" helper function create program course enrollment with no lms user """
return ProgramCourseEnrollment.objects.create(
program_enrollment=self.program_enrollment,
course_key=self.course_key,
course_enrollment=None,
status="active"
)
def test_change_status_no_enrollment(self):
program_course_enrollment = self._create_completed_program_course_enrollment()
with LogCapture() as capture:
self.program_course_enrollment.course_enrollment = None
self.program_course_enrollment.change_status("inactive")
program_course_enrollment.course_enrollment = None
program_course_enrollment.change_status("inactive")
expected_message = "User {} {} {} has no course_enrollment".format(
self.user,
self.program_enrollment,
@@ -146,12 +169,43 @@ class ProgramCourseEnrollmentModelTests(TestCase):
)
def test_change_status_not_active_or_inactive(self):
program_course_enrollment = self._create_completed_program_course_enrollment()
with LogCapture() as capture:
status = "potential-future-status-0123"
self.program_course_enrollment.change_status(status)
program_course_enrollment.change_status(status)
message = ("Changed {} status to {}, not changing course_enrollment"
" status because status is not 'active' or 'inactive'")
expected_message = message.format(self.program_course_enrollment, status)
expected_message = message.format(program_course_enrollment, status)
capture.check(
('lms.djangoapps.program_enrollments.models', 'WARNING', expected_message)
)
def test_enroll_new_course_enrollment(self):
program_course_enrollment = self._create_waiting_program_course_enrollment()
program_course_enrollment.enroll(self.user)
course_enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_key)
self.assertEqual(course_enrollment.user, self.user)
self.assertEqual(course_enrollment.course.id, self.course_key)
self.assertEqual(course_enrollment.mode, CourseMode.MASTERS)
@ddt.data(
(CourseMode.VERIFIED, CourseMode.VERIFIED),
(CourseMode.AUDIT, CourseMode.MASTERS),
(CourseMode.HONOR, CourseMode.MASTERS)
)
@ddt.unpack
def test_enroll_existing_course_enrollment(self, original_mode, result_mode):
course_enrollment = CourseEnrollmentFactory.create(
course_id=self.course_key,
user=self.user,
mode=original_mode
)
program_course_enrollment = self._create_waiting_program_course_enrollment()
program_course_enrollment.enroll(self.user)
course_enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_key)
self.assertEqual(course_enrollment.user, self.user)
self.assertEqual(course_enrollment.course.id, self.course_key)
self.assertEqual(course_enrollment.mode, result_mode)

View File

@@ -0,0 +1,176 @@
"""
Unit tests for completing program course enrollments
once a social auth entry for the user is created.
"""
from django.test import TestCase
import mock
from opaque_keys.edx.keys import CourseKey
import pytest
from social_django.models import UserSocialAuth
from testfixtures import LogCapture
from course_modes.models import CourseMode
from edx_django_utils.cache import RequestCache
from lms.djangoapps.program_enrollments.signals import logger
from lms.djangoapps.program_enrollments.tests.factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory
from organizations.tests.factories import OrganizationFactory
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from student.models import CourseEnrollmentException
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from third_party_auth.tests.factories import SAMLProviderConfigFactory
class SocialAuthEnrollmentCompletionSignalTest(TestCase):
"""
Test post-save handler on UserSocialAuth
"""
def setUp(self):
super(SocialAuthEnrollmentCompletionSignalTest, self).setUp()
RequestCache.clear_all_namespaces()
@classmethod
def setUpClass(cls):
super(SocialAuthEnrollmentCompletionSignalTest, cls).setUpClass()
cls.external_id = '0000'
cls.provider_slug = 'uox'
cls.course_keys = [
CourseKey.from_string('course-v1:edX+DemoX+Test_Course'),
CourseKey.from_string('course-v1:edX+DemoX+Another_Test_Course'),
]
cls.organization = OrganizationFactory.create()
cls.user = UserFactory.create()
for course_key in cls.course_keys:
CourseOverviewFactory(id=course_key)
SAMLProviderConfigFactory.create(organization=cls.organization, slug=cls.provider_slug)
def _create_waiting_program_enrollment(self):
""" helper method to create a waiting program enrollment """
return ProgramEnrollmentFactory.create(
user=None,
external_user_key=self.external_id
)
def _create_waiting_course_enrollments(self, program_enrollment):
""" helper method to create waiting course enrollments """
return [
ProgramCourseEnrollmentFactory(
program_enrollment=program_enrollment,
course_enrollment=None,
course_key=course_key
)
for course_key in self.course_keys
]
def _assert_program_enrollment_user(self, program_enrollment):
""" validate program enrollment has a user """
program_enrollment.refresh_from_db()
self.assertEqual(program_enrollment.user, self.user)
def _assert_program_course_enrollment(self, program_course_enrollment, mode=CourseMode.MASTERS):
""" validate program course enrollment has a valid course enrollment """
program_course_enrollment.refresh_from_db()
student_course_enrollment = program_course_enrollment.course_enrollment
self.assertEqual(student_course_enrollment.user, self.user)
self.assertEqual(student_course_enrollment.course.id, program_course_enrollment.course_key)
self.assertEqual(student_course_enrollment.mode, mode)
def test_waiting_course_enrollments_completed(self):
program_enrollment = self._create_waiting_program_enrollment()
program_course_enrollments = self._create_waiting_course_enrollments(program_enrollment)
UserSocialAuth.objects.create(
user=self.user,
uid='{0}:{1}'.format(self.provider_slug, self.external_id)
)
self._assert_program_enrollment_user(program_enrollment)
for program_course_enrollment in program_course_enrollments:
self._assert_program_course_enrollment(program_course_enrollment)
def test_learner_already_enrolled_in_course(self):
course_key = self.course_keys[0]
course = CourseOverview.objects.get(id=course_key)
CourseEnrollmentFactory(user=self.user, course=course, mode=CourseMode.VERIFIED)
program_enrollment = self._create_waiting_program_enrollment()
program_course_enrollments = self._create_waiting_course_enrollments(program_enrollment)
UserSocialAuth.objects.create(
user=self.user,
uid='{0}:{1}'.format(self.provider_slug, self.external_id)
)
self._assert_program_enrollment_user(program_enrollment)
duplicate_program_course_enrollment = program_course_enrollments[0]
self._assert_program_course_enrollment(duplicate_program_course_enrollment, CourseMode.VERIFIED)
program_course_enrollment = program_course_enrollments[1]
self._assert_program_course_enrollment(program_course_enrollment)
def test_enrolled_with_no_course_enrollments(self):
program_enrollment = self._create_waiting_program_enrollment()
UserSocialAuth.objects.create(
user=self.user,
uid='{0}:{1}'.format(self.provider_slug, self.external_id)
)
self._assert_program_enrollment_user(program_enrollment)
def test_create_social_auth_with_no_waiting_enrollments(self):
"""
No exceptions should be raised if there are no enrollments to update
"""
UserSocialAuth.objects.create(
user=self.user,
uid='{0}:{1}'.format(self.provider_slug, self.external_id)
)
def test_create_social_auth_provider_has_no_organization(self):
"""
No exceptions should be raised if provider is not linked to an organization
"""
provider = SAMLProviderConfigFactory.create()
UserSocialAuth.objects.create(
user=self.user,
uid='{0}:{1}'.format(provider.slug, self.external_id)
)
def test_create_social_auth_non_saml_provider(self):
"""
No exceptions should be raised for a non-SAML uid or if a SAML provider cannot be found
"""
UserSocialAuth.objects.create(
user=self.user,
uid='google-oauth-user@gmail.com'
)
UserSocialAuth.objects.create(
user=self.user,
uid='123:123:123'
)
def test_log_on_enrollment_failure(self):
program_enrollment = self._create_waiting_program_enrollment()
program_course_enrollments = self._create_waiting_course_enrollments(program_enrollment)
with mock.patch('student.models.CourseEnrollment.enroll') as enrollMock:
enrollMock.side_effect = CourseEnrollmentException('something has gone wrong')
with LogCapture(logger.name) as log:
with pytest.raises(CourseEnrollmentException):
UserSocialAuth.objects.create(
user=self.user,
uid='{0}:{1}'.format(self.provider_slug, self.external_id)
)
error_tmpl = u'Failed to enroll user={} with waiting program_course_enrollment={}: {}'
log.check_present(
(
logger.name,
'WARNING',
error_tmpl.format(self.user.id, program_course_enrollments[0].id, 'something has gone wrong')
)
)