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