diff --git a/common/djangoapps/entitlements/api/v1/tests/test_views.py b/common/djangoapps/entitlements/api/v1/tests/test_views.py index dca5862333..0cd4c06e85 100644 --- a/common/djangoapps/entitlements/api/v1/tests/test_views.py +++ b/common/djangoapps/entitlements/api/v1/tests/test_views.py @@ -350,20 +350,6 @@ 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)} @@ -371,7 +357,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, mode=CourseMode.VERIFIED) + course_entitlement = CourseEntitlementFactory.create(user=self.user) mock_get_course_runs.return_value = self.return_values url = reverse( self.ENTITLEMENTS_ENROLLMENT_NAMESPACE, @@ -395,7 +381,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, mode=CourseMode.VERIFIED) + course_entitlement = CourseEntitlementFactory.create(user=self.user) mock_get_course_runs.return_value = self.return_values url = reverse( @@ -430,7 +416,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, mode=CourseMode.VERIFIED) + course_entitlement = CourseEntitlementFactory.create(user=self.user) url = reverse( self.ENTITLEMENTS_ENROLLMENT_NAMESPACE, @@ -467,7 +453,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, mode=CourseMode.VERIFIED) + course_entitlement = CourseEntitlementFactory.create(user=self.user) mock_get_course_runs.return_value = self.return_values url = reverse( @@ -488,13 +474,16 @@ 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, mode=CourseMode.VERIFIED) + course_entitlement = CourseEntitlementFactory.create(user=self.user) mock_get_course_runs.return_value = self.return_values url = reverse( @@ -519,7 +508,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, mode=CourseMode.VERIFIED) + course_entitlement = CourseEntitlementFactory.create(user=self.user) mock_get_course_runs.return_value = self.return_values url = reverse( @@ -566,7 +555,7 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): mock_refund_entitlement, mock_is_refundable ): - course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED) + course_entitlement = CourseEntitlementFactory.create(user=self.user) mock_get_course_runs.return_value = self.return_values url = reverse( @@ -610,7 +599,7 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase): mock_refund_entitlement, mock_is_refundable ): - course_entitlement = CourseEntitlementFactory.create(user=self.user, mode=CourseMode.VERIFIED) + course_entitlement = CourseEntitlementFactory.create(user=self.user) 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 8755dd7023..0ef9a385f2 100644 --- a/common/djangoapps/entitlements/api/v1/views.py +++ b/common/djangoapps/entitlements/api/v1/views.py @@ -1,4 +1,3 @@ -import datetime import logging from django.db import IntegrityError, transaction @@ -7,7 +6,6 @@ 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 @@ -16,7 +14,6 @@ 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 @@ -321,8 +318,9 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): } ) + # Determine if this is a Switch session or a simple enroll and handle both. try: - course_run_key = CourseKey.from_string(course_run_id) + course_run_string = CourseKey.from_string(course_run_id) except InvalidKeyError: return Response( status=status.HTTP_400_BAD_REQUEST, @@ -330,23 +328,10 @@ 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_key, + course_run_key=course_run_string, user=request.user ) if response: @@ -358,7 +343,7 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet): ) response = self._enroll_entitlement( entitlement=entitlement, - course_run_key=course_run_key, + course_run_key=course_run_string, user=request.user ) if response: diff --git a/common/djangoapps/entitlements/tests/test_utils.py b/common/djangoapps/entitlements/tests/test_utils.py deleted file mode 100644 index 8cf5874825..0000000000 --- a/common/djangoapps/entitlements/tests/test_utils.py +++ /dev/null @@ -1,118 +0,0 @@ -""" -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 deleted file mode 100644 index 1c9ce8271b..0000000000 --- a/common/djangoapps/entitlements/utils.py +++ /dev/null @@ -1,42 +0,0 @@ -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 3743697ed7..f52bab27bf 100644 --- a/openedx/core/djangoapps/catalog/utils.py +++ b/openedx/core/djangoapps/catalog/utils.py @@ -2,22 +2,21 @@ import copy import datetime import logging - import pycountry + +from dateutil.parser import parse as datetime_parse 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__) @@ -316,16 +315,48 @@ 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 - search_time = datetime.datetime.now(UTC) + now = datetime.datetime.now(UTC) for course_run in course_runs: - 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: + + # 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: enrollable_sessions.append(course_run) enrollable_sessions.sort(key=lambda session: session.get('start'))