Merge pull request #17491 from edx/aj/LEARNER-3898

Aj/learner 3898
This commit is contained in:
Albert (AJ) St. Aubin
2018-02-21 20:45:08 -05:00
committed by GitHub
10 changed files with 314 additions and 38 deletions

View File

@@ -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={

View File

@@ -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

View File

@@ -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):
"""

View File

@@ -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):
"""

View File

@@ -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)

View File

@@ -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

View File

@@ -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):

View File

@@ -1,6 +1,8 @@
""" API v0 views. """
import logging
from courseware import courses
from django.core.urlresolvers import reverse
from edx_rest_api_client import exceptions
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
@@ -11,16 +13,15 @@ from rest_framework.views import APIView
from six import text_type
from course_modes.models import CourseMode
from courseware import courses
from enrollment.api import add_enrollment
from enrollment.views import EnrollmentCrossDomainSessionAuth
from entitlements.models import CourseEntitlement
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.embargo import api as embargo_api
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
from student.models import CourseEnrollment
from util.json_request import JsonResponse
from ...constants import Messages
from ...http import DetailResponse
@@ -113,6 +114,14 @@ class BasketsView(APIView):
honor_mode = CourseMode.mode_for_course(course_key, CourseMode.HONOR)
audit_mode = CourseMode.mode_for_course(course_key, CourseMode.AUDIT)
# Check to see if the User has an entitlement and enroll them if they have one for this course
if CourseEntitlement.check_for_existing_entitlement_and_enroll(user=user, course_run_key=course_key):
return JsonResponse(
{
'redirect_destination': reverse('courseware', args=[unicode(course_id)]),
},
)
# Accept either honor or audit as an enrollment mode to
# maintain backwards compatibility with existing courses
default_enrollment_mode = audit_mode or honor_mode

View File

@@ -41,12 +41,15 @@
this.redirect(redirectUrl);
}
}
}).done(function() {
}).done(function(response) {
// If we successfully enrolled, redirect the user
// to the next page (usually the student dashboard or payment flow)
if (redirectUrl) {
if (response.redirect_destination) {
this.redirect(response.redirect_destination)
} else if (redirectUrl) {
this.redirect(redirectUrl);
}
});
},

View File

@@ -2,6 +2,7 @@
import copy
import datetime
import logging
import uuid
import pycountry
from django.conf import settings
@@ -11,13 +12,13 @@ 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 entitlements.utils import is_course_run_entitlement_fulfillable
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__)
@@ -287,6 +288,69 @@ def get_course_runs_for_course(course_uuid):
return []
def get_course_uuid_for_course(course_run_key):
"""
Retrieve the Course UUID for a given course key
Arguments:
course_run_key (CourseKey): A Key for a Course run that will be pulled apart to get just the information
required for a Course (e.g. org+course)
Returns:
UUID: Course UUID and None if it was not retrieved.
"""
catalog_integration = CatalogIntegration.current()
if catalog_integration.is_enabled():
try:
user = catalog_integration.get_service_user()
except ObjectDoesNotExist:
logger.error(
'Catalog service user with username [%s] does not exist. Course UUID will not be retrieved.',
catalog_integration.service_username,
)
return []
api = create_catalog_api_client(user)
run_cache_key = '{base}.course_run.{course_run_key}'.format(
base=catalog_integration.CACHE_KEY,
course_run_key=course_run_key
)
course_run_data = get_edx_api_data(
catalog_integration,
'course_runs',
resource_id=unicode(course_run_key),
api=api,
cache_key=run_cache_key if catalog_integration.is_cache_enabled else None,
long_term_cache=True,
many=False,
)
course_key_str = course_run_data.get('course', None)
if course_key_str:
run_cache_key = '{base}.course.{course_key}'.format(
base=catalog_integration.CACHE_KEY,
course_key=course_key_str
)
data = get_edx_api_data(
catalog_integration,
'courses',
resource_id=course_key_str,
api=api,
cache_key=run_cache_key if catalog_integration.is_cache_enabled else None,
long_term_cache=True,
many=False,
)
uuid_str = data.get('uuid', None)
if uuid_str:
return uuid.UUID(uuid_str)
return None
def get_pseudo_session_for_entitlement(entitlement):
"""
This function is used to pass pseudo-data to the front end, returning a single session, regardless of whether that
@@ -325,7 +389,7 @@ def get_fulfillable_course_runs_for_entitlement(entitlement, course_runs):
for course_run in course_runs:
course_id = CourseKey.from_string(course_run.get('key'))
is_enrolled = CourseEnrollment.is_enrolled(entitlement.user, course_id)
if is_course_run_entitlement_fullfillable(course_id, entitlement, search_time):
if is_course_run_entitlement_fulfillable(course_id, entitlement, search_time):
if (is_enrolled and
entitlement.enrollment_course_run and
course_id == entitlement.enrollment_course_run.course_id):