Skip to content
Snippets Groups Projects
Unverified Commit a3521b9c authored by McKenzie Welter's avatar McKenzie Welter Committed by GitHub
Browse files

Merge pull request #17758 from edx/McKenzieW/learner-4095

Enable mode-specific course entitlement policy
parents 187bc401 cc19e505
No related branches found
No related tags found
No related merge requests found
"""Admin forms for Course Entitlements"""
from django import forms
from django.contrib import admin
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore
from .models import CourseEntitlement, CourseEntitlementPolicy, CourseEntitlementSupportDetail
......@@ -72,4 +73,5 @@ class CourseEntitlementPolicyAdmin(admin.ModelAdmin):
list_display = ('expiration_period',
'refund_period',
'regain_period',
'mode',
'site')
......@@ -7,24 +7,25 @@ from datetime import datetime, timedelta
from django.conf import settings
from django.core.urlresolvers import reverse
from django.utils.timezone import now
from mock import patch
from opaque_keys.edx.locator import CourseKey
from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory
from mock import patch
from opaque_keys.edx.locator import CourseKey
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from student.models import CourseEnrollment
from student.tests.factories import TEST_PASSWORD, CourseEnrollmentFactory, UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from student.models import CourseEnrollment
from student.tests.factories import (TEST_PASSWORD, CourseEnrollmentFactory, UserFactory)
log = logging.getLogger(__name__)
# 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
from entitlements.models import CourseEntitlement, CourseEntitlementPolicy
from entitlements.api.v1.serializers import CourseEntitlementSerializer
from entitlements.api.v1.views import set_entitlement_policy
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
......@@ -56,6 +57,16 @@ class EntitlementViewSetTest(ModuleStoreTestCase):
"order_number": "EDX-1001",
}
def _assert_default_policy(self, policy):
"""
Assert that a policy is equal to the default Course Entitlement Policy.
"""
default_policy = CourseEntitlementPolicy()
assert policy.expiration_period == default_policy.expiration_period
assert policy.refund_period == default_policy.refund_period
assert policy.regain_period == default_policy.regain_period
assert policy.mode == default_policy.mode
def test_auth_required(self):
self.client.logout()
response = self.client.get(self.entitlements_list_url)
......@@ -124,6 +135,175 @@ class EntitlementViewSetTest(ModuleStoreTestCase):
)
assert results == CourseEntitlementSerializer(course_entitlement).data
def test_default_no_policy_entry(self):
"""
Verify that, when there are no entries in the course entitlement policy table,
the default policy is used for a newly created entitlement.
"""
course_uuid = uuid.uuid4()
entitlement_data = self._get_data_set(self.user, str(course_uuid))
self.client.post(
self.entitlements_list_url,
data=json.dumps(entitlement_data),
content_type='application/json',
)
course_entitlement = CourseEntitlement.objects.get(
user=self.user,
course_uuid=course_uuid
)
self._assert_default_policy(course_entitlement.policy)
def test_default_no_matching_policy_entry(self):
"""
Verify that, when no course entitlement policy is found with the same mode or site
as the created entitlement, the default policy is used for the entitlement.
"""
CourseEntitlementPolicy.objects.create(mode=CourseMode.PROFESSIONAL, site=None)
course_uuid = uuid.uuid4()
entitlement_data = self._get_data_set(self.user, str(course_uuid))
self.client.post(
self.entitlements_list_url,
data=json.dumps(entitlement_data),
content_type='application/json',
)
course_entitlement = CourseEntitlement.objects.get(
user=self.user,
course_uuid=course_uuid
)
self._assert_default_policy(course_entitlement.policy)
def test_set_custom_mode_policy_on_create(self):
"""
Verify that, when there does not exist a course entitlement policy with the same mode and site as
a created entitlement, but there does exist a policy with the same mode and a null site,
that policy is assigned to the entitlement.
"""
policy = CourseEntitlementPolicy.objects.create(mode=CourseMode.PROFESSIONAL, site=None)
course_uuid = uuid.uuid4()
entitlement_data = self._get_data_set(self.user, str(course_uuid))
entitlement_data['mode'] = CourseMode.PROFESSIONAL
self.client.post(
self.entitlements_list_url,
data=json.dumps(entitlement_data),
content_type='application/json',
)
course_entitlement = CourseEntitlement.objects.get(
user=self.user,
course_uuid=course_uuid
)
assert course_entitlement.policy == policy
# To verify policy selecting behavior involving site specificity, we interact directly
# with the 'set_entitlement_policy' method due to an inablity to predict or manually assign
# the site associated with the requests made in unittests.
def test_set_custom_site_policy_on_create(self):
"""
Verify that, when there does not exist a course entitlement policy with the same mode and site as
a created entitlement, but there does exist a policy with the same site and a null mode,
that policy is assigned to the entitlement.
"""
course_uuid = uuid.uuid4()
entitlement_data = self._get_data_set(self.user, str(course_uuid))
self.client.post(
self.entitlements_list_url,
data=json.dumps(entitlement_data),
content_type='application/json',
)
course_entitlement = CourseEntitlement.objects.get(
user=self.user,
course_uuid=course_uuid
)
policy_site = SiteFactory.create()
policy = CourseEntitlementPolicy.objects.create(mode=None, site=policy_site)
set_entitlement_policy(course_entitlement, policy_site)
assert course_entitlement.policy == policy
def test_set_policy_match_site_over_mode(self):
"""
Verify that, when both a mode-agnostic policy matching the site of a created entitlement and a site-agnostic
policy matching the mode of a created entitlement exist but no policy matching both the site and mode of the
created entitlement exists, the site-specific (mode-agnostic) policy matching the entitlement is selected over
the mode-specific (site-agnostic) policy.
"""
course_uuid = uuid.uuid4()
entitlement_data = self._get_data_set(self.user, str(course_uuid))
self.client.post(
self.entitlements_list_url,
data=json.dumps(entitlement_data),
content_type='application/json',
)
course_entitlement = CourseEntitlement.objects.get(
user=self.user,
course_uuid=course_uuid
)
policy_site = SiteFactory.create()
policy = CourseEntitlementPolicy.objects.create(mode=None, site=policy_site)
CourseEntitlementPolicy.objects.create(mode=entitlement_data['mode'], site=None)
set_entitlement_policy(course_entitlement, policy_site)
assert course_entitlement.policy == policy
def test_set_policy_site_and_mode_specific(self):
"""
Verify that, when there exists a policy matching both the mode and site of the a given course entitlement,
it is selected over appropriate site- and mode-specific (mode- and site-agnostic) policies and the default
policy for assignment to the entitlement.
"""
course_uuid = uuid.uuid4()
entitlement_data = self._get_data_set(self.user, str(course_uuid))
entitlement_data['mode'] = CourseMode.PROFESSIONAL
self.client.post(
self.entitlements_list_url,
data=json.dumps(entitlement_data),
content_type='application/json',
)
course_entitlement = CourseEntitlement.objects.get(
user=self.user,
course_uuid=course_uuid
)
policy_site = SiteFactory.create()
policy = CourseEntitlementPolicy.objects.create(mode=entitlement_data['mode'], site=policy_site)
CourseEntitlementPolicy.objects.create(mode=entitlement_data['mode'], site=None)
CourseEntitlementPolicy.objects.create(mode=None, site=policy_site)
set_entitlement_policy(course_entitlement, policy_site)
assert course_entitlement.policy == policy
def test_professional_policy_for_no_id_professional(self):
"""
Verify that when there exists a policy with a professional mode that it is assigned
to new entitlements with the mode no-id-professional.
"""
policy = CourseEntitlementPolicy.objects.create(mode=CourseMode.PROFESSIONAL)
course_uuid = uuid.uuid4()
entitlement_data = self._get_data_set(self.user, str(course_uuid))
entitlement_data['mode'] = CourseMode.NO_ID_PROFESSIONAL_MODE
self.client.post(
self.entitlements_list_url,
data=json.dumps(entitlement_data),
content_type='application/json',
)
course_entitlement = CourseEntitlement.objects.get(
user=self.user,
course_uuid=course_uuid
)
assert course_entitlement.policy == policy
def test_add_entitlement_with_support_detail(self):
"""
Verify that an EntitlementSupportDetail entry is made when the request includes support interaction information.
......
import logging
from django.db import IntegrityError, transaction
from django.db.models import Q
from django.http import HttpResponseBadRequest
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
......@@ -8,22 +9,21 @@ from edx_rest_framework_extensions.authentication import JwtAuthentication
from edx_rest_framework_extensions.paginators import DefaultPagination
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from rest_framework import permissions, viewsets, status
from rest_framework import permissions, status, viewsets
from rest_framework.authentication import SessionAuthentication
from rest_framework.response import Response
from course_modes.models import CourseMode
from entitlements.api.v1.filters import CourseEntitlementFilter
from entitlements.api.v1.permissions import IsAdminOrSupportOrAuthenticatedReadOnly
from entitlements.api.v1.serializers import CourseEntitlementSerializer
from entitlements.models import CourseEntitlement, CourseEntitlementSupportDetail
from entitlements.models import CourseEntitlement, CourseEntitlementPolicy, CourseEntitlementSupportDetail
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.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf
from student.models import CourseEnrollment
from student.models import CourseEnrollmentException, AlreadyEnrolledError
from course_modes.models import CourseMode
from student.models import AlreadyEnrolledError, CourseEnrollment, CourseEnrollmentException
log = logging.getLogger(__name__)
......@@ -89,6 +89,27 @@ def _process_revoke_and_unenroll_entitlement(course_entitlement, is_refund=False
raise IntegrityError
def set_entitlement_policy(entitlement, site):
"""
Assign the appropriate CourseEntitlementPolicy to the given CourseEntitlement based on its mode and site.
Arguments:
entitlement: Course Entitlement object
site: string representation of a Site object
Notes:
Site-specific, mode-agnostic policies take precedence over mode-specific, site-agnostic policies.
If no appropriate CourseEntitlementPolicy is found, the default CourseEntitlementPolicy is assigned.
"""
policy_mode = entitlement.mode
if CourseMode.is_professional_slug(policy_mode):
policy_mode = CourseMode.PROFESSIONAL
filter_query = (Q(site=site) | Q(site__isnull=True)) & (Q(mode=policy_mode) | Q(mode__isnull=True))
policy = CourseEntitlementPolicy.objects.filter(filter_query).order_by('-site', '-mode').first()
entitlement.policy = policy if policy else None
entitlement.save()
class EntitlementViewSet(viewsets.ModelViewSet):
ENTITLEMENT_UUID4_REGEX = '[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}'
......@@ -127,6 +148,7 @@ class EntitlementViewSet(viewsets.ModelViewSet):
self.perform_create(serializer)
entitlement = serializer.instance
set_entitlement_policy(entitlement, request.site)
if support_details:
for support_detail in support_details:
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('entitlements', '0007_change_expiration_period_default'),
]
operations = [
migrations.AddField(
model_name='courseentitlementpolicy',
name='mode',
field=models.CharField(max_length=32, null=True, choices=[(None, b'---------'), (b'verified', b'verified'), (b'professional', b'professional')]),
),
migrations.AlterField(
model_name='courseentitlementpolicy',
name='site',
field=models.ForeignKey(to='sites.Site', null=True),
),
]
......@@ -6,18 +6,18 @@ 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.db import models, transaction
from django.utils.timezone import now
from model_utils import Choices
from model_utils.models import TimeStampedModel
from course_modes.models import CourseMode
from entitlements.utils import is_course_run_entitlement_fulfillable
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 student.models import CourseEnrollment, CourseEnrollmentException
from util.date_utils import strftime_localized
from entitlements.utils import is_course_run_entitlement_fulfillable
log = logging.getLogger("common.entitlements.models")
......@@ -30,6 +30,7 @@ class CourseEntitlementPolicy(models.Model):
DEFAULT_EXPIRATION_PERIOD_DAYS = 730
DEFAULT_REFUND_PERIOD_DAYS = 60
DEFAULT_REGAIN_PERIOD_DAYS = 14
MODES = Choices((None, '---------'), CourseMode.VERIFIED, CourseMode.PROFESSIONAL)
# Use a DurationField to calculate time as it returns a timedelta, useful in performing operations with datetimes
expiration_period = models.DurationField(
......@@ -48,7 +49,8 @@ class CourseEntitlementPolicy(models.Model):
"it is no longer able to be regained by a user."),
null=False
)
site = models.ForeignKey(Site)
site = models.ForeignKey(Site, null=True)
mode = models.CharField(max_length=32, choices=MODES, null=True)
def get_days_until_expiration(self, entitlement):
"""
......@@ -131,11 +133,12 @@ class CourseEntitlementPolicy(models.Model):
and not entitlement.expired_at)
def __unicode__(self):
return u'Course Entitlement Policy: expiration_period: {}, refund_period: {}, regain_period: {}'\
return u'Course Entitlement Policy: expiration_period: {}, refund_period: {}, regain_period: {}, mode: {}'\
.format(
self.expiration_period,
self.refund_period,
self.regain_period,
self.mode
)
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment