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

Refactor: introduce enrollment module #2338

Merged
merged 17 commits into from
Sep 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a3d9c00
refactor: introduce enrollment.Status enum and use in view logic
angela-tran Sep 5, 2024
2a4c473
refactor: move business logic into `enrollment` module
angela-tran Sep 5, 2024
959de4e
refactor(tests): move helper function tests to more appropriate file
angela-tran Sep 5, 2024
a0ba87f
refactor(tests): separate view and business logic tests for system error
angela-tran Sep 5, 2024
b40be8c
refactor(tests): separate view and business logic tests for exception
angela-tran Sep 5, 2024
f87e1f4
chore: use fixture for card token value
angela-tran Sep 5, 2024
e48f6d8
refactor(tests): separate view and business logic test for success
angela-tran Sep 5, 2024
41bfb6d
test: add an assertion to success unit test
angela-tran Sep 5, 2024
e80f4d5
refactor(tests): finish out success scenarios - expiration not supported
angela-tran Sep 5, 2024
7b23d29
refactor(tests): finish out success scenarios - expiration supported
angela-tran Sep 5, 2024
5df0cfe
refactor(tests): separate view and business logic tests for reenrollment
angela-tran Sep 5, 2024
967ab50
refactor(tests): move scenario to enrollment module test file
angela-tran Sep 5, 2024
3a313df
chore: remove unused fixtures only needed during refactor
angela-tran Sep 5, 2024
db8b8f4
refactor: enroll function can read agency and flow from the request
angela-tran Sep 5, 2024
a6863d2
chore: remove unused fixtures
angela-tran Sep 5, 2024
7abf4e5
refactor(enrollment): move usage of analytics and sentry back to view
angela-tran Sep 5, 2024
e4b4d7a
feat: docstring for the enroll function
angela-tran Sep 10, 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
146 changes: 146 additions & 0 deletions benefits/enrollment/enrollment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
from enum import Enum
from datetime import timedelta

from django.utils import timezone
from littlepay.api.client import Client
from requests.exceptions import HTTPError

from benefits.core import session


class Status(Enum):
# SUCCESS means the enrollment went through successfully
SUCCESS = 1

# SYSTEM_ERROR means the enrollment system encountered an internal error (returned a 500 HTTP status)
SYSTEM_ERROR = 2

# EXCEPTION means the enrollment system is working, but something unexpected happened
# because of a misconfiguration or invalid request from our side
EXCEPTION = 3

# REENROLLMENT_ERROR means that the user tried to re-enroll but is not within the reenrollment window
REENROLLMENT_ERROR = 4


def enroll(request, card_token):
"""
Attempts to enroll this card into the transit processor group for the flow in the request's session.

Returns a tuple containing a Status indicating the result of the attempt and any exception that occurred.
"""
agency = session.agency(request)
flow = session.flow(request)

client = Client(
base_url=agency.transit_processor.api_base_url,
client_id=agency.transit_processor_client_id,
client_secret=agency.transit_processor_client_secret,
audience=agency.transit_processor_audience,
)
client.oauth.ensure_active_token(client.token)

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

exception = None
try:
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 flow.supports_expiration:
# set expiry on session
if already_enrolled and group_funding_source.expiry_date is not None:
session.update(request, enrollment_expiry=group_funding_source.expiry_date)
else:
session.update(request, enrollment_expiry=_calculate_expiry(flow.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=session.enrollment_expiry(request)
)
status = Status.SUCCESS
else: # already_enrolled
if group_funding_source.expiry_date 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=session.enrollment_expiry(request),
)
status = Status.SUCCESS
else:
is_expired = _is_expired(group_funding_source.expiry_date)
is_within_reenrollment_window = _is_within_reenrollment_window(
group_funding_source.expiry_date, 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=session.enrollment_expiry(request),
)
status = Status.SUCCESS
else:
# re-enrollment error, return enrollment error with expiration and reenrollment_date
status = Status.REENROLLMENT_ERROR
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)
status = Status.SUCCESS
else: # already_enrolled
if group_funding_source.expiry_date is None:
# no action, return success
status = Status.SUCCESS
else:
# remove expiration date, return success
raise NotImplementedError("Removing expiration date is currently not supported")

except HTTPError as e:
if e.response.status_code >= 500:
status = Status.SYSTEM_ERROR
exception = e
else:
status = Status.EXCEPTION
exception = Exception(f"{e}: {e.response.json()}")
except Exception as e:
status = Status.EXCEPTION
exception = e

return status, exception


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(expiry_date):
"""Returns whether the passed in datetime is expired or not."""
return expiry_date <= timezone.now()


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


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
133 changes: 18 additions & 115 deletions benefits/enrollment/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,11 @@
"""

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 @@ -19,6 +18,7 @@
from benefits.core.middleware import EligibleSessionRequired, FlowSessionRequired, pageview_decorator

from . import analytics, forms
from .enrollment import Status, enroll

TEMPLATE_RETRY = "enrollment/retry.html"
TEMPLATE_SYSTEM_ERROR = "enrollment/system_error.html"
Expand Down Expand Up @@ -76,102 +76,36 @@ def index(request):
"""View handler for the enrollment landing page."""
session.update(request, origin=reverse(routes.ENROLLMENT_INDEX))

agency = session.agency(request)
flow = session.flow(request)

# POST back after transit processor form, process card token
if request.method == "POST":
form = forms.CardTokenizeSuccessForm(request.POST)
if not form.is_valid():
raise Exception("Invalid card token form")

card_token = form.cleaned_data.get("card_token")
status, exception = enroll(request, card_token)

client = Client(
base_url=agency.transit_processor.api_base_url,
client_id=agency.transit_processor_client_id,
client_secret=agency.transit_processor_client_secret,
audience=agency.transit_processor_audience,
)
client.oauth.ensure_active_token(client.token)

funding_source = client.get_funding_source_by_token(card_token)
group_id = flow.group_id
match (status):
case Status.SUCCESS:
return success(request)

try:
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
case Status.SYSTEM_ERROR:
analytics.returned_error(request, str(exception))
sentry_sdk.capture_exception(exception)
return system_error(request)

if flow.supports_expiration:
# set expiry on session
if already_enrolled and group_funding_source.expiry_date is not None:
session.update(request, enrollment_expiry=group_funding_source.expiry_date)
else:
session.update(request, enrollment_expiry=_calculate_expiry(flow.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=session.enrollment_expiry(request)
)
return success(request)
else: # already_enrolled
if group_funding_source.expiry_date 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=session.enrollment_expiry(request),
)
return success(request)
else:
is_expired = _is_expired(group_funding_source.expiry_date)
is_within_reenrollment_window = _is_within_reenrollment_window(
group_funding_source.expiry_date, 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=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.expiry_date 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:
if e.response.status_code >= 500:
analytics.returned_error(request, str(e))
sentry_sdk.capture_exception(e)
case Status.EXCEPTION:
analytics.returned_error(request, str(exception))
raise exception

return system_error(request)
else:
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
case Status.REENROLLMENT_ERROR:
return reenrollment_error(request)

# GET enrollment index
else:
agency = session.agency(request)
flow = session.flow(request)

tokenize_retry_form = forms.CardTokenizeFailForm(routes.ENROLLMENT_RETRY, "form-card-tokenize-fail-retry")
tokenize_server_error_form = forms.CardTokenizeFailForm(routes.SERVER_ERROR, "form-card-tokenize-fail-server-error")
tokenize_system_error_form = forms.CardTokenizeFailForm(
Expand Down Expand Up @@ -201,37 +135,6 @@ def index(request):
return TemplateResponse(request, flow.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(expiry_date):
"""Returns whether the passed in datetime is expired or not."""
return expiry_date <= timezone.now()


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


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


@decorator_from_middleware(EligibleSessionRequired)
def reenrollment_error(request):
"""View handler for a re-enrollment attempt that is not yet within the re-enrollment window."""
Expand Down
Loading
Loading