Merge pull request #16627 from edx/jlajoie/LEARNER-2983
LEARNER-2983: Adds policy for entitlements
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
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')
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
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)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# -*- 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),
|
||||
),
|
||||
]
|
||||
@@ -1,8 +1,122 @@
|
||||
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)
|
||||
|
||||
180
common/djangoapps/entitlements/tests/test_models.py
Normal file
180
common/djangoapps/entitlements/tests/test_models.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user