Updates to the Course Entitlement API to block Learners from enrolling

in runs that are not available.

[LEARNER-3800]
This commit is contained in:
Albert St. Aubin
2017-12-18 14:35:53 -05:00
parent 5ccd465d85
commit cac22680e2
4 changed files with 193 additions and 42 deletions

View File

@@ -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
@@ -31,7 +34,6 @@ class EntitlementsPagination(DefaultPagination):
page_size = 50
max_page_size = 100
@transaction.atomic
def _unenroll_entitlement(course_entitlement, course_run_key):
"""
@@ -40,7 +42,6 @@ def _unenroll_entitlement(course_entitlement, course_run_key):
CourseEnrollment.unenroll(course_entitlement.user, course_run_key, skip_refund=True)
course_entitlement.set_enrollment(None)
@transaction.atomic
def _process_revoke_and_unenroll_entitlement(course_entitlement, is_refund=False):
"""
@@ -318,9 +319,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 +328,24 @@ 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, datetime.datetime.now(UTC), 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 +357,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:

View File

@@ -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, now(), 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, now() + timedelta(days=3), 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, now() + timedelta(days=3), entitlement)
def test_course_run_not_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 not is_course_run_entitlement_fullfillable(course_overview.id, now() + timedelta(days=3), 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, now() + timedelta(days=3), entitlement)

View File

@@ -0,0 +1,47 @@
from course_modes.models import CourseMode
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from student.models import CourseEnrollment
def is_course_run_entitlement_fullfillable(course_run_id, compare_date, entitlement):
"""
Checks that the current run meets the following criteria for an entitlement
1) Are currently running or start in the future
2) A user can enroll in
3) A user can upgrade to the entitlement mode
4) Are not already currently enrolled in a session
Arguments:
course_run_id (String): The id of the Course run that is being checked.
compare_date: The date and time that we are comparing against. Generally the current date and time.
user_enrolled_sessions (list): The list of sessions that the user is enrolled in currently.
mode (String): The current mode of the entitlement that we are verifying against.
Returns:
bool: True is the Course Run is fullfillable for the CourseEntitlement.
"""
# Only courses that have not ended will be displayed
course_overview = CourseOverview.get_from_id(course_run_id)
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)))
# Only courses that can currently be enrolled in will be displayed
enrollment_start = course_overview.enrollment_start
enrollment_end = course_overview.enrollment_end
is_enrolled = CourseEnrollment.is_enrolled(entitlement.user, course_run_id)
can_enroll = (
(not enrollment_start or enrollment_start < compare_date)
and (not enrollment_end or enrollment_end > compare_date)
and not is_enrolled
)
# Check if it is an upgradable mode and mode matches entitlement mode
can_upgrade = False
unexpired_paid_modes = [mode.slug for mode in CourseMode.paid_modes_for_course(course_run_id)]
if unexpired_paid_modes and entitlement.mode in unexpired_paid_modes:
can_upgrade = True
return is_running and can_upgrade and can_enroll

View File

@@ -2,21 +2,21 @@
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 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 +315,20 @@ 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
1) Are currently running or start 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
3) A user can upgrade to the entitlement mode
4) Are not already currently enrolled in a 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)
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'))
if is_course_run_entitlement_fullfillable(course_id, now, entitlement):
enrollable_sessions.append(course_run)
enrollable_sessions.sort(key=lambda session: session.get('start'))