Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: in-person eligibility policies #2689

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
23 changes: 23 additions & 0 deletions benefits/core/migrations/0036_in_person_enrollmentflows.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
("core", "0035_enrollmentflow_system_name_choices"),
]

def migrate_in_person_flows(apps, schema_editor):
EnrollmentFlow = apps.get_model("core", "EnrollmentFlow")
for flow in EnrollmentFlow.objects.all():
in_person = "in_person" # value of EnrollmentMethods.IN_PERSON as of this migration
if in_person in flow.supported_enrollment_methods:
if flow.system_name not in [
"senior",
"medicare",
"courtesy_card",
]: # the keys in `in_person.context.eligibility_index` as of this migration
flow.supported_enrollment_methods.remove(in_person)
flow.save()

operations = [migrations.RunPython(migrate_in_person_flows)]
22 changes: 18 additions & 4 deletions benefits/core/models/enrollment.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .claims import ClaimsProvider
from .transit import TransitAgency
from benefits.core import context as core_context
from benefits.in_person import context as in_person_context

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -257,8 +258,12 @@ def enrollment_success_template(self):
else:
return self.enrollment_success_template_override or f"{prefix}--{self.transit_agency.slug}.html"

@property
def in_person_eligibility_context(self):
return in_person_context.eligibility_index[self.system_name].dict()
Copy link
Member Author

@angela-tran angela-tran Feb 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@thekaveman - to follow up on #2698 (comment):

I rewrote this so that it is non-defensive and simply looks up the key. Consumers of this property are responsible for knowing what to do, whether that's to let the exception bubble up or to handle it.

Here's the diff: 21e0f69

That commit shows how InPersonEligibilityForm consumes the property. EnrollmentFlow.clean consumes it in a similar way.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see... yeah I don't think this is the specific use-case I was thinking about, and I didn't understand the difference in what we were talking about. I agree this change isn't as clean.

The use-case I was thinking about (related to #2698 (comment)): Why was it possible to load a fixture with a bad system_name? I expected this to have failed on import: #2698 (comment)

But I think you described why this is: #2698 (comment). And then it was fixed anyway by adding agency_card to the enum...

Sorry, I mixed up a few different things in that comment. But really I was wondering: why should we even be able to import bad data like that? And does that mean it is somehow possible to get a bad value in there from the Admin? I think both of those are probably answered by your explanation.


def clean(self):
template_errors = []
errors = []

if self.transit_agency:
templates = [
Expand All @@ -276,10 +281,19 @@ def clean(self):
# so just create directly for a missing template
for t in templates:
if not template_path(t):
template_errors.append(ValidationError(f"Template not found: {t}"))
errors.append(ValidationError(f"Template not found: {t}"))

if EnrollmentMethods.IN_PERSON in self.supported_enrollment_methods:
try:
in_person_eligibility_context = self.in_person_eligibility_context
except KeyError:
in_person_eligibility_context = None

if not in_person_eligibility_context:
errors.append(ValidationError(f"In-person eligibility context not found for: {self.system_name}"))
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Open to suggestions on this error message 😅

I guess it would help if it told the user some sort of action to take, like to contact a dev or to uncheck In-person.

Copy link
Member

@thekaveman thekaveman Feb 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm... yeah, this is a technically accurate error message! But since we show it to end-users, I think we probably want something more friendly here 😅

I agree with your action-oriented take, maybe something like:

{system_name} not configured for In-person. Please uncheck to continue."


if template_errors:
raise ValidationError(template_errors)
if errors:
raise ValidationError(errors)

def eligibility_form_instance(self, *args, **kwargs):
"""Return an instance of this flow's EligibilityForm, or None."""
Expand Down
5 changes: 4 additions & 1 deletion benefits/core/templates/core/includes/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

<div class="form-field-container {{ form.classes }}">
{% for field in form %}
<div class="form-group mb-0">
<div class="form-group mb-0{% if field.field.hide %} d-none{% endif %}">
{# djlint:off #}
{% if field.label %}
<label for="{{ field.id_for_label }}" class="form-control-label">{{ field.label }}{% if field.field.required %}<span class="required-label text-body">*</span>{% endif %}
Expand Down Expand Up @@ -131,5 +131,8 @@
document.querySelector("#{{ form.id }} button[type=submit]").addEventListener("click", recaptchaSubmit);
</script>
{% endif %}

{% block extra-scripts %}
{% endblock extra-scripts %}
</form>
{% endif %}
3 changes: 3 additions & 0 deletions benefits/in_person/context/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .eligibility import eligibility_index

__all__ = ["eligibility_index"]
26 changes: 26 additions & 0 deletions benefits/in_person/context/eligibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from dataclasses import dataclass, asdict

from benefits.core.context import SystemName


@dataclass
class EligibilityIndex:
policy_details: str

def dict(self):
return asdict(self)


eligibility_index = {
SystemName.OLDER_ADULT.value: EligibilityIndex(
policy_details="I confirmed this rider’s identity using a government-issued ID and verified they are age 65 or older."
),
SystemName.MEDICARE.value: EligibilityIndex(
policy_details="I confirmed this rider’s identity using a government-issued ID and verified they possess a valid "
"Medicare card."
),
SystemName.COURTESY_CARD.value: EligibilityIndex(
policy_details="I confirmed this rider’s identity using a government-issued ID and verified they possess an MST "
"Courtesy Card."
),
}
46 changes: 39 additions & 7 deletions benefits/in_person/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,53 @@ class InPersonEligibilityForm(forms.Form):
method = "POST"

flow = forms.ChoiceField(label="Choose an eligibility type to qualify this rider.", widget=forms.widgets.RadioSelect)
verified = forms.BooleanField(label="I have verified this person’s eligibility for a transit benefit.", required=True)

cancel_url = routes.ADMIN_INDEX

flow_field_error_message = "Choose an eligibility type"
verified_field_error_message = "Check the box to verify you have confirmed eligibility"

def __init__(self, agency: models.TransitAgency, *args, **kwargs):
super().__init__(*args, **kwargs)
flows = agency.enrollment_flows.filter(supported_enrollment_methods__contains=models.EnrollmentMethods.IN_PERSON)

self.classes = "in-person-eligibility-form"
flow_field = self.fields["flow"]
verified_field = self.fields["verified"]

flow_field.choices = [(f.id, f.label) for f in flows]
flow_field.widget.attrs.update({"data-custom-validity": "Please choose an eligibility type."})
verified_field.widget.attrs.update(
{"data-custom-validity": "Please confirm you have used an agency policy to verify eligibility."}
)
flow_field.widget.attrs.update({"data-custom-validity": self.flow_field_error_message})

# dynamically add a BooleanField for each flow
for flow in flows:
field_id = f"verified_{flow.id}"
self.fields[field_id] = forms.BooleanField(
required=False, # `clean()` will handle requiring the specific field
label=self.get_policy_details(flow),
widget=forms.widgets.CheckboxInput(attrs={"class": "d-none"}), # start out hidden
)
field = self.fields[field_id]
field.hide = True
field.widget.attrs.update({"data-custom-validity": self.verified_field_error_message})

self.use_custom_validity = True

def get_policy_details(self, flow: models.EnrollmentFlow):
try:
eligibility_context = flow.in_person_eligibility_context
except KeyError:
eligibility_context = None

return eligibility_context["policy_details"] if eligibility_context else None

def clean(self):
cleaned_data = super().clean()

flow_field_name = "flow"
selected_flow = cleaned_data.get(flow_field_name)

if not selected_flow:
self.add_error(flow_field_name, self.flow_field_error_message)
else:
verified_field_name = f"verified_{selected_flow}"
verified = cleaned_data.get(verified_field_name)
if not verified:
self.add_error(verified_field_name, self.verified_field_error_message)
2 changes: 1 addition & 1 deletion benefits/in_person/templates/in_person/eligibility.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<div class="border border-3 border-top-0 border-start-0 border-end-0">
<h2 class="p-3 m-0 text-start">In-person enrollment</h2>
</div>
<div class="p-3">{% include "core/includes/form.html" with form=form %}</div>
<div class="p-3">{% include "in_person/includes/eligibility-form.html" with form=form %}</div>
<div class="row d-flex justify-content-start p-3 pt-8">
<div class="col-6">
{% url form.cancel_url as url_cancel %}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{% extends "core/includes/form.html" %}

{% block extra-scripts %}
<script nonce="{{ request.csp_nonce }}">
ready(function() {
let showCheckbox = function(flow_id) {
let checkbox_parent = document.querySelector("[class^='form-group']:has([id='id_verified_" + flow_id + "'])")
checkbox_parent.classList.remove("d-none");

let flow_verified_checkbox = checkbox_parent.querySelector("[id='id_verified_" + flow_id + "']");
flow_verified_checkbox.classList.remove("d-none");
flow_verified_checkbox.required = true;

let flow_verified_label = checkbox_parent.querySelector("[for='id_verified_" + flow_id + "']");
flow_verified_label.classList.remove("d-none");
};

let hideOtherCheckboxes = function(flow_id) {
let other_groups = document.querySelectorAll("[class^='form-group']:has([id^='id_verified_']:not([id='id_verified_" + flow_id + "']))");
other_groups.forEach(group => {
group.classList.add("d-none");

let checkbox = group.querySelector("[id^='id_verified']");
checkbox.classList.add("d-none");
checkbox.required = false;
checkbox.checked = false;

group.querySelector("[for^='id_verified']").classList.add("d-none");

});
};

/* Add listener to radio buttons. */
let flow_radio_buttons = document.querySelectorAll("[id*='id_flow_']");
flow_radio_buttons.forEach(input => {
input.addEventListener("change", (event) => {
let flow_id = event.currentTarget.value;
showCheckbox(flow_id);
hideOtherCheckboxes(flow_id);
});

if (input.checked) {
showCheckbox(input.value);
}
});
});
</script>
{% endblock extra-scripts %}
10 changes: 10 additions & 0 deletions tests/pytest/core/models/test_enrollment.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,16 @@ def test_EnrollmentFlow_clean_templates(model_EnrollmentFlow_with_scope_and_clai
model_EnrollmentFlow_with_scope_and_claim.clean()


@pytest.mark.django_db
def test_EnrollmentFlow_clean_in_person_eligibility_context_not_found(model_EnrollmentFlow):
model_EnrollmentFlow.system_name = "nonexistent_system_name"

with pytest.raises(
ValidationError, match=f"In-person eligibility context not found for: {model_EnrollmentFlow.system_name}"
):
model_EnrollmentFlow.clean()


@pytest.mark.django_db
def test_EnrollmentEvent_create(model_TransitAgency, model_EnrollmentFlow):
ts = timezone.now()
Expand Down
24 changes: 19 additions & 5 deletions tests/pytest/in_person/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,25 @@ def test_eligibility_logged_in_filtering_flows(mocker, model_TransitAgency, admi

@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_flow")
def test_eligibility_post_valid_form_eligibility_verified(
def test_eligibility_post_no_flow_selected(admin_client):

path = reverse(routes.IN_PERSON_ELIGIBILITY)
form_data = {}
response = admin_client.post(path, form_data)

# should return user back to the in-person eligibility index
assert response.status_code == 200
assert response.template_name == "in_person/eligibility.html"


@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_flow")
def test_eligibility_post_flow_selected_and_verified(
admin_client, model_EnrollmentFlow, mocked_session_update, mocked_eligibility_analytics_module
):

path = reverse(routes.IN_PERSON_ELIGIBILITY)
form_data = {"flow": 1, "verified": True}
form_data = {"flow": 1, "verified_1": True}
response = admin_client.post(path, form_data)

assert response.status_code == 302
Expand All @@ -102,13 +115,14 @@ def test_eligibility_post_valid_form_eligibility_verified(


@pytest.mark.django_db
@pytest.mark.usefixtures("mocked_session_agency")
def test_eligibility_post_valid_form_eligibility_unverified(admin_client):
@pytest.mark.usefixtures("mocked_session_agency", "mocked_session_flow")
def test_eligibility_post_flow_selected_and_unverified(admin_client):

path = reverse(routes.IN_PERSON_ELIGIBILITY)
form_data = {"flow": 1, "verified": False}
form_data = {"flow": 1, "verified_1": False}
response = admin_client.post(path, form_data)

# should return user back to the in-person eligibility index
assert response.status_code == 200
assert response.template_name == "in_person/eligibility.html"

Expand Down
Loading