From a567719236fa98f44cdd9e1b359a457e0ee87c13 Mon Sep 17 00:00:00 2001 From: Jeff LaJoie Date: Wed, 15 Nov 2017 12:39:49 -0500 Subject: [PATCH] LEARNER-2983: Adding of helper functions and modification of DRF to use SiteConfiguration policy --- common/djangoapps/entitlements/admin.py | 15 +- .../entitlements/api/v1/tests/test_views.py | 67 ++++++- .../djangoapps/entitlements/api/v1/views.py | 40 +++- .../migrations/0003_auto_20171205_1431.py | 41 ++++ common/djangoapps/entitlements/models.py | 185 +++++++++++++++++- .../entitlements/tests/factories.py | 16 +- .../entitlements/tests/test_models.py | 180 +++++++++++++++++ 7 files changed, 530 insertions(+), 14 deletions(-) create mode 100644 common/djangoapps/entitlements/migrations/0003_auto_20171205_1431.py create mode 100644 common/djangoapps/entitlements/tests/test_models.py diff --git a/common/djangoapps/entitlements/admin.py b/common/djangoapps/entitlements/admin.py index 133582ac73..dd167097c5 100644 --- a/common/djangoapps/entitlements/admin.py +++ b/common/djangoapps/entitlements/admin.py @@ -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') diff --git a/common/djangoapps/entitlements/api/v1/tests/test_views.py b/common/djangoapps/entitlements/api/v1/tests/test_views.py index 5e4669a9fd..a74ac51ecb 100644 --- a/common/djangoapps/entitlements/api/v1/tests/test_views.py +++ b/common/djangoapps/entitlements/api/v1/tests/test_views.py @@ -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) diff --git a/common/djangoapps/entitlements/api/v1/views.py b/common/djangoapps/entitlements/api/v1/views.py index d2eb4a19af..68f3a1eb3a 100644 --- a/common/djangoapps/entitlements/api/v1/views.py +++ b/common/djangoapps/entitlements/api/v1/views.py @@ -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): """ diff --git a/common/djangoapps/entitlements/migrations/0003_auto_20171205_1431.py b/common/djangoapps/entitlements/migrations/0003_auto_20171205_1431.py new file mode 100644 index 0000000000..6e9fe2dadf --- /dev/null +++ b/common/djangoapps/entitlements/migrations/0003_auto_20171205_1431.py @@ -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), + ), + ] diff --git a/common/djangoapps/entitlements/models.py b/common/djangoapps/entitlements/models.py index eb43bd6b32..a272fe1724 100644 --- a/common/djangoapps/entitlements/models.py +++ b/common/djangoapps/entitlements/models.py @@ -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) diff --git a/common/djangoapps/entitlements/tests/factories.py b/common/djangoapps/entitlements/tests/factories.py index 6daa8ccb31..cd60f3e83f 100644 --- a/common/djangoapps/entitlements/tests/factories.py +++ b/common/djangoapps/entitlements/tests/factories.py @@ -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) diff --git a/common/djangoapps/entitlements/tests/test_models.py b/common/djangoapps/entitlements/tests/test_models.py new file mode 100644 index 0000000000..ec4f0e25e4 --- /dev/null +++ b/common/djangoapps/entitlements/tests/test_models.py @@ -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