Unverified Commit f89b31da by Jeff LaJoie Committed by GitHub

Merge pull request #16627 from edx/jlajoie/LEARNER-2983

LEARNER-2983: Adds policy for entitlements
parents ff03f037 a5677192
from django.contrib import admin
from .models import CourseEntitlement
from .models import CourseEntitlement, CourseEntitlementPolicy
@admin.register(CourseEntitlement)
class EntitlementAdmin(admin.ModelAdmin):
class CourseEntitlementAdmin(admin.ModelAdmin):
list_display = ('user',
'uuid',
'course_uuid',
......@@ -14,3 +14,14 @@ class EntitlementAdmin(admin.ModelAdmin):
'mode',
'enrollment_course_run',
'order_number')
@admin.register(CourseEntitlementPolicy)
class CourseEntitlementPolicyAdmin(admin.ModelAdmin):
"""
Registration of CourseEntitlementPolicy for Django Admin
"""
list_display = ('expiration_period',
'refund_period',
'regain_period',
'site')
import json
import unittest
import uuid
from datetime import datetime, timedelta
import pytz
from django.conf import settings
from django.core.urlresolvers import reverse
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.tests.factories import CourseEnrollmentFactory, UserFactory, TEST_PASSWORD
# 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
......@@ -133,6 +135,44 @@ class EntitlementViewSetTest(ModuleStoreTestCase):
results = response.data.get('results', [])
assert results == CourseEntitlementSerializer([entitlement], many=True).data
def test_staff_get_expired_entitlements(self):
past_datetime = datetime.utcnow().replace(tzinfo=pytz.UTC) - timedelta(days=365 * 2)
entitlements = CourseEntitlementFactory.create_batch(2, created=past_datetime, user=self.user)
# Set the first entitlement to be at a time that it isn't expired
entitlements[0].created = datetime.utcnow()
entitlements[0].save()
response = self.client.get(
self.entitlements_list_url,
content_type='application/json',
)
assert response.status_code == 200
results = response.data.get('results', []) # pylint: disable=no-member
# Make sure that the first result isn't expired, and the second one is also not for staff users
assert results[0].get('expired_at') is None and results[1].get('expired_at') is None
def test_get_user_expired_entitlements(self):
past_datetime = datetime.utcnow().replace(tzinfo=pytz.UTC) - timedelta(days=365 * 2)
not_staff_user = UserFactory()
self.client.login(username=not_staff_user.username, password=TEST_PASSWORD)
entitlement_user2 = CourseEntitlementFactory.create_batch(2, user=not_staff_user, created=past_datetime)
url = reverse('entitlements_api:v1:entitlements-list')
url += '?user={username}'.format(username=not_staff_user.username)
# Set the first entitlement to be at a time that it isn't expired
entitlement_user2[0].created = datetime.utcnow()
entitlement_user2[0].save()
response = self.client.get(
url,
content_type='application/json',
)
assert response.status_code == 200
results = response.data.get('results', []) # pylint: disable=no-member
assert results[0].get('expired_at') is None and results[1].get('expired_at')
def test_get_user_entitlements(self):
user2 = UserFactory()
CourseEntitlementFactory.create()
......@@ -161,10 +201,27 @@ class EntitlementViewSetTest(ModuleStoreTestCase):
assert response.status_code == 200
results = response.data
assert results == CourseEntitlementSerializer(entitlement).data
assert results == CourseEntitlementSerializer(entitlement).data and results.get('expired_at') is None
def test_get_expired_entitlement_by_uuid(self):
past_datetime = datetime.utcnow().replace(tzinfo=pytz.UTC) - timedelta(days=365 * 2)
entitlement = CourseEntitlementFactory(created=past_datetime)
CourseEntitlementFactory.create_batch(2)
CourseEntitlementFactory()
url = reverse(self.ENTITLEMENTS_DETAILS_PATH, args=[str(entitlement.uuid)])
response = self.client.get(
url,
content_type='application/json',
)
assert response.status_code == 200
results = response.data # pylint: disable=no-member
assert results.get('expired_at')
def test_delete_and_revoke_entitlement(self):
course_entitlement = CourseEntitlementFactory()
course_entitlement = CourseEntitlementFactory.create()
url = reverse(self.ENTITLEMENTS_DETAILS_PATH, args=[str(course_entitlement.uuid)])
response = self.client.delete(
......@@ -176,7 +233,7 @@ class EntitlementViewSetTest(ModuleStoreTestCase):
assert course_entitlement.expired_at is not None
def test_revoke_unenroll_entitlement(self):
course_entitlement = CourseEntitlementFactory()
course_entitlement = CourseEntitlementFactory.create()
url = reverse(self.ENTITLEMENTS_DETAILS_PATH, args=[str(course_entitlement.uuid)])
enrollment = CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id)
......
import logging
from django.db import transaction
from django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from edx_rest_framework_extensions.authentication import JwtAuthentication
from rest_framework import permissions, viewsets
from rest_framework.response import Response
from entitlements.api.v1.filters import CourseEntitlementFilter
from entitlements.api.v1.permissions import IsAdminOrAuthenticatedReadOnly
......@@ -34,12 +36,44 @@ class EntitlementViewSet(viewsets.ModelViewSet):
# Return the full query set so that the Filters class can be used to apply,
# - The UUID Filter
# - The User Filter to the GET request
return CourseEntitlement.objects.all().select_related('user')
return CourseEntitlement.objects.all().select_related('user').select_related('enrollment_course_run')
# Non Staff Users will only be able to retrieve their own entitlements
return CourseEntitlement.objects.filter(user=user).select_related('user')
return CourseEntitlement.objects.filter(user=user).select_related('user').select_related(
'enrollment_course_run'
)
# All other methods require the full Query set and the Permissions class already restricts access to them
# to Admin users
return CourseEntitlement.objects.all().select_related('user')
return CourseEntitlement.objects.all().select_related('user').select_related('enrollment_course_run')
def retrieve(self, request, *args, **kwargs):
"""
Override the retrieve method to expire a record that is past the
policy and is requested via the API before returning that record.
"""
entitlement = self.get_object()
entitlement.update_expired_at()
serializer = self.get_serializer(entitlement)
return Response(serializer.data)
def list(self, request, *args, **kwargs):
"""
Override the list method to expire records that are past the
policy and requested via the API before returning those records.
"""
queryset = self.filter_queryset(self.get_queryset())
user = self.request.user
if not user.is_staff:
with transaction.atomic():
for entitlement in queryset:
entitlement.update_expired_at()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)
def perform_destroy(self, instance):
"""
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import datetime
class Migration(migrations.Migration):
dependencies = [
('sites', '0001_initial'),
('entitlements', '0002_auto_20171102_0719'),
]
operations = [
migrations.CreateModel(
name='CourseEntitlementPolicy',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('expiration_period', models.DurationField(default=datetime.timedelta(450), help_text=b'Duration in days from when an entitlement is created until when it is expired.')),
('refund_period', models.DurationField(default=datetime.timedelta(60), help_text=b'Duration in days from when an entitlement is created until when it is no longer refundable')),
('regain_period', models.DurationField(default=datetime.timedelta(14), help_text=b'Duration in days from when an entitlement is redeemed for a course run until it is no longer able to be regained by a user.')),
('site', models.ForeignKey(to='sites.Site')),
],
),
migrations.AlterField(
model_name='courseentitlement',
name='enrollment_course_run',
field=models.ForeignKey(blank=True, to='student.CourseEnrollment', help_text=b'The current Course enrollment for this entitlement. If NULL the Learner has not enrolled.', null=True),
),
migrations.AlterField(
model_name='courseentitlement',
name='expired_at',
field=models.DateTimeField(help_text=b'The date that an entitlement expired, if NULL the entitlement has not expired.', null=True, blank=True),
),
migrations.AddField(
model_name='courseentitlement',
name='_policy',
field=models.ForeignKey(blank=True, to='entitlements.CourseEntitlementPolicy', null=True),
),
]
import uuid as uuid_tools
from datetime import datetime, timedelta
import pytz
from django.conf import settings
from django.contrib.sites.models import Site
from django.db import models
from certificates.models import GeneratedCertificate # pylint: disable=import-error
from model_utils.models import TimeStampedModel
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
class CourseEntitlementPolicy(models.Model):
"""
Represents the Entitlement's policy for expiration, refunds, and regaining a used certificate
"""
DEFAULT_EXPIRATION_PERIOD_DAYS = 450
DEFAULT_REFUND_PERIOD_DAYS = 60
DEFAULT_REGAIN_PERIOD_DAYS = 14
# Use a DurationField to calculate time as it returns a timedelta, useful in performing operations with datetimes
expiration_period = models.DurationField(
default=timedelta(days=DEFAULT_EXPIRATION_PERIOD_DAYS),
help_text="Duration in days from when an entitlement is created until when it is expired.",
null=False
)
refund_period = models.DurationField(
default=timedelta(days=DEFAULT_REFUND_PERIOD_DAYS),
help_text="Duration in days from when an entitlement is created until when it is no longer refundable",
null=False
)
regain_period = models.DurationField(
default=timedelta(days=DEFAULT_REGAIN_PERIOD_DAYS),
help_text=("Duration in days from when an entitlement is redeemed for a course run until "
"it is no longer able to be regained by a user."),
null=False
)
site = models.ForeignKey(Site)
def get_days_until_expiration(self, entitlement):
"""
Returns an integer of number of days until the entitlement expires.
Includes the logic for regaining an entitlement.
"""
now = datetime.now(tz=pytz.UTC)
expiry_date = entitlement.created + self.expiration_period
days_until_expiry = (expiry_date - now).days
if not entitlement.enrollment_course_run:
return days_until_expiry
course_overview = CourseOverview.get_from_id(entitlement.enrollment_course_run.course_id)
# Compute the days left for the regain
days_since_course_start = (now - course_overview.start).days
days_since_enrollment = (now - entitlement.enrollment_course_run.created).days
# We want to return whichever days value is less since it is then the more recent one
days_until_regain_ends = (self.regain_period.days - # pylint: disable=no-member
min(days_since_course_start, days_since_enrollment))
# If the base days until expiration is less than the days until the regain period ends, use that instead
if days_until_expiry < days_until_regain_ends:
return days_until_expiry
return days_until_regain_ends # pylint: disable=no-member
def is_entitlement_regainable(self, entitlement):
"""
Determines from the policy if an entitlement can still be regained by the user, if they choose
to by leaving and regaining their entitlement within policy.regain_period days from start date of
the course or their redemption, whichever comes later, and the expiration period hasn't passed yet
"""
if entitlement.enrollment_course_run:
if GeneratedCertificate.certificate_for_student(
entitlement.user_id, entitlement.enrollment_course_run.course_id) is not None:
return False
# This is >= because a days_until_expiration 0 means that the expiration day has not fully passed yet
# and that the entitlement should not be expired as there is still time
return self.get_days_until_expiration(entitlement) >= 0
return False
def is_entitlement_refundable(self, entitlement):
"""
Determines from the policy if an entitlement can still be refunded, if the entitlement has not
yet been redeemed (enrollment_course_run is NULL) and policy.refund_period has not yet passed, or if
the entitlement has been redeemed, but the regain period hasn't passed yet.
"""
# If there's no order number, it cannot be refunded
if entitlement.order_number is None:
return False
# This is > because a get_days_since_created of refund_period means that that many days have passed,
# which should then make the entitlement no longer refundable
if entitlement.get_days_since_created() > self.refund_period.days: # pylint: disable=no-member
return False
if entitlement.enrollment_course_run:
return self.is_entitlement_regainable(entitlement)
return True
def is_entitlement_redeemable(self, entitlement):
"""
Determines from the policy if an entitlement can be redeemed, if it has not passed the
expiration period of policy.expiration_period, and has not already been redeemed
"""
# This is < because a get_days_since_created of expiration_period means that that many days have passed,
# which should then expire the entitlement
return (entitlement.get_days_since_created() < self.expiration_period.days # pylint: disable=no-member
and not entitlement.enrollment_course_run)
def __unicode__(self):
return u'Course Entitlement Policy: expiration_period: {}, refund_period: {}, regain_period: {}'\
.format(
self.expiration_period,
self.refund_period,
self.regain_period,
)
class CourseEntitlement(TimeStampedModel):
......@@ -15,19 +129,86 @@ class CourseEntitlement(TimeStampedModel):
course_uuid = models.UUIDField(help_text='UUID for the Course, not the Course Run')
expired_at = models.DateTimeField(
null=True,
help_text='The date that an entitlement expired, if NULL the entitlement has not expired.'
help_text='The date that an entitlement expired, if NULL the entitlement has not expired.',
blank=True
)
mode = models.CharField(max_length=100, help_text='The mode of the Course that will be applied on enroll.')
enrollment_course_run = models.ForeignKey(
'student.CourseEnrollment',
null=True,
help_text='The current Course enrollment for this entitlement. If NULL the Learner has not enrolled.'
help_text='The current Course enrollment for this entitlement. If NULL the Learner has not enrolled.',
blank=True
)
order_number = models.CharField(max_length=128, null=True)
_policy = models.ForeignKey(CourseEntitlementPolicy, null=True, blank=True)
@property
def expired_at_datetime(self):
"""
Getter to be used instead of expired_at because of the conditional check and update
"""
self.update_expired_at()
return self.expired_at
@expired_at_datetime.setter
def expired_at_datetime(self, value):
"""
Setter to be used instead for expired_at for consistency
"""
self.expired_at = value
@property
def policy(self):
"""
Getter to be used instead of _policy because of the null object pattern
"""
return self._policy or CourseEntitlementPolicy()
@policy.setter
def policy(self, value):
"""
Setter to be used instead of _policy because of the null object pattern
"""
self._policy = value
def get_days_since_created(self):
"""
Returns an integer of number of days since the entitlement has been created
"""
utc = pytz.UTC
return (datetime.now(tz=utc) - self.created).days
def update_expired_at(self):
"""
Updates the expired_at attribute if it is not set AND it is expired according to the entitlement's policy,
OR if the policy can no longer be regained AND the policy has been redeemed
"""
if not self.expired_at:
if (self.policy.get_days_until_expiration(self) < 0 or
(self.enrollment_course_run and not self.is_entitlement_regainable())):
self.expired_at = datetime.utcnow()
self.save()
def get_days_until_expiration(self):
"""
Returns an integer of number of days until the entitlement expires based on the entitlement's policy
"""
return self.policy.get_days_until_expiration(self)
def is_entitlement_regainable(self):
"""
Returns a boolean as to whether or not the entitlement can be regained based on the entitlement's policy
"""
return self.policy.is_entitlement_regainable(self)
def is_entitlement_refundable(self):
"""
Returns a boolean as to whether or not the entitlement can be refunded based on the entitlement's policy
"""
return self.policy.is_entitlement_refundable(self)
def is_entitlement_redeemable(self):
"""
Returns a boolean as to whether or not the entitlement can be redeemed based on the entitlement's policy
"""
return self.policy.is_entitlement_redeemable(self)
......@@ -4,9 +4,20 @@ from uuid import uuid4
import factory
from factory.fuzzy import FuzzyChoice, FuzzyText
from entitlements.models import CourseEntitlement
from student.tests.factories import UserFactory
from course_modes.helpers import CourseMode
from entitlements.models import CourseEntitlement, CourseEntitlementPolicy
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
from student.tests.factories import UserFactory
class CourseEntitlementPolicyFactory(factory.django.DjangoModelFactory):
"""
Factory for a a CourseEntitlementPolicy
"""
class Meta(object):
model = CourseEntitlementPolicy
site = factory.SubFactory(SiteFactory)
class CourseEntitlementFactory(factory.django.DjangoModelFactory):
......@@ -18,3 +29,4 @@ class CourseEntitlementFactory(factory.django.DjangoModelFactory):
mode = FuzzyChoice([CourseMode.VERIFIED, CourseMode.PROFESSIONAL])
user = factory.SubFactory(UserFactory)
order_number = FuzzyText(prefix='TEXTX', chars=string.digits)
policy = factory.SubFactory(CourseEntitlementPolicyFactory)
"""Test Entitlements models"""
import unittest
from datetime import datetime, timedelta
import pytz
from django.conf import settings
from django.test import TestCase
from certificates.models import CertificateStatuses # pylint: disable=import-error
from lms.djangoapps.certificates.api import MODES
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
# 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
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class TestModels(TestCase):
"""Test entitlement with policy model functions."""
def setUp(self):
super(TestModels, self).setUp()
self.course = CourseOverviewFactory.create(
start=datetime.utcnow()
)
self.enrollment = CourseEnrollmentFactory.create(course_id=self.course.id)
def test_is_entitlement_redeemable(self):
"""
Test that the entitlement is not expired when created now, and is expired when created 2 years
ago with a policy that sets the expiration period to 450 days
"""
entitlement = CourseEntitlementFactory.create()
assert entitlement.is_entitlement_redeemable() is True
# Create a date 2 years in the past (greater than the policy expire period of 450 days)
past_datetime = datetime.utcnow().replace(tzinfo=pytz.UTC) - timedelta(days=365 * 2)
entitlement.created = past_datetime
entitlement.save()
assert entitlement.is_entitlement_redeemable() is False
def test_is_entitlement_refundable(self):
"""
Test that the entitlement is refundable when created now, and is not refundable when created 70 days
ago with a policy that sets the expiration period to 60 days. Also test that if the entitlement is spent
and greater than 14 days it is no longer refundable.
"""
entitlement = CourseEntitlementFactory.create()
assert entitlement.is_entitlement_refundable() is True
# If there is no order_number make sure the entitlement is not refundable
entitlement.order_number = None
assert entitlement.is_entitlement_refundable() is False
# Create a date 70 days in the past (greater than the policy refund expire period of 60 days)
past_datetime = datetime.utcnow().replace(tzinfo=pytz.UTC) - timedelta(days=70)
entitlement = CourseEntitlementFactory.create(created=past_datetime)
assert entitlement.is_entitlement_refundable() is False
entitlement = CourseEntitlementFactory.create(enrollment_course_run=self.enrollment)
# Create a date 20 days in the past (less than the policy refund expire period of 60 days)
# but more than the policy regain period of 14 days and also the course start
past_datetime = datetime.utcnow().replace(tzinfo=pytz.UTC) - timedelta(days=20)
entitlement.created = past_datetime
self.enrollment.created = past_datetime
self.course.start = past_datetime
entitlement.save()
self.course.save()
self.enrollment.save()
assert entitlement.is_entitlement_refundable() is False
# Removing the entitlement being redeemed, make sure that the entitlement is refundable
entitlement.enrollment_course_run = None
assert entitlement.is_entitlement_refundable() is True
def test_is_entitlement_regainable(self):
"""
Test that the entitlement is not expired when created now, and is expired when created20 days
ago with a policy that sets the expiration period to 14 days
"""
entitlement = CourseEntitlementFactory.create(enrollment_course_run=self.enrollment)
assert entitlement.is_entitlement_regainable() is True
# Create and associate a GeneratedCertificate for a user and course and make sure it isn't regainable
GeneratedCertificateFactory(
user=entitlement.user,
course_id=entitlement.enrollment_course_run.course_id,
mode=MODES.verified,
status=CertificateStatuses.downloadable,
)
assert entitlement.is_entitlement_regainable() is False
# Create a date 20 days in the past (greater than the policy expire period of 14 days)
# and apply it to both the entitlement and the course
past_datetime = datetime.utcnow().replace(tzinfo=pytz.UTC) - timedelta(days=20)
entitlement = CourseEntitlementFactory.create(enrollment_course_run=self.enrollment, created=past_datetime)
self.enrollment.created = past_datetime
self.course.start = past_datetime
self.course.save()
self.enrollment.save()
assert entitlement.is_entitlement_regainable() is False
def test_get_days_until_expiration(self):
"""
Test that the expiration period is always less than or equal to the policy expiration
"""
entitlement = CourseEntitlementFactory.create(enrollment_course_run=self.enrollment)
# This will always either be 1 less than the expiration_period_days because the get_days_until_expiration
# method will have had at least some time pass between object creation in setUp and this method execution,
# or the exact same as the original expiration_period_days if somehow no time has passed
assert entitlement.get_days_until_expiration() <= entitlement.policy.expiration_period.days
def test_expired_at_datetime(self):
"""
Tests that using the getter method properly updates the expired_at field for an entitlement
"""
# Verify a brand new entitlement isn't expired and the db row isn't updated
entitlement = CourseEntitlementFactory.create()
expired_at_datetime = entitlement.expired_at_datetime
assert expired_at_datetime is None
assert entitlement.expired_at is None
# Verify an entitlement from two years ago is expired and the db row is updated
past_datetime = datetime.utcnow().replace(tzinfo=pytz.UTC) - timedelta(days=365 * 2)
entitlement.created = past_datetime
entitlement.save()
expired_at_datetime = entitlement.expired_at_datetime
assert expired_at_datetime
assert entitlement.expired_at
# Verify that a brand new entitlement that has been redeemed is not expired
entitlement = CourseEntitlementFactory.create(enrollment_course_run=self.enrollment)
assert entitlement.enrollment_course_run
expired_at_datetime = entitlement.expired_at_datetime
assert expired_at_datetime is None
assert entitlement.expired_at is None
# Verify that an entitlement that has been redeemed but not within 14 days
# and the course started more than two weeks ago is expired
past_datetime = datetime.utcnow().replace(tzinfo=pytz.UTC) - timedelta(days=20)
entitlement.created = past_datetime
self.enrollment.created = past_datetime
self.course.start = past_datetime
entitlement.save()
self.course.save()
self.enrollment.save()
assert entitlement.enrollment_course_run
expired_at_datetime = entitlement.expired_at_datetime
assert expired_at_datetime
assert entitlement.expired_at
# Verify a date 451 days in the past (1 days after the policy expiration)
# That is enrolled and started in within the regain period is still expired
entitlement = CourseEntitlementFactory.create(enrollment_course_run=self.enrollment)
expired_datetime = datetime.utcnow().replace(tzinfo=pytz.UTC) - timedelta(days=451)
entitlement.created = expired_datetime
now = datetime.now(tz=pytz.UTC)
self.enrollment.created = now
self.course.start = now
entitlement.save()
self.course.save()
self.enrollment.save()
assert entitlement.enrollment_course_run
expired_at_datetime = entitlement.expired_at_datetime
assert expired_at_datetime
assert entitlement.expired_at
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment