@@ -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,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 entitlements.utils import is_course_run_entitlement_fulfillable
|
||||
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
|
||||
@@ -332,7 +330,7 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
|
||||
)
|
||||
|
||||
# Verify that the run is fullfillable
|
||||
if not is_course_run_entitlement_fullfillable(course_run_key, entitlement):
|
||||
if not is_course_run_entitlement_fulfillable(course_run_key, entitlement):
|
||||
return Response(
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
data={
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
1. Course UUID Retrieved from Discovery by API
|
||||
----------------------------------------------
|
||||
|
||||
Status
|
||||
------
|
||||
|
||||
Accepted
|
||||
|
||||
Context
|
||||
-------
|
||||
|
||||
Course UUID is a more reliable and consistently unique identifier for a Course.
|
||||
|
||||
|
||||
Decision
|
||||
--------
|
||||
|
||||
The decision was made for consistency to not move the course UUID into the Platform data model. As a result the only
|
||||
method available to get a Course UUID based on a Course Key is the Discovery Service.
|
||||
|
||||
Consequences
|
||||
------------
|
||||
|
||||
When there is a need to find a Course by UUID, but only the Course Key is available the Discovery API is required to
|
||||
resolve the identifier.
|
||||
|
||||
References
|
||||
----------
|
||||
|
||||
* https://openedx.atlassian.net/wiki/spaces/LEARNER/pages/171180253/Program+Bundling
|
||||
@@ -1,16 +1,25 @@
|
||||
"""Entitlement Models"""
|
||||
|
||||
import logging
|
||||
import uuid as uuid_tools
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.models import Site
|
||||
from django.db import models
|
||||
from django.db import transaction
|
||||
from django.utils.timezone import now
|
||||
from model_utils.models import TimeStampedModel
|
||||
|
||||
from lms.djangoapps.certificates.models import GeneratedCertificate
|
||||
from openedx.core.djangoapps.catalog.utils import get_course_uuid_for_course
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from student.models import CourseEnrollment
|
||||
from student.models import CourseEnrollmentException
|
||||
from util.date_utils import strftime_localized
|
||||
from entitlements.utils import is_course_run_entitlement_fulfillable
|
||||
|
||||
log = logging.getLogger("common.entitlements.models")
|
||||
|
||||
|
||||
class CourseEntitlementPolicy(models.Model):
|
||||
@@ -320,6 +329,91 @@ class CourseEntitlement(TimeStampedModel):
|
||||
enrollment_course_run=None
|
||||
).select_related('user').select_related('enrollment_course_run')
|
||||
|
||||
@classmethod
|
||||
def get_fulfillable_entitlements(cls, user):
|
||||
"""
|
||||
Returns all fulfillable entitlements for a User
|
||||
|
||||
Arguments:
|
||||
user (User): The user we are looking at the entitlements of.
|
||||
|
||||
Returns
|
||||
Queryset: A queryset of course Entitlements ordered descending by creation date that a user can enroll in.
|
||||
These must not be expired and not have a course run already assigned to it.
|
||||
"""
|
||||
|
||||
return cls.objects.filter(
|
||||
user=user,
|
||||
).exclude(
|
||||
expired_at__isnull=False,
|
||||
enrollment_course_run__isnull=False
|
||||
).order_by('-created')
|
||||
|
||||
@classmethod
|
||||
def get_fulfillable_entitlement_for_user_course_run(cls, user, course_run_key):
|
||||
"""
|
||||
Retrieves a fulfillable entitlement for the user and the given course run.
|
||||
|
||||
Arguments:
|
||||
user (User): The user that we are inspecting the entitlements for.
|
||||
course_run_key (CourseKey): The course run Key.
|
||||
|
||||
Returns:
|
||||
CourseEntitlement: The most recent fulfillable CourseEntitlement, None otherwise.
|
||||
"""
|
||||
# Check if the User has any fulfillable entitlements.
|
||||
# Note: Wait to retrieve the Course UUID until we have confirmed the User has fulfillable entitlements.
|
||||
# This was done to avoid calling the APIs when the User does not have an entitlement.
|
||||
entitlements = cls.get_fulfillable_entitlements(user)
|
||||
if entitlements:
|
||||
course_uuid = get_course_uuid_for_course(course_run_key)
|
||||
if course_uuid:
|
||||
entitlement = entitlements.filter(course_uuid=course_uuid).first()
|
||||
if is_course_run_entitlement_fulfillable(course_run_key=course_run_key, entitlement=entitlement):
|
||||
return entitlement
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
@transaction.atomic
|
||||
def enroll_user_and_fulfill_entitlement(cls, entitlement, course_run_key):
|
||||
"""
|
||||
Enrolls the user in the Course Run and updates the entitlement with the new Enrollment.
|
||||
|
||||
Returns:
|
||||
bool: True if successfully fulfills given entitlement by enrolling the user in the given course run.
|
||||
"""
|
||||
try:
|
||||
enrollment = CourseEnrollment.enroll(
|
||||
user=entitlement.user,
|
||||
course_key=course_run_key,
|
||||
mode=entitlement.mode
|
||||
)
|
||||
except CourseEnrollmentException:
|
||||
log.exception('Login for Course Entitlement {uuid} failed'.format(uuid=entitlement.uuid))
|
||||
return False
|
||||
|
||||
entitlement.set_enrollment(enrollment)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def check_for_existing_entitlement_and_enroll(cls, user, course_run_key):
|
||||
"""
|
||||
Looks at the User's existing entitlements to see if the user already has a Course Entitlement for the
|
||||
course run provided in the course_key. If the user does have an Entitlement with no run set, the User is
|
||||
enrolled in the mode set in the Entitlement.
|
||||
|
||||
Arguments:
|
||||
user (User): The user that we are inspecting the entitlements for.
|
||||
course_run_key (CourseKey): The course run Key.
|
||||
Returns:
|
||||
bool: True if the user had an eligible course entitlement to which an enrollment in the
|
||||
given course run was applied.
|
||||
"""
|
||||
entitlement = cls.get_fulfillable_entitlement_for_user_course_run(user, course_run_key)
|
||||
if entitlement:
|
||||
return cls.enroll_user_and_fulfill_entitlement(entitlement, course_run_key)
|
||||
return False
|
||||
|
||||
|
||||
class CourseEntitlementSupportDetail(TimeStampedModel):
|
||||
"""
|
||||
|
||||
@@ -6,16 +6,88 @@ from datetime import timedelta
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from django.utils.timezone import now
|
||||
from mock import patch
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
|
||||
from lms.djangoapps.certificates.models import CertificateStatuses # pylint: disable=import-error
|
||||
from course_modes.models import CourseMode
|
||||
from course_modes.tests.factories import CourseModeFactory
|
||||
from lms.djangoapps.certificates.api import MODES
|
||||
from lms.djangoapps.certificates.models import CertificateStatuses
|
||||
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
||||
from student.tests.factories import CourseEnrollmentFactory
|
||||
from student.models import CourseEnrollment
|
||||
from student.tests.factories import (TEST_PASSWORD, CourseEnrollmentFactory, UserFactory)
|
||||
|
||||
# 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.models import CourseEntitlement
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
class TestCourseEntitlementModelHelpers(ModuleStoreTestCase):
|
||||
"""
|
||||
Series of tests for the helper methods in the CourseEntitlement Model Class.
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestCourseEntitlementModelHelpers, self).setUp()
|
||||
self.user = UserFactory()
|
||||
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
||||
|
||||
@patch("entitlements.models.get_course_uuid_for_course")
|
||||
def test_check_for_existing_entitlement_and_enroll(self, mock_get_course_uuid):
|
||||
course = CourseFactory()
|
||||
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=now() + timedelta(days=1)
|
||||
)
|
||||
entitlement = CourseEntitlementFactory.create(
|
||||
mode=CourseMode.VERIFIED,
|
||||
user=self.user,
|
||||
)
|
||||
mock_get_course_uuid.return_value = entitlement.course_uuid
|
||||
|
||||
assert not CourseEnrollment.is_enrolled(user=self.user, course_key=course.id)
|
||||
|
||||
CourseEntitlement.check_for_existing_entitlement_and_enroll(
|
||||
user=self.user,
|
||||
course_run_key=course.id,
|
||||
)
|
||||
|
||||
assert CourseEnrollment.is_enrolled(user=self.user, course_key=course.id)
|
||||
|
||||
entitlement.refresh_from_db()
|
||||
assert entitlement.enrollment_course_run
|
||||
|
||||
@patch("entitlements.models.get_course_uuid_for_course")
|
||||
def test_check_for_no_entitlement_and_do_not_enroll(self, mock_get_course_uuid):
|
||||
course = CourseFactory()
|
||||
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=now() + timedelta(days=1)
|
||||
)
|
||||
entitlement = CourseEntitlementFactory.create(
|
||||
mode=CourseMode.VERIFIED,
|
||||
user=self.user,
|
||||
)
|
||||
mock_get_course_uuid.return_value = None
|
||||
|
||||
assert not CourseEnrollment.is_enrolled(user=self.user, course_key=course.id)
|
||||
|
||||
CourseEntitlement.check_for_existing_entitlement_and_enroll(
|
||||
user=self.user,
|
||||
course_run_key=course.id,
|
||||
)
|
||||
|
||||
assert not CourseEnrollment.is_enrolled(user=self.user, course_key=course.id)
|
||||
|
||||
entitlement.refresh_from_db()
|
||||
assert entitlement.enrollment_course_run is None
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
@@ -28,6 +100,8 @@ class TestModels(TestCase):
|
||||
start=now()
|
||||
)
|
||||
self.enrollment = CourseEnrollmentFactory.create(course_id=self.course.id)
|
||||
self.user = UserFactory()
|
||||
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
||||
|
||||
def test_is_entitlement_redeemable(self):
|
||||
"""
|
||||
|
||||
@@ -16,13 +16,13 @@ from student.tests.factories import (TEST_PASSWORD, UserFactory, CourseOverviewF
|
||||
# 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
|
||||
from entitlements.utils import is_course_run_entitlement_fulfillable
|
||||
|
||||
|
||||
@skip_unless_lms
|
||||
class TestCourseRunFullfillableForEntitlement(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the utility function is_course_run_entitlement_fullfillable
|
||||
Tests for the utility function is_course_run_entitlement_fulfillable
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
@@ -64,7 +64,7 @@ class TestCourseRunFullfillableForEntitlement(ModuleStoreTestCase):
|
||||
|
||||
entitlement = CourseEntitlementFactory.create(mode=CourseMode.VERIFIED)
|
||||
|
||||
assert is_course_run_entitlement_fullfillable(course_overview.id, entitlement)
|
||||
assert is_course_run_entitlement_fulfillable(course_overview.id, entitlement)
|
||||
|
||||
def test_course_run_not_fullfillable_run_ended(self):
|
||||
course_overview = self.create_course(
|
||||
@@ -76,7 +76,7 @@ class TestCourseRunFullfillableForEntitlement(ModuleStoreTestCase):
|
||||
|
||||
entitlement = CourseEntitlementFactory.create(mode=CourseMode.VERIFIED)
|
||||
|
||||
assert not is_course_run_entitlement_fullfillable(course_overview.id, entitlement)
|
||||
assert not is_course_run_entitlement_fulfillable(course_overview.id, entitlement)
|
||||
|
||||
def test_course_run_not_fullfillable_enroll_period_ended(self):
|
||||
course_overview = self.create_course(
|
||||
@@ -88,7 +88,7 @@ class TestCourseRunFullfillableForEntitlement(ModuleStoreTestCase):
|
||||
|
||||
entitlement = CourseEntitlementFactory.create(mode=CourseMode.VERIFIED)
|
||||
|
||||
assert not is_course_run_entitlement_fullfillable(course_overview.id, entitlement)
|
||||
assert not is_course_run_entitlement_fulfillable(course_overview.id, entitlement)
|
||||
|
||||
def test_course_run_fullfillable_user_enrolled(self):
|
||||
course_overview = self.create_course(
|
||||
@@ -102,7 +102,7 @@ class TestCourseRunFullfillableForEntitlement(ModuleStoreTestCase):
|
||||
# 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)
|
||||
assert is_course_run_entitlement_fulfillable(course_overview.id, entitlement)
|
||||
|
||||
def test_course_run_not_fullfillable_upgrade_ended(self):
|
||||
course_overview = self.create_course(
|
||||
@@ -115,4 +115,4 @@ class TestCourseRunFullfillableForEntitlement(ModuleStoreTestCase):
|
||||
|
||||
entitlement = CourseEntitlementFactory.create(mode=CourseMode.VERIFIED)
|
||||
|
||||
assert not is_course_run_entitlement_fullfillable(course_overview.id, entitlement)
|
||||
assert not is_course_run_entitlement_fulfillable(course_overview.id, entitlement)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from course_modes.models import CourseMode
|
||||
from django.utils import timezone
|
||||
|
||||
from course_modes.models import CourseMode
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
|
||||
|
||||
def is_course_run_entitlement_fullfillable(course_run_id, entitlement, compare_date=timezone.now()):
|
||||
def is_course_run_entitlement_fulfillable(course_run_key, entitlement, compare_date=timezone.now()):
|
||||
"""
|
||||
Checks that the current run meets the following criteria for an entitlement
|
||||
|
||||
@@ -13,14 +13,14 @@ def is_course_run_entitlement_fullfillable(course_run_id, entitlement, compare_d
|
||||
3) A User can upgrade to the entitlement mode
|
||||
|
||||
Arguments:
|
||||
course_run_id (String): The id of the Course run that is being checked.
|
||||
course_run_key (CourseKey): 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)
|
||||
course_overview = CourseOverview.get_from_id(course_run_key)
|
||||
|
||||
# Verify that the course is still running
|
||||
run_start = course_overview.start
|
||||
@@ -36,7 +36,7 @@ def is_course_run_entitlement_fullfillable(course_run_id, entitlement, compare_d
|
||||
)
|
||||
|
||||
# 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)]
|
||||
unexpired_paid_modes = [mode.slug for mode in CourseMode.paid_modes_for_course(course_run_key)]
|
||||
can_upgrade = unexpired_paid_modes and entitlement.mode in unexpired_paid_modes
|
||||
|
||||
return is_running and can_upgrade and can_enroll
|
||||
|
||||
@@ -9,16 +9,20 @@ import uuid
|
||||
import warnings
|
||||
from collections import namedtuple
|
||||
|
||||
import analytics
|
||||
import dogstats_wrapper as dog_stats_api
|
||||
from bulk_email.models import Optout
|
||||
from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import authenticate, load_backend, login as django_login, logout
|
||||
from django.contrib.auth import login as django_login
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.models import AnonymousUser, User
|
||||
from django.contrib.auth.views import password_reset_confirm
|
||||
from django.core import mail
|
||||
from django.core.urlresolvers import NoReverseMatch, reverse, reverse_lazy
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.core.validators import ValidationError, validate_email
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.db import transaction
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import Signal, receiver
|
||||
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
|
||||
@@ -26,12 +30,15 @@ from django.shortcuts import redirect
|
||||
from django.template.context_processors import csrf
|
||||
from django.template.response import TemplateResponse
|
||||
from django.utils.encoding import force_bytes, force_text
|
||||
from django.utils.http import base36_to_int, is_safe_url, urlencode, urlsafe_base64_encode
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.utils.http import base36_to_int, urlsafe_base64_encode
|
||||
from django.utils.translation import get_language, ungettext
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
||||
from django.views.decorators.http import require_GET, require_POST
|
||||
from eventtracking import tracker
|
||||
from ipware.ip import get_ip
|
||||
# Note that this lives in LMS, so this dependency should be refactored.
|
||||
from notification_prefs.views import enable_notifications
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from pytz import UTC
|
||||
@@ -39,19 +46,14 @@ from requests import HTTPError
|
||||
from six import text_type, iteritems
|
||||
from social_core.exceptions import AuthAlreadyAssociated, AuthException
|
||||
from social_django import utils as social_utils
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
import analytics
|
||||
import dogstats_wrapper as dog_stats_api
|
||||
import openedx.core.djangoapps.external_auth.views
|
||||
import third_party_auth
|
||||
import track.views
|
||||
from bulk_email.models import Optout # pylint: disable=import-error
|
||||
from course_modes.models import CourseMode
|
||||
from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date # pylint: disable=import-error
|
||||
from edxmako.shortcuts import render_to_response, render_to_string
|
||||
from eventtracking import tracker
|
||||
# Note that this lives in LMS, so this dependency should be refactored.
|
||||
from notification_prefs.views import enable_notifications
|
||||
from entitlements.models import CourseEntitlement
|
||||
from openedx.core.djangoapps import monitoring_utils
|
||||
from openedx.core.djangoapps.catalog.utils import (
|
||||
get_programs_with_type,
|
||||
@@ -103,7 +105,6 @@ from util.bad_request_rate_limiter import BadRequestRateLimiter
|
||||
from util.db import outer_atomic
|
||||
from util.json_request import JsonResponse
|
||||
from util.password_policy_validators import validate_password_length, validate_password_strength
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
log = logging.getLogger("edx.student")
|
||||
|
||||
@@ -403,6 +404,9 @@ def change_enrollment(request, check_access=True):
|
||||
if redirect_url:
|
||||
return HttpResponse(redirect_url)
|
||||
|
||||
if CourseEntitlement.check_for_existing_entitlement_and_enroll(user=user, course_run_key=course_id):
|
||||
return HttpResponse(reverse('courseware', args=[unicode(course_id)]))
|
||||
|
||||
# Check that auto enrollment is allowed for this course
|
||||
# (= the course is NOT behind a paywall)
|
||||
if CourseMode.can_auto_enroll(course_id):
|
||||
|
||||
Reference in New Issue
Block a user