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: enrollments can expire #1989

Merged
merged 18 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
33b0efc
refactor(enrollment): check for existing group funding source first
angela-tran Mar 27, 2024
0ee7251
feat(enrollment): outline new scenarios that consider expiration
angela-tran Mar 27, 2024
0e6b8a9
feat(settings): app time zone is America/Los_Angeles
thekaveman Mar 26, 2024
245d484
feat: implement expiration date calculation
angela-tran Mar 27, 2024
6ae8416
feat: implement scenario - supports expiry and not enrolled yet
angela-tran Mar 27, 2024
2be2891
feat: implement scenario - supports expiration, but no expiration
angela-tran Mar 27, 2024
1757d3b
feat: implement scenario - supports expiration, is expired
angela-tran Mar 28, 2024
df49eea
feat: implement helper function to indicate expiration
angela-tran Mar 28, 2024
d43a265
feat: implement helper function to indicate being within reenrollment
angela-tran Mar 28, 2024
4fa50a7
chore: raise error for a scenario that cannot be implemented yet
angela-tran Mar 28, 2024
768e3ea
feat: implement scenario - supports expiration, not expired yet
angela-tran Mar 28, 2024
945e4c0
test: add coverage for function that checks for existing enrollment
angela-tran Mar 28, 2024
25f6d38
test: make assertion about expiry date more specific
angela-tran Mar 28, 2024
77bb1f8
refactor(view): remove potentially misleading variables
angela-tran Mar 29, 2024
f39d909
chore: make test and fixture names more explicit about no expiry
angela-tran Mar 29, 2024
b9caeed
chore: use more specific fixture for cases of not supporting expiration
angela-tran Mar 29, 2024
86ea118
refactor(view): remove unnecessary log statements
angela-tran Apr 1, 2024
8cb5e33
refactor(view): consolidate sending analytics event into success view
angela-tran Apr 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
118 changes: 104 additions & 14 deletions benefits/enrollment/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
"""

import logging
from datetime import timedelta

from django.http import JsonResponse
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.decorators import decorator_from_middleware
from littlepay.api.client import Client
from requests.exceptions import HTTPError
Expand All @@ -26,6 +28,7 @@
ROUTE_SUCCESS = "enrollment:success"
ROUTE_TOKEN = "enrollment:token"

TEMPLATE_REENROLLMENT_ERROR = "enrollment/reenrollment-error.html"
TEMPLATE_RETRY = "enrollment/retry.html"
TEMPLATE_SUCCESS = "enrollment/success.html"

Expand Down Expand Up @@ -69,7 +72,6 @@ def index(request):
if not form.is_valid():
raise Exception("Invalid card token form")

logger.debug("Read tokenized card")
card_token = form.cleaned_data.get("card_token")

client = Client(
Expand All @@ -81,25 +83,73 @@ def index(request):
client.oauth.ensure_active_token(client.token)

funding_source = client.get_funding_source_by_token(card_token)
group_id = eligibility.group_id

try:
client.link_concession_group_funding_source(funding_source_id=funding_source.id, group_id=eligibility.group_id)
group_funding_source = _get_group_funding_source(
client=client, group_id=group_id, funding_source_id=funding_source.id
)

already_enrolled = group_funding_source is not None

if eligibility.supports_expiration:
# set expiry on session
if already_enrolled and group_funding_source.concession_expiry is not None:
session.update(request, enrollment_expiry=group_funding_source.concession_expiry)
else:
session.update(request, enrollment_expiry=_calculate_expiry(eligibility.expiration_days))

if not already_enrolled:
# enroll user with an expiration date, return success
client.link_concession_group_funding_source(
group_id=group_id, funding_source_id=funding_source.id, expiry_date=session.enrollment_expiry(request)
)
return success(request)
else: # already_enrolled
if group_funding_source.concession_expiry is None:
# update expiration of existing enrollment, return success
client.update_concession_group_funding_source_expiry(
group_id=group_id,
funding_source_id=funding_source.id,
expiry_date=session.enrollment_expiry(request),
)
return success(request)
else:
is_expired = _is_expired(group_funding_source.concession_expiry)
is_within_reenrollment_window = _is_within_reenrollment_window(
group_funding_source.concession_expiry, session.enrollment_reenrollment(request)
)

if is_expired or is_within_reenrollment_window:
# update expiration of existing enrollment, return success
client.update_concession_group_funding_source_expiry(
group_id=group_id,
funding_source_id=funding_source.id,
expiry_date=session.enrollment_expiry(request),
)
return success(request)
else:
# re-enrollment error, return enrollment error with expiration and reenrollment_date
return reenrollment_error(request)
else: # eligibility does not support expiration
if not already_enrolled:
# enroll user with no expiration date, return success
client.link_concession_group_funding_source(group_id=group_id, funding_source_id=funding_source.id)
return success(request)
else: # already_enrolled
if group_funding_source.concession_expiry is None:
# no action, return success
return success(request)
else:
# remove expiration date, return success
raise NotImplementedError("Removing expiration date is currently not supported")

except HTTPError as e:
# 409 means that customer already belongs to a concession group.
# the response JSON will look like:
# {"errors":[{"detail":"Conflict (409) - Customer already belongs to a concession group."}]}
if e.response.status_code == 409:
analytics.returned_success(request, eligibility.group_id)
return success(request)
else:
analytics.returned_error(request, str(e))
raise Exception(f"{e}: {e.response.json()}")
analytics.returned_error(request, str(e))
raise Exception(f"{e}: {e.response.json()}")
except Exception as e:
analytics.returned_error(request, str(e))
raise e
else:
analytics.returned_success(request, eligibility.group_id)
return success(request)

# GET enrollment index
else:
Expand All @@ -122,6 +172,43 @@ def index(request):
return TemplateResponse(request, eligibility.enrollment_index_template, context)


def _get_group_funding_source(client: Client, group_id, funding_source_id):
group_funding_sources = client.get_concession_group_linked_funding_sources(group_id)
matching_group_funding_source = None
for group_funding_source in group_funding_sources:
if group_funding_source.id == funding_source_id:
matching_group_funding_source = group_funding_source
break

return matching_group_funding_source


def _is_expired(concession_expiry):
"""Returns whether the passed in datetime is expired or not."""
return concession_expiry <= timezone.now()


def _is_within_reenrollment_window(concession_expiry, enrollment_reenrollment_date):
"""Returns if we are currently within the reenrollment window."""
return enrollment_reenrollment_date <= timezone.now() < concession_expiry


def _calculate_expiry(expiration_days):
"""Returns the expiry datetime, which should be midnight in our configured timezone of the (N + 1)th day from now,
where N is expiration_days."""
default_time_zone = timezone.get_default_timezone()
expiry_date = timezone.localtime(timezone=default_time_zone) + timedelta(days=expiration_days + 1)
expiry_datetime = expiry_date.replace(hour=0, minute=0, second=0, microsecond=0)

return expiry_datetime


def reenrollment_error(request):
"""View handler for a re-enrollment attempt that is not yet within the re-enrollment window."""
analytics.returned_error(request, "Re-enrollment error")
return TemplateResponse(request, TEMPLATE_REENROLLMENT_ERROR)


@decorator_from_middleware(EligibleSessionRequired)
def retry(request):
"""View handler for a recoverable failure condition."""
Expand All @@ -139,6 +226,7 @@ def retry(request):


@pageview_decorator
@decorator_from_middleware(EligibleSessionRequired)
@decorator_from_middleware(VerifierSessionRequired)
def success(request):
"""View handler for the final success page."""
Expand All @@ -147,10 +235,12 @@ def success(request):

agency = session.agency(request)
verifier = session.verifier(request)
eligibility = session.eligibility(request)

if session.logged_in(request) and verifier.auth_provider.supports_sign_out:
# overwrite origin for a logged in user
# if they click the logout button, they are taken to the new route
session.update(request, origin=reverse(ROUTE_LOGGED_OUT))

analytics.returned_success(request, eligibility.group_id)
return TemplateResponse(request, agency.enrollment_success_template)
6 changes: 5 additions & 1 deletion benefits/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,11 @@ def RUNTIME_ENVIRONMENT():

USE_I18N = True

TIME_ZONE = "UTC"
# See https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-TIME_ZONE
# > Note that this isn’t necessarily the time zone of the server.
# > When USE_TZ is True, this is the default time zone that Django will use to display datetimes in templates
# > and to interpret datetimes entered in forms.
TIME_ZONE = "America/Los_Angeles"
thekaveman marked this conversation as resolved.
Show resolved Hide resolved
USE_TZ = True

# Static files (CSS, JavaScript, Images)
Expand Down
10 changes: 10 additions & 0 deletions tests/pytest/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from unittest.mock import create_autospec
from django.contrib.sessions.middleware import SessionMiddleware
from django.middleware.locale import LocaleMiddleware
from django.utils import timezone

import pytest
from pytest_socket import disable_socket
Expand Down Expand Up @@ -256,6 +257,15 @@ def mocked_session_oauth_token(mocker):
return mocker.patch("benefits.core.session.oauth_token", autospec=True, return_value="token")


@pytest.fixture
def mocked_session_enrollment_expiry(mocker):
return mocker.patch(
"benefits.core.session.enrollment_expiry",
autospec=True,
return_value=timezone.make_aware(timezone.datetime(2024, 1, 1), timezone=timezone.get_default_timezone()),
)


@pytest.fixture
def mocked_session_verifier(mocker, model_EligibilityVerifier):
return mocker.patch("benefits.core.session.verifier", autospec=True, return_value=model_EligibilityVerifier)
Expand Down
Loading
Loading