From 762211c69169fe0fa19e28c352a9e64650c49cdf Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Wed, 20 Mar 2024 14:27:49 -0700 Subject: [PATCH] feat: Add support for late redemption to policies * Add a new database column to policies: `late_redemption_allowed_until`. * Add a calculated serializer field for policies: `is_late_redemption_allowed`. * Add a django object action with custom activation page to limit scope of feature to 7 days. * Conditionally pass a special `late_enrollment` hint to the subsidy service while creating transactions. ENT-8518 --- .../api/serializers/subsidy_access_policy.py | 15 ++- .../tests/test_subsidy_access_policy_views.py | 16 ++++ .../{admin.py => admin/__init__.py} | 41 +++++++- .../apps/subsidy_access_policy/admin/forms.py | 35 +++++++ .../apps/subsidy_access_policy/admin/utils.py | 10 ++ .../apps/subsidy_access_policy/admin/views.py | 93 +++++++++++++++++++ ...esspolicy_late_redemption_allowed_until.py | 23 +++++ .../apps/subsidy_access_policy/models.py | 25 ++++- .../tests/test_models.py | 85 +++++++++++++++++ enterprise_access/settings/base.py | 2 + .../admin/set_late_redemption.html | 58 ++++++++++++ requirements/base.in | 1 + requirements/base.txt | 2 + requirements/common_constraints.txt.tmp | 11 ++- requirements/dev.txt | 12 +-- requirements/doc.txt | 2 + requirements/production.txt | 2 + requirements/quality.txt | 9 +- requirements/test.txt | 2 + requirements/validation.txt | 14 +-- 20 files changed, 427 insertions(+), 31 deletions(-) rename enterprise_access/apps/subsidy_access_policy/{admin.py => admin/__init__.py} (84%) create mode 100644 enterprise_access/apps/subsidy_access_policy/admin/forms.py create mode 100644 enterprise_access/apps/subsidy_access_policy/admin/utils.py create mode 100644 enterprise_access/apps/subsidy_access_policy/admin/views.py create mode 100644 enterprise_access/apps/subsidy_access_policy/migrations/0024_subsidyaccesspolicy_late_redemption_allowed_until.py create mode 100644 enterprise_access/templates/subsidy_access_policy/admin/set_late_redemption.html diff --git a/enterprise_access/apps/api/serializers/subsidy_access_policy.py b/enterprise_access/apps/api/serializers/subsidy_access_policy.py index dee86765..59ed8376 100644 --- a/enterprise_access/apps/api/serializers/subsidy_access_policy.py +++ b/enterprise_access/apps/api/serializers/subsidy_access_policy.py @@ -176,6 +176,8 @@ class Meta: 'aggregates', 'assignment_configuration', 'group_associations', + 'late_redemption_allowed_until', + 'is_late_redemption_allowed', ] read_only_fields = fields @@ -213,8 +215,10 @@ class Meta: 'subsidy_expiration_datetime', 'is_subsidy_active', 'group_associations', + 'late_redemption_allowed_until', + 'is_late_redemption_allowed', ] - read_only_fields = ['uuid'] + read_only_fields = ['uuid', 'is_late_redemption_allowed'] extra_kwargs = { 'uuid': {'read_only': True}, 'display_name': { @@ -265,6 +269,10 @@ class Meta: 'allow_null': True, 'required': False, }, + 'late_redemption_allowed_until': { + 'allow_null': True, + 'required': False, + }, } @property @@ -417,6 +425,7 @@ class Meta: 'subsidy_active_datetime', 'subsidy_expiration_datetime', 'is_subsidy_active', + 'late_redemption_allowed_until', ) extra_kwargs = { 'display_name': { @@ -475,6 +484,10 @@ class Meta: 'allow_null': True, 'required': False, }, + 'late_redemption_allowed_until': { + 'allow_null': True, + 'required': False, + }, } def validate(self, attrs): diff --git a/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py b/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py index a46b1752..0f52a589 100644 --- a/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py +++ b/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py @@ -305,6 +305,8 @@ def test_detail_view(self, role_context_dict): }, 'assignment_configuration': None, 'group_associations': [str(enterprise_group_uuid)], + 'late_redemption_allowed_until': None, + 'is_late_redemption_allowed': False, }, response.json()) @ddt.data( @@ -398,6 +400,8 @@ def test_list_view(self, role_context_dict): }, 'assignment_configuration': None, 'group_associations': [], + 'late_redemption_allowed_until': None, + 'is_late_redemption_allowed': False, }, { 'access_method': 'direct', @@ -426,6 +430,8 @@ def test_list_view(self, role_context_dict): }, 'assignment_configuration': None, 'group_associations': [], + 'late_redemption_allowed_until': None, + 'is_late_redemption_allowed': False, }, ] @@ -521,6 +527,8 @@ def test_destroy_view(self, request_payload, expected_change_reason): }, 'assignment_configuration': None, 'group_associations': [], + 'late_redemption_allowed_until': None, + 'is_late_redemption_allowed': False, } self.assertEqual(expected_response, response.json()) @@ -615,6 +623,7 @@ def test_update_views(self, is_patch, request_payload): 'per_learner_enrollment_limit': policy_for_edit.per_learner_enrollment_limit, 'spend_limit': policy_for_edit.spend_limit, 'subsidy_uuid': str(policy_for_edit.subsidy_uuid), + 'late_redemption_allowed_until': None, # All the rest of the fields that we do not support PATCHing. 'uuid': str(policy_for_edit.uuid), @@ -633,6 +642,7 @@ def test_update_views(self, is_patch, request_payload): }, 'assignment_configuration': None, 'group_associations': [], + 'is_late_redemption_allowed': False, } expected_response.update(request_payload) self.assertEqual(expected_response, response.json()) @@ -852,6 +862,8 @@ def test_create_view(self, policy_type, extra_fields, expected_response_code, ex expected_response = payload.copy() expected_response.setdefault("per_learner_enrollment_limit") expected_response.setdefault("per_learner_spend_limit") + expected_response["late_redemption_allowed_until"] = None + expected_response["is_late_redemption_allowed"] = False assert response_json == expected_response elif expected_response_code == status.HTTP_400_BAD_REQUEST: for expected_error_keyword in expected_error_keywords: @@ -909,6 +921,8 @@ def test_idempotent_create_view(self, policy_type, extra_fields, expected_respon expected_response = payload.copy() expected_response.setdefault("per_learner_enrollment_limit") expected_response.setdefault("per_learner_spend_limit") + expected_response["late_redemption_allowed_until"] = None + expected_response["is_late_redemption_allowed"] = False assert response_json == expected_response # Test idempotency @@ -923,6 +937,8 @@ def test_idempotent_create_view(self, policy_type, extra_fields, expected_respon expected_response = payload.copy() expected_response.setdefault("per_learner_enrollment_limit") expected_response.setdefault("per_learner_spend_limit") + expected_response["late_redemption_allowed_until"] = None + expected_response["is_late_redemption_allowed"] = False assert response_json == expected_response diff --git a/enterprise_access/apps/subsidy_access_policy/admin.py b/enterprise_access/apps/subsidy_access_policy/admin/__init__.py similarity index 84% rename from enterprise_access/apps/subsidy_access_policy/admin.py rename to enterprise_access/apps/subsidy_access_policy/admin/__init__.py index 996613b1..9bffd140 100644 --- a/enterprise_access/apps/subsidy_access_policy/admin.py +++ b/enterprise_access/apps/subsidy_access_policy/admin/__init__.py @@ -4,8 +4,11 @@ from django.conf import settings from django.contrib import admin +from django.http import HttpResponseRedirect +from django.urls import re_path, reverse from django.utils.safestring import mark_safe from django.utils.text import Truncator # for shortening a text +from django_object_actions import DjangoObjectActions, action from djangoql.admin import DjangoQLSearchMixin from pygments import highlight from pygments.formatters import HtmlFormatter # pylint: disable=no-name-in-module @@ -14,6 +17,8 @@ from enterprise_access.apps.api.serializers.subsidy_access_policy import SubsidyAccessPolicyResponseSerializer from enterprise_access.apps.subsidy_access_policy import constants, models +from enterprise_access.apps.subsidy_access_policy.admin.utils import UrlNames +from enterprise_access.apps.subsidy_access_policy.admin.views import SubsidyAccessPolicySetLateRedemptionView logger = logging.getLogger(__name__) @@ -40,7 +45,7 @@ def cents_to_usd_string(cents): return "${:,.2f}".format(float(cents) / constants.CENTS_PER_DOLLAR) -class BaseSubsidyAccessPolicyMixin(SimpleHistoryAdmin): +class BaseSubsidyAccessPolicyMixin(DjangoObjectActions, SimpleHistoryAdmin): """ Mixin for common admin properties on subsidy access policy models. """ @@ -68,9 +73,40 @@ class BaseSubsidyAccessPolicyMixin(SimpleHistoryAdmin): 'created', 'modified', 'policy_spend_limit_dollars', + 'late_redemption_allowed_until', + 'is_late_redemption_allowed', 'api_serialized_repr', ) + change_actions = ( + 'set_late_redemption', + ) + + @action( + label='Set Late Redemption', + description='Enable/disable the "late redemption" feature for this policy' + ) + def set_late_redemption(self, request, obj): + """ + Object tool handler method - redirects to set_late_redemption view. + """ + # url names coming from get_urls are prefixed with 'admin' namespace + set_late_redemption_url = reverse('admin:' + UrlNames.SET_LATE_REDEMPTION, args=(obj.uuid,)) + return HttpResponseRedirect(set_late_redemption_url) + + def get_urls(self): + """ + Returns the additional urls used by the custom object tools. + """ + additional_urls = [ + re_path( + r"^([^/]+)/set_late_redemption", + self.admin_site.admin_view(SubsidyAccessPolicySetLateRedemptionView.as_view()), + name=UrlNames.SET_LATE_REDEMPTION, + ), + ] + return additional_urls + super().get_urls() + @admin.display(description='REST API serialization') def api_serialized_repr(self, obj): """ @@ -157,6 +193,7 @@ class PerLearnerEnrollmentCreditAccessPolicy(DjangoQLSearchMixin, BaseSubsidyAcc 'retired', 'catalog_uuid', 'subsidy_uuid', + 'late_redemption_allowed_until', 'created', 'modified', ] @@ -209,6 +246,7 @@ class PerLearnerSpendCreditAccessPolicy(DjangoQLSearchMixin, BaseSubsidyAccessPo 'retired', 'catalog_uuid', 'subsidy_uuid', + 'late_redemption_allowed_until', 'created', 'modified', ] @@ -266,6 +304,7 @@ class LearnerContentAssignmentAccessPolicy(DjangoQLSearchMixin, BaseSubsidyAcces 'retired', 'catalog_uuid', 'subsidy_uuid', + 'late_redemption_allowed_until', 'assignment_configuration', 'created', 'modified', diff --git a/enterprise_access/apps/subsidy_access_policy/admin/forms.py b/enterprise_access/apps/subsidy_access_policy/admin/forms.py new file mode 100644 index 00000000..a831b799 --- /dev/null +++ b/enterprise_access/apps/subsidy_access_policy/admin/forms.py @@ -0,0 +1,35 @@ +""" +Forms to be used for subsidy_access_policy django admin. +""" +from django import forms +from django.utils.translation import gettext as _ + + +class LateRedemptionDaysFromNowChoices: + """ + Enumerate different choices for the type of Subsidy. For example, this can be used to control whether enrollments + associated with this Subsidy should be rev rec'd through our standard commercial process or not. + """ + DISABLE_NOW = "disable_now" + CHOICES = ( + (DISABLE_NOW, _("Disable now")), + ("1", _("1")), + ("2", _("2")), + ("3", _("3")), + ("4", _("4")), + ("5", _("5")), + ("6", _("6")), + ("7", _("7")), + ) + + +class SetLateRedemptionForm(forms.Form): + """ + Form to set late redemption timeline. + """ + days_from_now = forms.ChoiceField( + label=_("Enable late redemptions until _ days from now"), + choices=LateRedemptionDaysFromNowChoices.CHOICES, + help_text=_("Unless disabled now, late redemptions will be disabled at midnight UTC of the selected day."), + required=True, + ) diff --git a/enterprise_access/apps/subsidy_access_policy/admin/utils.py b/enterprise_access/apps/subsidy_access_policy/admin/utils.py new file mode 100644 index 00000000..440887b1 --- /dev/null +++ b/enterprise_access/apps/subsidy_access_policy/admin/utils.py @@ -0,0 +1,10 @@ +""" +Admin utilities. +""" + + +class UrlNames: + """ + Collection on URL names used in admin + """ + SET_LATE_REDEMPTION = "set_late_redemption" diff --git a/enterprise_access/apps/subsidy_access_policy/admin/views.py b/enterprise_access/apps/subsidy_access_policy/admin/views.py new file mode 100644 index 00000000..8165f05a --- /dev/null +++ b/enterprise_access/apps/subsidy_access_policy/admin/views.py @@ -0,0 +1,93 @@ +""" +Custom Django Admin views for subsidy_access_policy app. +""" +from datetime import timedelta + +from django.conf import settings +from django.contrib import messages +from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.urls import reverse +from django.utils.translation import gettext as _ +from django.views.generic import View + +from enterprise_access.apps.subsidy_access_policy.admin.forms import ( + LateRedemptionDaysFromNowChoices, + SetLateRedemptionForm +) +from enterprise_access.apps.subsidy_access_policy.admin.utils import UrlNames +from enterprise_access.apps.subsidy_access_policy.models import SubsidyAccessPolicy +from enterprise_access.utils import localized_utcnow + + +class SubsidyAccessPolicySetLateRedemptionView(View): + """ + View which allows admins to set the late redemption timeline for a given policy. + """ + template = "subsidy_access_policy/admin/set_late_redemption.html" + + def get(self, request, policy_uuid): + """ + Handle GET request - render "Set Late Redemption" form. + + Args: + request (django.http.request.HttpRequest): Request instance + policy_uuid (str): Subsidy Access Policy UUID + + Returns: + django.http.response.HttpResponse: HttpResponse + """ + policy = SubsidyAccessPolicy.objects.get(uuid=policy_uuid) + opts = policy._meta + context = { + 'ENTERPRISE_LEARNER_PORTAL_URL': settings.ENTERPRISE_LEARNER_PORTAL_URL, + 'set_late_redemption_form': SetLateRedemptionForm(), + 'subsidy_access_policy': policy, + 'opts': opts, + } + return render(request, self.template, context) + + def post(self, request, policy_uuid): + """ + Handle POST request - handle form submissions. + + Arguments: + request (django.http.request.HttpRequest): Request instance + policy_uuid (str): Subsidy Access Policy UUID + + Returns: + django.http.response.HttpResponse: HttpResponse + """ + policy = SubsidyAccessPolicy.objects.get(uuid=policy_uuid) + set_late_redemption_form = SetLateRedemptionForm(request.POST) + + if set_late_redemption_form.is_valid(): + days_from_now = set_late_redemption_form.cleaned_data.get('days_from_now') + if days_from_now == LateRedemptionDaysFromNowChoices.DISABLE_NOW: + policy.late_redemption_allowed_until = None + policy.save() + else: + late_redemption_allowed_until = localized_utcnow() + timedelta(days=int(days_from_now)) + # Force time to the end-of-day UTC. This is consistent with the help text in the HTML template. + late_redemption_allowed_until = late_redemption_allowed_until.replace( + hour=23, + minute=59, + second=59, + microsecond=999999, + ) + policy.late_redemption_allowed_until = late_redemption_allowed_until + policy.save() + + messages.success(request, _("Successfully set late redemption.")) + + # Redirect to form GET if everything went smooth. + set_late_redemption_url = reverse("admin:" + UrlNames.SET_LATE_REDEMPTION, args=(policy_uuid,)) + return HttpResponseRedirect(set_late_redemption_url) + + # Somehow, form validation failed. Re-render form. + context = { + 'set_late_redemption_form': SetLateRedemptionForm(), + 'subsidy_access_policy': policy, + 'ENTERPRISE_LEARNER_PORTAL_URL': settings.ENTERPRISE_LEARNER_PORTAL_URL + } + return render(request, self.template, context) diff --git a/enterprise_access/apps/subsidy_access_policy/migrations/0024_subsidyaccesspolicy_late_redemption_allowed_until.py b/enterprise_access/apps/subsidy_access_policy/migrations/0024_subsidyaccesspolicy_late_redemption_allowed_until.py new file mode 100644 index 00000000..12162878 --- /dev/null +++ b/enterprise_access/apps/subsidy_access_policy/migrations/0024_subsidyaccesspolicy_late_redemption_allowed_until.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.9 on 2024-03-21 16:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('subsidy_access_policy', '0023_alter_policygroupassociation_enterprise_group_uuid'), + ] + + operations = [ + migrations.AddField( + model_name='historicalsubsidyaccesspolicy', + name='late_redemption_allowed_until', + field=models.DateTimeField(blank=True, help_text='Before this date, "late redemptions" will be allowed. If empty, late redemptions are disallowed.', null=True), + ), + migrations.AddField( + model_name='subsidyaccesspolicy', + name='late_redemption_allowed_until', + field=models.DateTimeField(blank=True, help_text='Before this date, "late redemptions" will be allowed. If empty, late redemptions are disallowed.', null=True), + ), + ] diff --git a/enterprise_access/apps/subsidy_access_policy/models.py b/enterprise_access/apps/subsidy_access_policy/models.py index 294d6720..fde3f77b 100644 --- a/enterprise_access/apps/subsidy_access_policy/models.py +++ b/enterprise_access/apps/subsidy_access_policy/models.py @@ -59,6 +59,10 @@ ) from .utils import ProxyAwareHistoricalRecords, create_idempotency_key_for_transaction, get_versioned_subsidy_client +# Magic key that is used transaction metadata hint to the subsidy service and all downstream services that the +# enrollment should be allowed even if the enrollment deadline has passed. +ALLOW_LATE_ENROLLMENT_KEY = 'allow_late_enrollment' + REQUEST_CACHE_NAMESPACE = 'subsidy_access_policy' POLICY_LOCK_RESOURCE_NAME = 'subsidy_access_policy' logger = logging.getLogger(__name__) @@ -184,6 +188,11 @@ class Meta: null=True, blank=True, ) + late_redemption_allowed_until = models.DateTimeField( + null=True, + blank=True, + help_text='Before this date, "late redemptions" will be allowed. If empty, late redemptions are disallowed.', + ) per_learner_enrollment_limit = models.IntegerField( null=True, blank=True, @@ -229,6 +238,15 @@ def is_redemption_enabled(self): """ return self.active and not self.retired + @property + def is_late_redemption_allowed(self): + """ + Return True if late redemption is currently allowed. + """ + if not self.late_redemption_allowed_until: + return False + return localized_utcnow() < self.late_redemption_allowed_until + @property def subsidy_active_datetime(self): """ @@ -809,13 +827,18 @@ def redeem(self, lms_user_id, content_key, all_transactions, metadata=None, **kw subsidy_access_policy_uuid=str(self.uuid), historical_redemptions_uuids=self._redemptions_for_idempotency_key(all_transactions), ) + # If this policy has late redemptions currently enabled, tell that to the subsidy service. + metadata_for_tx = metadata + if self.is_late_redemption_allowed: + metadata_for_tx = metadata.copy() if metadata else {} + metadata_for_tx[ALLOW_LATE_ENROLLMENT_KEY] = True try: creation_payload = { 'subsidy_uuid': str(self.subsidy_uuid), 'lms_user_id': lms_user_id, 'content_key': content_key, 'subsidy_access_policy_uuid': str(self.uuid), - 'metadata': metadata, + 'metadata': metadata_for_tx, 'idempotency_key': idempotency_key, } requested_price_cents = kwargs.get('requested_price_cents') diff --git a/enterprise_access/apps/subsidy_access_policy/tests/test_models.py b/enterprise_access/apps/subsidy_access_policy/tests/test_models.py index 6e785957..90e3277c 100644 --- a/enterprise_access/apps/subsidy_access_policy/tests/test_models.py +++ b/enterprise_access/apps/subsidy_access_policy/tests/test_models.py @@ -36,6 +36,7 @@ ) from enterprise_access.apps.subsidy_access_policy.exceptions import MissingAssignment, SubsidyAPIHTTPError from enterprise_access.apps.subsidy_access_policy.models import ( + ALLOW_LATE_ENROLLMENT_KEY, REQUEST_CACHE_NAMESPACE, AssignedLearnerCreditAccessPolicy, PerLearnerEnrollmentCreditAccessPolicy, @@ -50,6 +51,7 @@ PolicyGroupAssociationFactory ) from enterprise_access.cache_utils import request_cache +from enterprise_access.utils import localized_utcnow from test_utils import TEST_ENTERPRISE_GROUP_UUID, TEST_USER_RECORD, TEST_USER_RECORD_NO_GROUPS from ..constants import AccessMethods @@ -688,6 +690,89 @@ def test_subsidy_record_http_error(self): self.assertIsNone(policy.is_subsidy_active) self.assertEqual(policy.subsidy_balance(), 0) + @ddt.data( + # late redemption never set. + { + 'late_redemption_allowed_until': None, + 'metadata_provided_to_policy': None, + 'expected_metadata_sent_to_subsidy': None, + }, + # late redemption set, but has expired. + { + 'late_redemption_allowed_until': localized_utcnow() - timedelta(days=1), + 'metadata_provided_to_policy': None, + 'expected_metadata_sent_to_subsidy': None, + }, + # late redemption set and currently allowed. + { + 'late_redemption_allowed_until': localized_utcnow() + timedelta(days=1), + 'metadata_provided_to_policy': None, + 'expected_metadata_sent_to_subsidy': {ALLOW_LATE_ENROLLMENT_KEY: True}, + }, + # late redemption never set. + # + some metadata is provided. + { + 'late_redemption_allowed_until': None, + 'metadata_provided_to_policy': {'foo': 'bar'}, + 'expected_metadata_sent_to_subsidy': {'foo': 'bar'}, + }, + # late redemption set, but has expired. + # + some metadata is provided. + { + 'late_redemption_allowed_until': localized_utcnow() - timedelta(days=1), + 'metadata_provided_to_policy': {'foo': 'bar'}, + 'expected_metadata_sent_to_subsidy': {'foo': 'bar'}, + }, + # late redemption set and currently allowed. + # + some metadata is provided. + { + 'late_redemption_allowed_until': localized_utcnow() + timedelta(days=1), + 'metadata_provided_to_policy': {'foo': 'bar'}, + 'expected_metadata_sent_to_subsidy': {'foo': 'bar', ALLOW_LATE_ENROLLMENT_KEY: True}, + }, + ) + @ddt.unpack + def test_redeem_pass_late_enrollment( + self, + late_redemption_allowed_until, + metadata_provided_to_policy, + expected_metadata_sent_to_subsidy, + ): + """ + Test redeem() when the late redemption feature is involved. + """ + + # Set up the entire environment to make the policy and subsidy happy to redeem. + self.mock_lms_api_client.get_enterprise_user.return_value = TEST_USER_RECORD + self.mock_catalog_contains_content_key.return_value = True + self.mock_get_content_metadata.return_value = { + 'content_price': 200, + } + self.mock_subsidy_client.can_redeem.return_value = {'can_redeem': True, 'active': True} + self.mock_transactions_cache_for_learner.return_value = { + 'transactions': [], + 'aggregates': {'total_quantity': -100}, + } + self.mock_subsidy_client.list_subsidy_transactions.return_value = { + 'results': [], + 'aggregates': {'total_quantity': -200}, + } + self.mock_subsidy_client.create_subsidy_transaction.return_value = {'uuid': str(uuid4())} + + # Optionally swap out the test policy with one that allows late redemption. + test_policy = PerLearnerSpendCapLearnerCreditAccessPolicyFactory( + per_learner_spend_limit=500, + spend_limit=10000, + late_redemption_allowed_until=late_redemption_allowed_until, + ) + + # Do the redemption + test_policy.redeem(self.lms_user_id, self.course_id, [], metadata=metadata_provided_to_policy) + + # Assert that the metadata we send to enterprise-subsidy contains the allow_late_enrollment hint (or not). + assert self.mock_subsidy_client.create_subsidy_transaction.call_args.kwargs['metadata'] \ + == expected_metadata_sent_to_subsidy + class SubsidyAccessPolicyResolverTests(TestCase): """ SubsidyAccessPolicy.resolve_policy() tests. """ diff --git a/enterprise_access/settings/base.py b/enterprise_access/settings/base.py index 7338d19b..c4b06594 100644 --- a/enterprise_access/settings/base.py +++ b/enterprise_access/settings/base.py @@ -55,6 +55,7 @@ def root(*path_fragments): 'djangoql', 'django_celery_results', 'django_filters', + 'django_object_actions', 'rest_framework', 'rest_framework_swagger', 'rules.apps.AutodiscoverRulesConfig', @@ -228,6 +229,7 @@ def root(*path_fragments): 'django.template.context_processors.debug', 'django.template.context_processors.i18n', 'django.template.context_processors.media', + 'django.template.context_processors.request', 'django.template.context_processors.static', 'django.template.context_processors.tz', 'django.contrib.messages.context_processors.messages', diff --git a/enterprise_access/templates/subsidy_access_policy/admin/set_late_redemption.html b/enterprise_access/templates/subsidy_access_policy/admin/set_late_redemption.html new file mode 100644 index 00000000..dfdac15f --- /dev/null +++ b/enterprise_access/templates/subsidy_access_policy/admin/set_late_redemption.html @@ -0,0 +1,58 @@ +{% extends "admin/base_site.html" %} +{% load i18n static admin_urls %} + +{% block extrastyle %} + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+
+

{% trans "Set Late Redemption" %}

+

+ This tool allows temporary "late" enrollment for any learner redeeming content with this policy. Choose a period + of time (starting now) during which the late enrollment feature is enabled for this policy. During that time + period, course runs with enrollment deadlines within the last 30 days will become visible and enrollable again to + learners. +

+

+ It is encouraged that if you know specifically which historical course run learners must enroll into, you can + craft a course about page URL containing a course run filter: +

+

+ + {{ ENTERPRISE_LEARNER_PORTAL_URL }}/<enterprise_slug>/course/<course_key>?course_run_key=<course_run_key> + +

+
+ {% csrf_token %} + {# as_p will render the form fields wrapped in

tags: #} + {{ set_late_redemption_form.as_p }} + +

+
+
+{% endblock %} + +{% block footer %} + {{ block.super }} +{% endblock %} diff --git a/requirements/base.in b/requirements/base.in index ec92e501..11d4bf15 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -11,6 +11,7 @@ django-celery-results django-crum django-extensions django-filter +django-object-actions django-rest-swagger django-simple-history django-waffle diff --git a/requirements/base.txt b/requirements/base.txt index c6367898..f91fe86e 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -117,6 +117,8 @@ django-model-utils==4.4.0 # via # edx-celeryutils # edx-rbac +django-object-actions==4.2.0 + # via -r requirements/base.in django-rest-swagger==2.2.0 # via -r requirements/base.in django-simple-history==3.5.0 diff --git a/requirements/common_constraints.txt.tmp b/requirements/common_constraints.txt.tmp index d26d7918..8b0c901b 100644 --- a/requirements/common_constraints.txt.tmp +++ b/requirements/common_constraints.txt.tmp @@ -14,7 +14,7 @@ # using LTS django version -Django<4.0 +Django<5.0 # elasticsearch>=7.14.0 includes breaking changes in it which caused issues in discovery upgrade process. # elastic search changelog: https://www.elastic.co/guide/en/enterprise-search/master/release-notes-7.14.0.html @@ -22,3 +22,12 @@ elasticsearch<7.14.0 # django-simple-history>3.0.0 adds indexing and causes a lot of migrations to be affected + +# opentelemetry requires version 6.x at the moment: +# https://github.com/open-telemetry/opentelemetry-python/issues/3570 +# Normally this could be added as a constraint in edx-django-utils, where we're +# adding the opentelemetry dependency. However, when we compile pip-tools.txt, +# that uses version 7.x, and then there's no undoing that when compiling base.txt. +# So we need to pin it globally, for now. +# Ticket for unpinning: https://github.com/openedx/edx-lint/issues/407 +importlib-metadata<7 diff --git a/requirements/dev.txt b/requirements/dev.txt index d3ad8b8f..ac74bebc 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -132,7 +132,6 @@ cryptography==42.0.5 # via # -r requirements/validation.txt # pyjwt - # secretstorage # social-auth-core ddt==1.7.2 # via -r requirements/validation.txt @@ -203,6 +202,8 @@ django-model-utils==4.4.0 # -r requirements/validation.txt # edx-celeryutils # edx-rbac +django-object-actions==4.2.0 + # via -r requirements/validation.txt django-rest-swagger==2.2.0 # via -r requirements/validation.txt django-simple-history==3.5.0 @@ -339,11 +340,6 @@ jaraco-functools==4.0.0 # via # -r requirements/validation.txt # keyring -jeepney==0.8.0 - # via - # -r requirements/validation.txt - # keyring - # secretstorage jinja2==3.1.3 # via # -r requirements/validation.txt @@ -614,10 +610,6 @@ rpds-py==0.18.0 # referencing rules==3.3 # via -r requirements/validation.txt -secretstorage==3.3.3 - # via - # -r requirements/validation.txt - # keyring semantic-version==2.10.0 # via # -r requirements/validation.txt diff --git a/requirements/doc.txt b/requirements/doc.txt index 8666d0f8..52aa18bc 100644 --- a/requirements/doc.txt +++ b/requirements/doc.txt @@ -200,6 +200,8 @@ django-model-utils==4.4.0 # -r requirements/test.txt # edx-celeryutils # edx-rbac +django-object-actions==4.2.0 + # via -r requirements/test.txt django-rest-swagger==2.2.0 # via -r requirements/test.txt django-simple-history==3.5.0 diff --git a/requirements/production.txt b/requirements/production.txt index 566cd6f9..a086e375 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -145,6 +145,8 @@ django-model-utils==4.4.0 # -r requirements/base.txt # edx-celeryutils # edx-rbac +django-object-actions==4.2.0 + # via -r requirements/base.txt django-rest-swagger==2.2.0 # via -r requirements/base.txt django-simple-history==3.5.0 diff --git a/requirements/quality.txt b/requirements/quality.txt index c7111075..f41c19a5 100644 --- a/requirements/quality.txt +++ b/requirements/quality.txt @@ -125,7 +125,6 @@ cryptography==42.0.5 # via # -r requirements/test.txt # pyjwt - # secretstorage # social-auth-core ddt==1.7.2 # via -r requirements/test.txt @@ -191,6 +190,8 @@ django-model-utils==4.4.0 # -r requirements/test.txt # edx-celeryutils # edx-rbac +django-object-actions==4.2.0 + # via -r requirements/test.txt django-rest-swagger==2.2.0 # via -r requirements/test.txt django-simple-history==3.5.0 @@ -319,10 +320,6 @@ jaraco-context==4.3.0 # via keyring jaraco-functools==4.0.0 # via keyring -jeepney==0.8.0 - # via - # keyring - # secretstorage jinja2==3.1.3 # via # -r requirements/test.txt @@ -555,8 +552,6 @@ rpds-py==0.18.0 # referencing rules==3.3 # via -r requirements/test.txt -secretstorage==3.3.3 - # via keyring semantic-version==2.10.0 # via # -r requirements/test.txt diff --git a/requirements/test.txt b/requirements/test.txt index 61d2ff45..4977a924 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -176,6 +176,8 @@ django-model-utils==4.4.0 # -r requirements/base.txt # edx-celeryutils # edx-rbac +django-object-actions==4.2.0 + # via -r requirements/base.txt django-rest-swagger==2.2.0 # via -r requirements/base.txt django-simple-history==3.5.0 diff --git a/requirements/validation.txt b/requirements/validation.txt index d55312de..c18a6f7b 100644 --- a/requirements/validation.txt +++ b/requirements/validation.txt @@ -154,7 +154,6 @@ cryptography==42.0.5 # -r requirements/quality.txt # -r requirements/test.txt # pyjwt - # secretstorage # social-auth-core ddt==1.7.2 # via @@ -239,6 +238,10 @@ django-model-utils==4.4.0 # -r requirements/test.txt # edx-celeryutils # edx-rbac +django-object-actions==4.2.0 + # via + # -r requirements/quality.txt + # -r requirements/test.txt django-rest-swagger==2.2.0 # via # -r requirements/quality.txt @@ -415,11 +418,6 @@ jaraco-functools==4.0.0 # via # -r requirements/quality.txt # keyring -jeepney==0.8.0 - # via - # -r requirements/quality.txt - # keyring - # secretstorage jinja2==3.1.3 # via # -r requirements/quality.txt @@ -726,10 +724,6 @@ rules==3.3 # via # -r requirements/quality.txt # -r requirements/test.txt -secretstorage==3.3.3 - # via - # -r requirements/quality.txt - # keyring semantic-version==2.10.0 # via # -r requirements/quality.txt