diff --git a/common/djangoapps/entitlements/api/v1/tests/test_views.py b/common/djangoapps/entitlements/api/v1/tests/test_views.py index 0cd4c06e85..dca5862333 100644 --- a/common/djangoapps/entitlements/api/v1/tests/test_views.py +++ b/common/djangoapps/entitlements/api/v1/tests/test_views.py @@ -350,6 +350,20 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): self.course = CourseFactory.create(org='edX', number='DemoX', display_name='Demo_Course') self.course2 = CourseFactory.create(org='edX', number='DemoX2', display_name='Demo_Course 2') + self.course_mode = CourseModeFactory( + course_id=self.course.id, + mode_slug=CourseMode.VERIFIED, + # This must be in the future to ensure it is returned by downstream code. + expiration_datetime=now() + timedelta(days=1) + ) + + self.course_mode = CourseModeFactory( + course_id=self.course2.id, + mode_slug=CourseMode.VERIFIED, + # This must be in the future to ensure it is returned by downstream code. + expiration_datetime=now() + timedelta(days=1) + ) + self.return_values = [ {'key': str(self.course.id)}, {'key': str(self.course2.id)} @@ -357,7 +371,7 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): @patch("entitlements.api.v1.views.get_course_runs_for_course") def test_user_can_enroll(self, mock_get_course_runs): - course_entitlement = CourseEntitlementFactory.create(user=self.user) + course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED) mock_get_course_runs.return_value = self.return_values url = reverse( self.ENTITLEMENTS_ENROLLMENT_NAMESPACE, @@ -381,7 +395,7 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): @patch("entitlements.api.v1.views.get_course_runs_for_course") def test_user_can_unenroll(self, mock_get_course_runs): - course_entitlement = CourseEntitlementFactory.create(user=self.user) + course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED) mock_get_course_runs.return_value = self.return_values url = reverse( @@ -416,7 +430,7 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): @patch("entitlements.api.v1.views.get_course_runs_for_course") def test_user_can_switch(self, mock_get_course_runs): mock_get_course_runs.return_value = self.return_values - course_entitlement = CourseEntitlementFactory.create(user=self.user) + course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED) url = reverse( self.ENTITLEMENTS_ENROLLMENT_NAMESPACE, @@ -453,7 +467,7 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): @patch("entitlements.api.v1.views.get_course_runs_for_course") def test_user_already_enrolled(self, mock_get_course_runs): - course_entitlement = CourseEntitlementFactory.create(user=self.user) + course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED) mock_get_course_runs.return_value = self.return_values url = reverse( @@ -474,16 +488,13 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): assert response.status_code == 201 assert CourseEnrollment.is_enrolled(self.user, self.course.id) - - course_entitlement.refresh_from_db() - assert CourseEnrollment.is_enrolled(self.user, self.course.id) assert course_entitlement.enrollment_course_run is not None @patch("entitlements.api.v1.views.get_course_runs_for_course") def test_user_cannot_enroll_in_unknown_course_run_id(self, mock_get_course_runs): fake_course_str = str(self.course.id) + 'fake' fake_course_key = CourseKey.from_string(fake_course_str) - course_entitlement = CourseEntitlementFactory.create(user=self.user) + course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED) mock_get_course_runs.return_value = self.return_values url = reverse( @@ -508,7 +519,7 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): @patch('entitlements.api.v1.views.refund_entitlement', return_value=True) @patch('entitlements.api.v1.views.get_course_runs_for_course') def test_user_can_revoke_and_refund(self, mock_get_course_runs, mock_refund_entitlement): - course_entitlement = CourseEntitlementFactory.create(user=self.user) + course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED) mock_get_course_runs.return_value = self.return_values url = reverse( @@ -555,7 +566,7 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): mock_refund_entitlement, mock_is_refundable ): - course_entitlement = CourseEntitlementFactory.create(user=self.user) + course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED) mock_get_course_runs.return_value = self.return_values url = reverse( @@ -599,7 +610,7 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): mock_refund_entitlement, mock_is_refundable ): - course_entitlement = CourseEntitlementFactory.create(user=self.user) + course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED) mock_get_course_runs.return_value = self.return_values url = reverse( diff --git a/common/djangoapps/entitlements/api/v1/views.py b/common/djangoapps/entitlements/api/v1/views.py index 0ef9a385f2..8755dd7023 100644 --- a/common/djangoapps/entitlements/api/v1/views.py +++ b/common/djangoapps/entitlements/api/v1/views.py @@ -1,3 +1,4 @@ +import datetime import logging from django.db import IntegrityError, transaction @@ -6,6 +7,7 @@ from django_filters.rest_framework import DjangoFilterBackend from edx_rest_framework_extensions.authentication import JwtAuthentication from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey +from pytz import UTC from rest_framework import permissions, viewsets, status from rest_framework.authentication import SessionAuthentication from rest_framework.response import Response @@ -14,6 +16,7 @@ from entitlements.api.v1.filters import CourseEntitlementFilter from entitlements.api.v1.permissions import IsAdminOrAuthenticatedReadOnly from entitlements.api.v1.serializers import CourseEntitlementSerializer from entitlements.models import CourseEntitlement +from entitlements.utils import is_course_run_entitlement_fullfillable from lms.djangoapps.commerce.utils import refund_entitlement from openedx.core.djangoapps.catalog.utils import get_course_runs_for_course from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf @@ -318,9 +321,8 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): } ) - # Determine if this is a Switch session or a simple enroll and handle both. try: - course_run_string = CourseKey.from_string(course_run_id) + course_run_key = CourseKey.from_string(course_run_id) except InvalidKeyError: return Response( status=status.HTTP_400_BAD_REQUEST, @@ -328,10 +330,23 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): 'message': 'Invalid {course_id}'.format(course_id=course_run_id) } ) + + # Verify that the run is fullfillable + if not is_course_run_entitlement_fullfillable(course_run_key, entitlement): + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={ + 'message': 'The User is unable to enroll in Course Run {course_id}, it is not available.'.format( + course_id=course_run_id + ) + } + ) + + # Determine if this is a Switch session or a simple enroll and handle both. if entitlement.enrollment_course_run is None: response = self._enroll_entitlement( entitlement=entitlement, - course_run_key=course_run_string, + course_run_key=course_run_key, user=request.user ) if response: @@ -343,7 +358,7 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): ) response = self._enroll_entitlement( entitlement=entitlement, - course_run_key=course_run_string, + course_run_key=course_run_key, user=request.user ) if response: diff --git a/common/djangoapps/entitlements/tests/test_utils.py b/common/djangoapps/entitlements/tests/test_utils.py new file mode 100644 index 0000000000..8cf5874825 --- /dev/null +++ b/common/djangoapps/entitlements/tests/test_utils.py @@ -0,0 +1,118 @@ +""" +Test entitlements utilities +""" + +from datetime import timedelta + +from django.conf import settings +from django.utils.timezone import now +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + +from course_modes.models import CourseMode +from course_modes.tests.factories import CourseModeFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms +from student.tests.factories import (TEST_PASSWORD, UserFactory, CourseOverviewFactory, CourseEnrollmentFactory) + +# Entitlements is not in CMS' INSTALLED_APPS so these imports will error during test collection +if settings.ROOT_URLCONF == 'lms.urls': + from entitlements.tests.factories import CourseEntitlementFactory + from entitlements.utils import is_course_run_entitlement_fullfillable + + +@skip_unless_lms +class TestCourseRunFullfillableForEntitlement(ModuleStoreTestCase): + """ + Tests for the utility function is_course_run_entitlement_fullfillable + """ + + def setUp(self): + super(TestCourseRunFullfillableForEntitlement, self).setUp() + + self.user = UserFactory(is_staff=True) + self.client.login(username=self.user.username, password=TEST_PASSWORD) + + def create_course( + self, + start_from_now, + end_from_now, + enrollment_start_from_now, + enrollment_end_from_now, + upgraded_ended_from_now=1 + ): + course_overview = CourseOverviewFactory.create( + start=now() + timedelta(days=start_from_now), + end=now() + timedelta(days=end_from_now), + enrollment_start=now() + timedelta(days=enrollment_start_from_now), + enrollment_end=now() + timedelta(days=enrollment_end_from_now) + ) + + CourseModeFactory( + course_id=course_overview.id, + mode_slug=CourseMode.VERIFIED, + # This must be in the future to ensure it is returned by downstream code. + expiration_datetime=now() + timedelta(days=upgraded_ended_from_now) + ) + return course_overview + + def test_course_run_fullfillble(self): + course_overview = self.create_course( + start_from_now=-2, + end_from_now=2, + enrollment_start_from_now=-1, + enrollment_end_from_now=1 + ) + + entitlement = CourseEntitlementFactory.create(mode=CourseMode.VERIFIED) + + assert is_course_run_entitlement_fullfillable(course_overview.id, entitlement) + + def test_course_run_not_fullfillable_run_ended(self): + course_overview = self.create_course( + start_from_now=-3, + end_from_now=-1, + enrollment_start_from_now=-3, + enrollment_end_from_now=-2 + ) + + entitlement = CourseEntitlementFactory.create(mode=CourseMode.VERIFIED) + + assert not is_course_run_entitlement_fullfillable(course_overview.id, entitlement) + + def test_course_run_not_fullfillable_enroll_period_ended(self): + course_overview = self.create_course( + start_from_now=-3, + end_from_now=2, + enrollment_start_from_now=-2, + enrollment_end_from_now=-1 + ) + + entitlement = CourseEntitlementFactory.create(mode=CourseMode.VERIFIED) + + assert not is_course_run_entitlement_fullfillable(course_overview.id, entitlement) + + def test_course_run_fullfillable_user_enrolled(self): + course_overview = self.create_course( + start_from_now=-3, + end_from_now=2, + enrollment_start_from_now=-2, + enrollment_end_from_now=1 + ) + + entitlement = CourseEntitlementFactory.create(mode=CourseMode.VERIFIED) + # Enroll User in the Course, but do not update the entitlement + CourseEnrollmentFactory.create(user=entitlement.user, course_id=course_overview.id) + + assert is_course_run_entitlement_fullfillable(course_overview.id, entitlement) + + def test_course_run_not_fullfillable_upgrade_ended(self): + course_overview = self.create_course( + start_from_now=-3, + end_from_now=2, + enrollment_start_from_now=-2, + enrollment_end_from_now=1, + upgraded_ended_from_now=-1 + ) + + entitlement = CourseEntitlementFactory.create(mode=CourseMode.VERIFIED) + + assert not is_course_run_entitlement_fullfillable(course_overview.id, entitlement) diff --git a/common/djangoapps/entitlements/utils.py b/common/djangoapps/entitlements/utils.py new file mode 100644 index 0000000000..1c9ce8271b --- /dev/null +++ b/common/djangoapps/entitlements/utils.py @@ -0,0 +1,42 @@ +from course_modes.models import CourseMode +from django.utils import timezone + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + + +def is_course_run_entitlement_fullfillable(course_run_id, entitlement, compare_date=timezone.now()): + """ + Checks that the current run meets the following criteria for an entitlement + + 1) Is currently running or start in the future + 2) A User can enroll in + 3) A User can upgrade to the entitlement mode + + Arguments: + course_run_id (String): The id of the Course run that is being checked. + entitlement: The Entitlement that we are checking against. + compare_date: The date and time that we are comparing against. Defaults to timezone.now() + + Returns: + bool: True if the Course Run is fullfillable for the CourseEntitlement. + """ + course_overview = CourseOverview.get_from_id(course_run_id) + + # Verify that the course is still running + run_start = course_overview.start + run_end = course_overview.end + is_running = run_start and (not run_end or (run_end and (run_end > compare_date))) + + # Verify that the course run can currently be enrolled + enrollment_start = course_overview.enrollment_start + enrollment_end = course_overview.enrollment_end + can_enroll = ( + (not enrollment_start or enrollment_start < compare_date) + and (not enrollment_end or enrollment_end > compare_date) + ) + + # Ensure the course run is upgradeable and the mode matches the entitlement's mode + unexpired_paid_modes = [mode.slug for mode in CourseMode.paid_modes_for_course(course_run_id)] + can_upgrade = unexpired_paid_modes and entitlement.mode in unexpired_paid_modes + + return is_running and can_upgrade and can_enroll diff --git a/openedx/core/djangoapps/catalog/utils.py b/openedx/core/djangoapps/catalog/utils.py index f52bab27bf..3743697ed7 100644 --- a/openedx/core/djangoapps/catalog/utils.py +++ b/openedx/core/djangoapps/catalog/utils.py @@ -2,21 +2,22 @@ import copy import datetime import logging -import pycountry -from dateutil.parser import parse as datetime_parse +import pycountry from django.conf import settings from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist from edx_rest_api_client.client import EdxRestApiClient +from opaque_keys.edx.keys import CourseKey from pytz import UTC +from entitlements.utils import is_course_run_entitlement_fullfillable +from student.models import CourseEnrollment from openedx.core.djangoapps.catalog.cache import (PROGRAM_CACHE_KEY_TPL, SITE_PROGRAM_UUIDS_CACHE_KEY_TPL) from openedx.core.djangoapps.catalog.models import CatalogIntegration from openedx.core.lib.edx_api_utils import get_edx_api_data from openedx.core.lib.token_utils import JwtBuilder -from student.models import CourseEnrollment logger = logging.getLogger(__name__) @@ -315,48 +316,16 @@ def get_fulfillable_course_runs_for_entitlement(entitlement, course_runs): """ Takes a list of course runs and returns only the course runs, sorted by start date, that: - 1) Are currently running or in the future - 2) A user can enroll in - 3) A user can upgrade in - 4) Are published - 5) Are not enrolled in already for an active session - These are the only sessions that can be selected for an entitlement. """ - enrollable_sessions = [] - enrollments_for_user = CourseEnrollment.enrollments_for_user(entitlement.user).filter(mode=entitlement.mode) - enrolled_sessions = frozenset([str(e.course_id) for e in enrollments_for_user]) - # Only show published course runs that can still be enrolled and upgraded - now = datetime.datetime.now(UTC) + search_time = datetime.datetime.now(UTC) for course_run in course_runs: - - # Only courses that have not ended will be displayed - run_start = course_run.get('start') - run_end = course_run.get('end') - is_running = run_start and (not run_end or datetime_parse(run_end) > now) - - # Only courses that can currently be enrolled in will be displayed - enrollment_start = course_run.get('enrollment_start') - enrollment_end = course_run.get('enrollment_end') - can_enroll = ((not enrollment_start or datetime_parse(enrollment_start) < now) - and (not enrollment_end or datetime_parse(enrollment_end) > now) - and course_run.get('key') not in enrolled_sessions) - - # Only upgrade-able courses will be displayed - can_upgrade = False - for seat in course_run.get('seats', []): - if seat.get('type') == entitlement.mode: - upgrade_deadline = seat.get('upgrade_deadline', None) - can_upgrade = not upgrade_deadline or (datetime_parse(upgrade_deadline) > now) - break - - # Only published courses will be displayed - is_published = course_run.get('status') == 'published' - - if is_running and can_upgrade and can_enroll and is_published: + course_id = CourseKey.from_string(course_run.get('key')) + is_enrolled = CourseEnrollment.is_enrolled(entitlement.user, str(course_id)) + if is_course_run_entitlement_fullfillable(course_id, entitlement, search_time) and not is_enrolled: enrollable_sessions.append(course_run) enrollable_sessions.sort(key=lambda session: session.get('start'))