Skip to content

Commit

Permalink
Merge pull request #425 from openedx/pwnage101/ENT-8518
Browse files Browse the repository at this point in the history
feat: add late redemptions support to policies
  • Loading branch information
pwnage101 committed Mar 28, 2024
2 parents c08d23f + 762211c commit b43c313
Show file tree
Hide file tree
Showing 20 changed files with 427 additions and 31 deletions.
15 changes: 14 additions & 1 deletion enterprise_access/apps/api/serializers/subsidy_access_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ class Meta:
'aggregates',
'assignment_configuration',
'group_associations',
'late_redemption_allowed_until',
'is_late_redemption_allowed',
]
read_only_fields = fields

Expand Down Expand Up @@ -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': {
Expand Down Expand Up @@ -265,6 +269,10 @@ class Meta:
'allow_null': True,
'required': False,
},
'late_redemption_allowed_until': {
'allow_null': True,
'required': False,
},
}

@property
Expand Down Expand Up @@ -417,6 +425,7 @@ class Meta:
'subsidy_active_datetime',
'subsidy_expiration_datetime',
'is_subsidy_active',
'late_redemption_allowed_until',
)
extra_kwargs = {
'display_name': {
Expand Down Expand Up @@ -475,6 +484,10 @@ class Meta:
'allow_null': True,
'required': False,
},
'late_redemption_allowed_until': {
'allow_null': True,
'required': False,
},
}

def validate(self, attrs):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
},
]

Expand Down Expand Up @@ -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())

Expand Down Expand Up @@ -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),
Expand All @@ -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())
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__)

Expand All @@ -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.
"""
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -157,6 +193,7 @@ class PerLearnerEnrollmentCreditAccessPolicy(DjangoQLSearchMixin, BaseSubsidyAcc
'retired',
'catalog_uuid',
'subsidy_uuid',
'late_redemption_allowed_until',
'created',
'modified',
]
Expand Down Expand Up @@ -209,6 +246,7 @@ class PerLearnerSpendCreditAccessPolicy(DjangoQLSearchMixin, BaseSubsidyAccessPo
'retired',
'catalog_uuid',
'subsidy_uuid',
'late_redemption_allowed_until',
'created',
'modified',
]
Expand Down Expand Up @@ -266,6 +304,7 @@ class LearnerContentAssignmentAccessPolicy(DjangoQLSearchMixin, BaseSubsidyAcces
'retired',
'catalog_uuid',
'subsidy_uuid',
'late_redemption_allowed_until',
'assignment_configuration',
'created',
'modified',
Expand Down
35 changes: 35 additions & 0 deletions enterprise_access/apps/subsidy_access_policy/admin/forms.py
Original file line number Diff line number Diff line change
@@ -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,
)
10 changes: 10 additions & 0 deletions enterprise_access/apps/subsidy_access_policy/admin/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""
Admin utilities.
"""


class UrlNames:
"""
Collection on URL names used in admin
"""
SET_LATE_REDEMPTION = "set_late_redemption"
93 changes: 93 additions & 0 deletions enterprise_access/apps/subsidy_access_policy/admin/views.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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),
),
]
Loading

0 comments on commit b43c313

Please sign in to comment.