From 4fabc1dd78d605afb36fa9acdc286d2a67cde3e9 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Thu, 14 Mar 2024 18:28:14 +0000 Subject: [PATCH 1/6] feat(model): add expiration-related fields to EligibilityType `supports_expiration` serves as a toggle for expiration functionality. the other fields are for configuring the duration before expiration and reenrollment. --- .../0003_eligibilitytype_expiration.py | 28 +++++++++++++++++++ benefits/core/models.py | 3 ++ 2 files changed, 31 insertions(+) create mode 100644 benefits/core/migrations/0003_eligibilitytype_expiration.py diff --git a/benefits/core/migrations/0003_eligibilitytype_expiration.py b/benefits/core/migrations/0003_eligibilitytype_expiration.py new file mode 100644 index 000000000..2cf1a88e4 --- /dev/null +++ b/benefits/core/migrations/0003_eligibilitytype_expiration.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.2 on 2024-03-14 20:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0002_paymentprocessor_backoffice_api"), + ] + + operations = [ + migrations.AddField( + model_name="eligibilitytype", + name="expiration_days", + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="eligibilitytype", + name="expiration_reenrollment_days", + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="eligibilitytype", + name="supports_expiration", + field=models.BooleanField(default=False), + ), + ] diff --git a/benefits/core/models.py b/benefits/core/models.py index 72561c93a..7e807f534 100644 --- a/benefits/core/models.py +++ b/benefits/core/models.py @@ -109,6 +109,9 @@ class EligibilityType(models.Model): name = models.TextField() label = models.TextField() group_id = models.TextField() + supports_expiration = models.BooleanField(default=False) + expiration_days = models.PositiveSmallIntegerField(null=True, blank=True) + expiration_reenrollment_days = models.PositiveSmallIntegerField(null=True, blank=True) def __str__(self): return self.label From df7ee1d0628beba3b6c35ddddcdfeb27ffde6943 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Thu, 14 Mar 2024 18:58:41 +0000 Subject: [PATCH 2/6] feat(model): set up custom admin for EligibilityType this is to prepare for implementing custom validation --- benefits/core/admin.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/benefits/core/admin.py b/benefits/core/admin.py index cb468b629..a4cb6f9cd 100644 --- a/benefits/core/admin.py +++ b/benefits/core/admin.py @@ -5,6 +5,7 @@ import logging import requests +from django import forms from django.conf import settings from django.contrib import admin from . import models @@ -16,7 +17,6 @@ for model in [ - models.EligibilityType, models.EligibilityVerifier, models.PaymentProcessor, models.PemData, @@ -26,6 +26,20 @@ admin.site.register(model) +class EligibilityTypeForm(forms.ModelForm): + class Meta: + model = models.EligibilityType + exclude = [] + + +class EligibilityTypeAdmin(admin.ModelAdmin): + form = EligibilityTypeForm + + +logger.debug(f"Register {models.EligibilityType.__name__} with custom admin {EligibilityTypeAdmin.__name__}") +admin.site.register(models.EligibilityType, EligibilityTypeAdmin) + + def pre_login_user(user, request): logger.debug(f"Running pre-login callback for user: {user.username}") token = request.session.get("google_sso_access_token") From d34b7199fa5766c1d9ac4db5ac7339883be77b42 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Thu, 14 Mar 2024 19:16:10 +0000 Subject: [PATCH 3/6] feat: validate EligibilityType expiration days and reenrollment days --- benefits/core/admin.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/benefits/core/admin.py b/benefits/core/admin.py index a4cb6f9cd..a2d061e50 100644 --- a/benefits/core/admin.py +++ b/benefits/core/admin.py @@ -8,6 +8,7 @@ from django import forms from django.conf import settings from django.contrib import admin +from django.core.exceptions import ValidationError from . import models logger = logging.getLogger(__name__) @@ -31,6 +32,19 @@ class Meta: model = models.EligibilityType exclude = [] + def clean(self): + cleaned_data = super().clean() + supports_expiration = cleaned_data.get("supports_expiration") + expiration_days = cleaned_data.get("expiration_days") + expiration_reenrollment_days = cleaned_data.get("expiration_reenrollment_days") + + if supports_expiration: + message = "When support_expiration is True, this value must be greater than 0." + if expiration_days is None or expiration_days <= 0: + self.add_error("expiration_days", ValidationError(message)) + if expiration_reenrollment_days is None or expiration_reenrollment_days <= 0: + self.add_error("expiration_reenrollment_days", ValidationError(message)) + class EligibilityTypeAdmin(admin.ModelAdmin): form = EligibilityTypeForm From 65d4ddaff5afd76cdf085304165fc00cc5a4b70c Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Thu, 14 Mar 2024 20:28:08 +0000 Subject: [PATCH 4/6] test(form): supports_expiration being False results in valid form --- tests/pytest/core/test_admin.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/pytest/core/test_admin.py b/tests/pytest/core/test_admin.py index 36bfa3434..2e0f4dfde 100644 --- a/tests/pytest/core/test_admin.py +++ b/tests/pytest/core/test_admin.py @@ -1,7 +1,7 @@ import pytest from django.contrib.auth.models import User import benefits.core.admin -from benefits.core.admin import GOOGLE_USER_INFO_URL, pre_login_user +from benefits.core.admin import GOOGLE_USER_INFO_URL, pre_login_user, EligibilityTypeForm @pytest.fixture @@ -62,3 +62,21 @@ def test_pre_login_user_no_session_token(mocker, model_AdminUser): assert model_AdminUser.last_name == "" assert model_AdminUser.username == "" logger_spy.warning.assert_called_once() + + +def eligibility_type_form_data(supports_expiration=False, expiration_days=None, expiration_reenrollment_days=None): + form_data = { + "name": "calfresh", + "label": "CalFresh recipients", + "group_id": "123", + "supports_expiration": supports_expiration, + } + + return form_data + + +@pytest.mark.django_db +def test_EligibilityTypeForm_supports_expiration_False(): + form_data = eligibility_type_form_data(supports_expiration=False) + form = EligibilityTypeForm(form_data) + assert form.is_valid() From dcb23bdabda63c38e9bd070d2c611b4204632332 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Thu, 14 Mar 2024 20:30:13 +0000 Subject: [PATCH 5/6] test(form): form is not valid for non-positive expiration values --- tests/pytest/core/test_admin.py | 106 ++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/tests/pytest/core/test_admin.py b/tests/pytest/core/test_admin.py index 2e0f4dfde..de1ca6fbe 100644 --- a/tests/pytest/core/test_admin.py +++ b/tests/pytest/core/test_admin.py @@ -72,6 +72,12 @@ def eligibility_type_form_data(supports_expiration=False, expiration_days=None, "supports_expiration": supports_expiration, } + if expiration_days: + form_data.update(expiration_days=expiration_days) + + if expiration_reenrollment_days: + form_data.update(expiration_reenrollment_days=expiration_reenrollment_days) + return form_data @@ -80,3 +86,103 @@ def test_EligibilityTypeForm_supports_expiration_False(): form_data = eligibility_type_form_data(supports_expiration=False) form = EligibilityTypeForm(form_data) assert form.is_valid() + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "valid_expiration_reenrollment_days", + ["1", "14", "30"], + ids=lambda n: f"negative expiration_days, valid expiration_enrollment_days ({n})", +) +def test_EligibilityTypeForm_supports_expiration_True_negative_expiration_days(valid_expiration_reenrollment_days): + form_data = eligibility_type_form_data( + supports_expiration=True, expiration_days=-20, expiration_reenrollment_days=valid_expiration_reenrollment_days + ) + form = EligibilityTypeForm(form_data) + + # assert state of the form + assert not form.is_valid() + assert len(form.errors) == 1 + + # assert state of specific field + errors = form.errors["expiration_days"] + assert len(errors) == 2 + + # error message coming from PositiveSmallIntegerField validation + assert errors[0] == "Ensure this value is greater than or equal to 0." + # our custom validation message for when supports_expiration is True + assert errors[1] == "When support_expiration is True, this value must be greater than 0." + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "valid_expiration_reenrollment_days", + ["1", "14", "30"], + ids=lambda n: f"zero expiration days, valid expiration_enrollment_days ({n})", +) +def test_EligibilityTypeForm_supports_expiration_True_zero_expiration_days(valid_expiration_reenrollment_days): + form_data = eligibility_type_form_data( + supports_expiration=True, expiration_days=0, expiration_reenrollment_days=valid_expiration_reenrollment_days + ) + form = EligibilityTypeForm(form_data) + + # assert state of the form + assert not form.is_valid() + assert len(form.errors) == 1 + + # assert state of specific field + errors = form.errors["expiration_days"] + assert len(errors) == 1 + + # our custom validation message for when supports_expiration is True + assert errors[0] == "When support_expiration is True, this value must be greater than 0." + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "valid_expiration_days", + ["1", "14", "30"], + ids=lambda n: f"valid expiration_days ({n}), negative expiration_reenrollment_days", +) +def test_EligibilityTypeForm_supports_expiration_True_negative_expiration_reenrollment_days(valid_expiration_days): + form_data = eligibility_type_form_data( + supports_expiration=True, expiration_days=valid_expiration_days, expiration_reenrollment_days=-20 + ) + form = EligibilityTypeForm(form_data) + + # assert state of the form + assert not form.is_valid() + assert len(form.errors) == 1 + + # assert state of specific field + errors = form.errors["expiration_reenrollment_days"] + assert len(errors) == 2 + + # error message coming from PositiveSmallIntegerField validation + assert errors[0] == "Ensure this value is greater than or equal to 0." + # our custom validation message for when supports_expiration is True + assert errors[1] == "When support_expiration is True, this value must be greater than 0." + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "valid_expiration_days", + ["1", "14", "30"], + ids=lambda n: f"valid expiration_days ({n}), zero expiration_reenrollment_days", +) +def test_EligibilityTypeForm_supports_expiration_True_zero_expiration_reenrollment_days(valid_expiration_days): + form_data = eligibility_type_form_data( + supports_expiration=True, expiration_days=valid_expiration_days, expiration_reenrollment_days=0 + ) + form = EligibilityTypeForm(form_data) + + # assert state of the form + assert not form.is_valid() + assert len(form.errors) == 1 + + # assert state of specific field + errors = form.errors["expiration_reenrollment_days"] + assert len(errors) == 1 + + # our custom validation message for when supports_expiration is True + assert errors[0] == "When support_expiration is True, this value must be greater than 0." From fa36f7b1cd6f42c8b391f5a892a9863d9f0d8216 Mon Sep 17 00:00:00 2001 From: Angela Tran Date: Thu, 14 Mar 2024 20:34:47 +0000 Subject: [PATCH 6/6] test(form): form is valid for positive expiration values --- tests/pytest/core/test_admin.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/pytest/core/test_admin.py b/tests/pytest/core/test_admin.py index de1ca6fbe..358d958ff 100644 --- a/tests/pytest/core/test_admin.py +++ b/tests/pytest/core/test_admin.py @@ -186,3 +186,17 @@ def test_EligibilityTypeForm_supports_expiration_True_zero_expiration_reenrollme # our custom validation message for when supports_expiration is True assert errors[0] == "When support_expiration is True, this value must be greater than 0." + + +@pytest.mark.django_db +@pytest.mark.parametrize("expiration_days", ["1", "14", "3000"], ids=lambda n: f"expiration_days ({n})") +@pytest.mark.parametrize( + "expiration_reenrollment_days", ["1", "14", "3000"], ids=lambda n: f"expiration_reenrollment_days ({n})" +) +def test_EligibilityTypeForm_supports_expiration_True_valid(expiration_days, expiration_reenrollment_days): + form_data = eligibility_type_form_data( + supports_expiration=True, expiration_days=expiration_days, expiration_reenrollment_days=expiration_reenrollment_days + ) + form = EligibilityTypeForm(form_data) + + assert form.is_valid()