+ Coverage for benefits/enrollment/__init__.py: + 100% +
+ ++ 0 statements + + + + +
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.pages b/.pages new file mode 100644 index 0000000000..0dc50f68f0 --- /dev/null +++ b/.pages @@ -0,0 +1,10 @@ +nav: + - Home: + - README.md + - use-cases + - getting-started + - product-and-design + - development + - tests + - configuration + - deployment diff --git a/404.html b/404.html new file mode 100644 index 0000000000..b7efe2e43e --- /dev/null +++ b/404.html @@ -0,0 +1,1168 @@ + + + +
+ + + + + + + + + + + + + +MDN docs
+The Mozilla Developer Network has more on Content Security Policy
+++The HTTP
+Content-Security-Policy
response header allows web site administrators to control resources the user agent is +allowed to load for a given page.With a few exceptions, policies mostly involve specifying server origins and script endpoints. This helps guard against +cross-site scripting attacks
+
Strict CSP
+Benefits configures a Strict Content Security Policy. Read more about Strict CSP from Google: https://csp.withgoogle.com/docs/strict-csp.html.
+django-csp
¶django-csp docs
+ +Benefits uses the open source django-csp
library for helping to configure the correct response headers.
DJANGO_CSP_CONNECT_SRC
¶Comma-separated list of URIs. Configures the connect-src
Content Security Policy directive.
DJANGO_CSP_FONT_SRC
¶Comma-separated list of URIs. Configures the font-src
Content Security Policy directive.
DJANGO_CSP_FRAME_SRC
¶Comma-separated list of URIs. Configures the frame-src
Content Security Policy directive.
DJANGO_CSP_SCRIPT_SRC
¶Comma-separated list of URIs. Configures the script-src
Content Security Policy directive.
DJANGO_CSP_STYLE_SRC
¶Comma-separated list of URIs. Configures the style-src
Content Security Policy directive.
Data migration file
+ +Django docs
+ +Django data migrations are used to load the database with instances of the app’s model classes, defined in benefits/core/models.py
.
Migrations are run as the application starts up. See the bin/init.sh
script.
The sample values provided in the repository are sufficient to run the app locally and interact with e.g. the sample Transit +Agencies.
+During the deployment process, environment-specific values are set in environment variables and are read by the data migration file to build that environment’s configuration database. See the data migration file for the environment variable names.
+The sample data included in the repository is enough to bootstrap the application with basic functionality:
+Some configuration data is not available with the samples in the repository:
+ABC
¶DefTL
¶When the data migration changes, the configuration database needs to be rebuilt.
+The file is called django.db
and the following commands will rebuild it.
Run these commands from within the repository root, inside the devcontainer:
+bin/init.sh
+
The first steps of the Getting Started guide mention creating an .env
file.
The sections below outline in more detail the application environment variables that you may want to override, and their purpose. In App Service, this is more generally called the “configuration”.
+See other topic pages in this section for more specific environment variable configurations.
+Amplitude API docs
+Read more at https://developers.amplitude.com/docs/http-api-v2#request-format
+ANALYTICS_KEY
¶Deployment configuration
+You may change this setting when deploying the app to a non-localhost domain
+Amplitude API key for the project where the app will direct events.
+If blank or an invalid key, analytics events aren’t captured (though may still be logged).
+DJANGO_ADMIN
¶Boolean:
+True
: activates Django’s built-in admin interface for content authoring.False
(default): skips this activation.DJANGO_ALLOWED_HOSTS
¶Deployment configuration
+You must change this setting when deploying the app to a non-localhost domain
+Django docs
+ +A list of strings representing the host/domain names that this Django site can serve.
+DJANGO_DB_DIR
¶Deployment configuration
+You may change this setting when deploying the app to a non-localhost domain
+The directory where Django creates its Sqlite database file. Must exist and be +writable by the Django process.
+By default, the base project directory (i.e. the root of the repository).
+DJANGO_DB_RESET
¶Deployment configuration
+You may change this setting when deploying the app to a non-localhost domain
+Boolean:
+True
(default): deletes the existing database file and runs fresh Django migrations.False
: Django uses the existing database file.DJANGO_DEBUG
¶Deployment configuration
+Do not enable this in production
+Django docs
+ +Boolean:
+True
: the application is launched with debug mode turned on, allows pausing on breakpoints in the code, changes how static
+ files are servedFalse
(default): the application is launched with debug mode turned off, similar to how it runs in productionDJANGO_LOCAL_PORT
¶Local configuration
+This setting only affects the app running on localhost
+The port used to serve the Django application from the host machine (that is running the application container).
+i.e. if you are running the app in Docker on your local machine, this is the port that the app will be accessible from at +http://localhost:$DJANGO_LOCAL_PORT
+From inside the container, the app is always listening on port 8000
.
DJANGO_LOG_LEVEL
¶Deployment configuration
+You may change this setting when deploying the app to a non-localhost domain
+Django docs
+ +The log level used in the application’s logging configuration.
+By default the application sends logs to stdout
.
DJANGO_SECRET_KEY
¶Deployment configuration
+You must change this setting when deploying the app to a non-localhost domain
+Django docs
+ +Django’s primary secret, keep this safe!
+DJANGO_SUPERUSER_EMAIL
¶Deployment configuration
+You may change this setting when deploying the app to a non-localhost domain
+Required configuration
+This setting is required when DJANGO_ADMIN
is true
The email address of the Django Admin superuser created during initialization.
+DJANGO_SUPERUSER_PASSWORD
¶Deployment configuration
+You may change this setting when deploying the app to a non-localhost domain
+Required configuration
+This setting is required when DJANGO_ADMIN
is true
The password of the Django Admin superuser created during initialization.
+DJANGO_SUPERUSER_USERNAME
¶Deployment configuration
+You may change this setting when deploying the app to a non-localhost domain
+Required configuration
+This setting is required when DJANGO_ADMIN
is true
The username of the Django Admin superuser created during initialization.
+DJANGO_TRUSTED_ORIGINS
¶Deployment configuration
+You must change this setting when deploying the app to a non-localhost domain
+Django docs
+ +Comma-separated list of hosts which are trusted origins for unsafe requests (e.g. POST)
+HEALTHCHECK_USER_AGENTS
¶Deployment configuration
+You must change this setting when deploying the app to a non-localhost domain
+Comma-separated list of User-Agent strings which, when present as an HTTP header, should only receive healthcheck responses. Used by our HealthcheckUserAgents
middleware.
requests
configuration ¶requests
docs
REQUESTS_CONNECT_TIMEOUT
¶The number of seconds requests
will wait for the client to establish a connection to a remote machine. Defaults to 3 seconds.
REQUESTS_READ_TIMEOUT
¶The number of seconds the client will wait for the server to send a response. Defaults to 1 second.
+Cypress docs
+ +CYPRESS_baseUrl
¶The base URL for the (running) application, against which all Cypress .visit()
etc. commands are run.
When Cypress is running inside the devcontainer, this should be http://localhost:8000
. When Cypress is running outside the
+devcontainer, check the DJANGO_LOCAL_PORT
.
SENTRY_DSN
¶Sentry docs
+ +Enables sending events to Sentry.
+SENTRY_ENVIRONMENT
¶Sentry docs
+ +Segments errors by which deployment they occur in. This defaults to local
, and can be set to match one of the environment names.
SENTRY_REPORT_URI
¶Sentry docs
+ +Collect information on Content-Security-Policy (CSP) violations. Read more about CSP configuration in Benefits.
+To enable report collection, set this env var to the authenticated Sentry endpoint.
+SENTRY_TRACES_SAMPLE_RATE
¶Sentry docs
+ +Control the volume of transactions sent to Sentry. Value must be a float in the range [0.0, 1.0]
.
The default is 0.0
(i.e. no transactions are tracked).
The Getting Started section and sample configuration values in the repository give enough detail to +run the app locally, but further configuration is required before many of the integrations and features are active.
+There are two primary components of the application configuration:
+Many (but not all) of the environment variables are read into Django settings during application +startup.
+The model objects defined in the data migration file are also loaded into and seed Django’s database at application startup time.
+See the Setting secrets section for how to set secret values for a deployment.
+Settings file
+ +Django docs
+ +The Django entrypoint for production runs is defined in benefits/wsgi.py
.
This file names the module that tells Django which settings file to use:
+import os
+
+os.environ.setdefault("DJANGO_SETTINGS_MODULE", "benefits.settings")
+
Elsewhere, e.g. in manage.py
, this same environment variable is set to ensure benefits.settings
+are loaded for every app command and run.
Django docs
+ +From within the application, the Django settings object and the Django models are the two interfaces for application code to +read configuration data.
+Rather than importing the app’s settings module, Django recommends importing the django.conf.settings
object, which provides
+an abstraction and better handles default values:
from django.config import settings
+
+# ...
+
+if settings.ADMIN:
+ # do something when admin is enabled
+else:
+ # do something else when admin is disabled
+
Through the Django model framework, benefits.core.models
instances are used to access the configuration data:
from benefits.core.models import TransitAgency
+
+agency = TransitAgency.objects.get(short_name="ABC")
+
+if agency.active:
+ # do something when this agency is active
+else:
+ # do something when this agency is inactive
+
Benefits can be configured to require users to authenticate with an OAuth Open ID Connect (OIDC) +provider, before allowing the user to begin the Eligibility Verification process.
+This section describes the related settings and how to configure the application to enable this feature.
+Authlib docs
+Read more about configuring Authlib for Django
+Benefits uses the open-source Authlib for OAuth and OIDC client implementation. See the Authlib docs +for more details about what features are available. Specifically, from Authlib we:
+client.authorize_redirect()
to send the user into the OIDC server’s authentication flow, with our authorization
+ callback URLclient.authorize_access_token()
to get a validated
+ id token from the OIDC serverOAuth settings are configured as instances of the AuthProvider
model.
The data migration file contains sample values for an AuthProvider
configuration. You can set values for a real Open ID Connect provider in environment variables so that they are used instead of the sample values.
The benefits.oauth.client
module defines helpers for registering OAuth clients, and creating instances for
+use in e.g. views.
register_providers(oauth_registry)
uses data from AuthProvider
instances to register clients into the given registryoauth
is an authlib.integrations.django_client.OAuth
instanceProviders are registered into this instance once in the OAuthAppConfig.ready()
function at application
+startup.
Consumers call oauth.create_client(client_name)
with the name of a previously registered client to obtain an Authlib client
+instance.
The benefits application has a simple, single-configuration Rate Limit that acts
+per-IP to limit the number of consecutive requests in a given time period, via
+nginx limit_req_zone
+and limit_req
directives.
The configured rate limit is 12 requests/minute, or 1 request/5 seconds:
+limit_req_zone $limit zone=rate_limit:10m rate=12r/m;
+
An NGINX map +variable lists HTTP methods that will be rate limited:
+map $request_method $limit {
+ default "";
+ POST $binary_remote_addr;
+}
+
The default
means don’t apply a rate limit.
To add a new method, add a new line:
+map $request_method $limit {
+ default "";
+ OPTIONS $binary_remote_addr;
+ POST $binary_remote_addr;
+}
+
The limit_req
is applied to an NGINX location
block with a case-insensitive regex to match paths:
location ~* ^/(eligibility/confirm)$ {
+ limit_req zone=rate_limit;
+ # config...
+}
+
To add a new path, add a regex OR |
with the new path (omitting the leading slash):
location ~* ^/(eligibility/confirm|new/path)$ {
+ limit_req zone=rate_limit;
+ # config...
+}
+
reCAPTCHA docs
+See the reCAPTCHA Developer’s Guide for more information
+reCAPTCHA v3 is a free Google-provided service that helps protect the app from spam and abuse by using advanced +risk analysis techniques to tell humans and bots apart.
+reCAPTCHA is applied to all forms in the Benefits app that collect user-provided information. Version 3 works silently in the +background, with no additional interaction required by the user.
+Warning
+The following environment variables are all required to activate the reCAPTCHA feature
+DJANGO_RECAPTCHA_API_URL
¶URL to the reCAPTCHA JavaScript API library.
+By default, https://www.google.com/recaptcha/api.js
DJANGO_RECAPTCHA_SITE_KEY
¶Site key for the reCAPTCHA configuration.
+DJANGO_RECAPTCHA_SECRET_KEY
¶Secret key for the reCAPTCHA configuration.
+DJANGO_RECAPTCHA_VERIFY_URL
¶reCAPTCHA docs
+ +URL for the reCAPTCHA verify service.
+By default, https://www.google.com/recaptcha/api/siteverify
Before starting any configuration, the Cal-ITP team and transit agency staff should have a kickoff meeting to confirm that information provided is complete, implementation plan is feasible, and any approvals needed have been obtained.
+Then, the following steps are done by the Cal-ITP team to configure a new transit agency in the Benefits application.
+Note that a TransitAgency
model requires:
EligibilityType
sEligibilityVerifier
s used to verify one of those supported eligibility typesPaymentProcessor
for enrolling the user’s contactless card for discountsinfo_url
and phone
for users to contact customer serviceAlso note that these steps assume the transit agency is using Littlepay as their payment processor. Support for integration with other payment processors may be added in the future.
+For development and testing, only a Littlepay customer group is needed since there is no need to interact with any discount product. (We don’t have a way to tap a card against the QA system to trigger a discount and therefore have no reason to associate the group with any product.)
+group_id
on a new EligibilityType
in the Benefits database. (See Configuration data for more on loading the database.)EligibilityVerifier
in the database for each supported eligibility type. This will require configuration for either API-based verification or verification through an OAuth Open ID Connect provider (e.g. sandbox Login.gov) – either way, this resource should be meant for testing.TransitAgency
in the database and associates it with the new EligibilityType
s and EligibilityVerifier
s as well as the existing Littlepay PaymentProcessor
.For production validation, both a customer group and discount product are needed. The customer group used here is a temporary one for testing only. Production validation is done against the Benefits test environment to avoid disruption of the production environment.
+EligibilityType
for testing purposes in the Benefits database and sets the group_id
to the ID of the newly-created group.EligibilityVerifier
with configuration for a testing environment to ensure successful eligibility verification. (For example, use sandbox Login.gov instead of production Login.gov.)PaymentProcessor
for testing purposes with configuration for production Littlepay.TransitAgency
(created previously) with associations to the eligibility types, verifiers, and payment processor that were just created for testing.At this point, Cal-ITP and transit agency staff can coordinate to do on-the-ground testing where a live card is tapped on a live payment validator.
+Once production validation is done, the transit agency can be added to the production Benefits database.
+group_id
for a new EligibilityType
in the Benefits database.EligibilityVerifier
with configuration for the production eligibility verification system.TransitAgency
in the database with proper associations to eligibility types, verifiers, and payment processor.At this point, the customer group that was created in production Littlepay for testing purposes can be deleted. The temporary production validation objects in the Benefits database can also be deleted.
+EligibilityType
s, EligibilityVerifier
s, and PaymentProcessor
that were created in the Benefits test environment.dev-benefits.calitp.org is currently deployed into a Microsoft Azure account provided by California Department of Technology (CDT)’s Office of Enterprise Technology (OET), a.k.a. the “DevSecOps” team. More specifically, it uses custom containers on Azure App Service. More about the infrastructure.
+The Django application gets built into a Docker image with NGINX and +Gunicorn. SQLite is used within that same container to store configuration data; there is no external database.
+The application is deployed to an Azure Web App Container using three separate environments for dev
, test
,
+and prod
.
A GitHub Action per environment is responsible for building that branch’s image and pushing to GitHub Container +Registry (GHCR).
+GitHub POSTs a webhook to the Azure Web App when an image is published to GHCR, telling +Azure to restart the app and pull the latest image.
+You can view what Git commit is deployed for a given environment by visitng the URL path /static/sha.txt
.
Configuration settings are stored as Application Configuration variables in Azure. +Data is loaded via Django data migrations.
+Docker images for each of the deploy branches are available from GitHub Container Registry (GHCR):
+ghcr.io/cal-itp/benefits
dev
, test
, prod
The infrastructure is configured as code via Terraform, for various reasons.
+flowchart LR
+ benefits[Benefits application]
+ style benefits stroke-width:5px
+ recaptcha[Google reCAPTCHA]
+ rider((User's browser))
+ idg[Identity Gateway]
+ elig_server[Eligibility Server]
+ ac_data[(Agency Card data)]
+ cookies[(Cookies)]
+
+ benefits -->|Errors| sentry
+ elig_server -->|Errors| sentry
+
+ rider --> benefits
+ rider -->|Credentials and identity proofing| Login.gov
+ rider --> recaptcha
+ rider -->|Payment card info| Littlepay
+ rider -->|Events| Amplitude
+ rider -->|Session| cookies
+
+ benefits --> idg
+ benefits <--> recaptcha
+ benefits -->|Events| Amplitude
+ benefits -->|Group enrollment| Littlepay
+ benefits --> elig_server
+
+ subgraph "Agency Cards (e.g. MST Courtesy Cards)"
+ elig_server --> ac_data
+ end
+
+ idg --> Login.gov
+ Login.gov -->|User attributes| idg
+ idg -->|User attributes| benefits
+flowchart LR
+ internet[Public internet]
+ frontdoor[Front Door]
+ django[Django application]
+ interconnections[Other system interconnections]
+
+ internet --> Cloudflare
+ Cloudflare --> frontdoor
+ django <--> interconnections
+
+ subgraph Azure
+ frontdoor --> NGINX
+
+ subgraph App Service
+ subgraph Custom container
+ direction TB
+ NGINX --> django
+ end
+ end
+ end
+Front Door also includes the Web Application Firewall (WAF) and handles TLS termination. Front Door is managed by the DevSecOps team.
+The following things in Azure are managed by the California Department of Technology (CDT)’s DevSecOps (OET) team:
+Within the CDT Digital CA
directory (how to switch), there are two Subscriptions, with Resource Groups under each. Each environment corresponds to a single Resource Group, Terraform Workspace, and branch.
Environment | +Subscription | +Resource Group | +Workspace | +Branch | +
---|---|---|---|---|
Dev | +CDT/ODI Development |
+RG-CDT-PUB-VIP-CALITP-D-001 |
+dev |
+dev |
+
Test | +CDT/ODI Development |
+RG-CDT-PUB-VIP-CALITP-T-001 |
+test |
+test |
+
Prod | +CDT/ODI Production |
+RG-CDT-PUB-VIP-CALITP-P-001 |
+default |
+prod |
+
All resources in these Resource Groups should be reflected in Terraform in this repository. The exceptions are:
+prevent_destroy
is used on these Resources.You’ll see these referenced in Terraform as data sources.
+For browsing the Azure portal, you can switch your Default subscription filter
.
Terraform is plan
‘d when code is pushed to any branch on GitHub, then apply
‘d when merged to dev
. While other automation for this project is done through GitHub Actions, we use an Azure Pipeline (above) for a couple of reasons:
Install dependencies:
+Terraform - see exact version in deploy.yml
az login
+
terraform/
directory../init.sh <env>
+
terraform plan
+
For Azure resources, you need to ignore changes to tags, since they are automatically created by Azure Policy.
+lifecycle {
+ ignore_changes = [tags]
+}
+
The DevSecOps team sets the following naming convention for Resources:
+<<Resource Type>>-<<Department>>-<<Public/Private>>-<<Project Category>>-<<Project Name>>-<<Region>><<OS Type>>-<<Environment>>-<<Sequence Number>>
+
RG-CDT-PUB-VIP-BNSCN-E-D-001
ASP-CDT-PUB-VIP-BNSCN-EL-P-001
AS-CDT-PUB-VIP-BNSCN-EL-D-001
Use the following shorthand for conveying the Resource Type as part of the Resource Name:
+Resource | +Convention | +
---|---|
App Service | +AS |
+
App Service Plan | +ASP |
+
Virtual Network | +VNET |
+
Resource Group | +RG |
+
Virtual Machine | +VM |
+
Database | +DB |
+
Subnet | +SNET |
+
Front Door | +FD |
+
The following steps are required to set up the environment, with linked issues to automate them:
+terraform apply
slack-benefits-notify-email
Packages
eventThis is not a complete step-by-step guide; more a list of things to remember. This may be useful as part of incident response.
+ + + + + + + + +This list outlines the manual steps needed to make a new release of the
+benefits
app.
A release is made by merging changes into the prod
branch, which kicks off a
+deployment to the production environment. More details on the deployment steps
+can be found under Workflows.
The list of releases can be found on the repository Releases page +on GitHub.
+ +A new release implies a new version.
+benefits
uses the CalVer versioning scheme, where
+version numbers look like: YYYY.0M.R
YYYY
is the 4-digit year of the release; e.g. 2021
, 2022
0M
is the 2-digit, 0-padded month of the release; e.g. 02
is February, 12
+ is December.R
is the 1-based release counter for the given year and month;
+ e.g. 1
for the first release of the month, 2
for the second, and so on.Typically changes for a release will move from dev
, to test
, to prod
. This
+assumes dev
is in a state that it can be deployed without disruption. (This is called a Regular
release.)
If dev
or test
contain in-progress work that is not ready for production,
+and a hotfix is needed in production, a separate process to test the changes
+before deploying to prod
must be undertaken. (This is called a Hotfix
release.)
As implied in the previous step, all releases follow the same version number format.
+The following diagram shows how a release should propagate to prod
under
+different circumstances:
graph LR
+ A(Release branch) --> B{Are dev and test ready to deploy?};
+ B -->|Yes| C(dev);
+ C --> D(test);
+ D --> E(prod);
+ B -->|No| E;
+By convention the release branch is called release/YYYY.0M.R
using the
+upcoming version number.
The app code maintains a version number in
+benefits/__init__.py
,
+used by the instrumentation and logging systems.
This version number must be updated to match the new version in the same format:
+YYYY.0M.R
Initially from the release branch to the target environment branch, following +the merge sequence in the diagram above.
+After checks pass and review approval is given, merge the PR to kick off the +deployment.
+Repeat steps 3 and 4 for each deployment environment target, again following the +merge sequence in the diagram above.
+Once the deploy has completed to prod
, the version can be tagged and pushed to
+GitHub.
From a local terminal:
+git fetch
+
+git checkout prod
+
+git reset --hard origin/prod
+
+git tag YYYY.0M.R
+
+git push origin YYYY.0M.R
+
Also add a written description, and include screenshots/animations of new/updated pages/workflows.
+ + + + + + + + +Secret values used by the Benefits application (such as API keys, private keys, certificates, etc.) are stored in an Azure Key Vault for each environment.
+To set a secret, you can use the Azure portal or the Azure CLI.
+There are helper scripts under terraform/secrets
which build up the Azure CLI command, given some inputs. The usage is as follows:
First, make sure you are set up for local development and that you are in the terraform/secrets
directory.
cd terraform/secrets
+
To set a secret by providing a value:
+./value.sh <environment_letter> <secret_name> <secret_value>
+
where environment_letter
is D
for development, T
for test, and P
for production.
To set a secret by providing the path of a file containing the secret (useful for multi-line secrets):
+./file.sh <environment_letter> <secret_name> <file_path>
+
To verify the value of a secret, you can use the helper script named read.sh
.
./read.sh <environment_letter> <secret_name>
+
To make sure the Benefits application uses the latest secret values in Key Vault, you will need to make a change to the app service’s configuration. If you don’t do this step, the application will instead use cached values, which may not be what you expect. See the Azure docs for more details.
+The steps are:
+change_me_to_refresh_secrets
.The effects of following those steps should be:
+change_me_to_refresh_secrets
is set back to the value defined in our Terraform file for the App Service resource.We have ping tests set up to notify about availability of each environment. Alerts go to #benefits-notify.
+Open the Logs
for the environment you are interested in. The following tables are likely of interest:
AppServiceConsoleLogs
: stdout
and stderr
coming from the containerAppServiceHTTPLogs
: requests coming through App ServiceAppServicePlatformLogs
: deployment informationFor some pre-defined queries, click Queries
, then Group by: Query type
, and look under Query pack queries
.
After setting up the Azure CLI, you can use the following command to stream live logs:
+az webapp log tail --resource-group RG-CDT-PUB-VIP-CALITP-P-001 --name AS-CDT-PUB-VIP-CALITP-P-001 2>&1 | grep -v /healthcheck
+
https://as-cdt-pub-vip-calitp-p-001-dev.scm.azurewebsites.net/api/logs/docker
+Cal-ITP’s Sentry instance collects both errors (“Issues”) and app performance info.
+Alerts are sent to #benefits-notify in Slack. Others can be configured.
+You can troubleshoot Sentry itself by turning on debug mode and visiting /error/
.
This section serves as the runbook for Benefits.
+If Terraform commands fail (locally or in the Pipeline) due to an Error acquiring the state lock
:
Lock Info
for the Created
timestamp. If it’s in the past ten minutes or so, that probably means Terraform is still running elsewhere, and you should wait (stop here).apply
and it’s sitting waiting for them to approve it. They will need to (gracefully) exit for the lock to be released.Created
time is more than ten minutes ago, that probably means the last Terraform command didn’t release the lock. You’ll need to grab the ID
from the Lock Info
output and force unlock.If the container fails to start, you should see a downtime alert. Assuming this app version was working in another environment, the issue is likely due to misconfiguration. Some things you can do:
+Littlepay API issues may show up as:
+Connect your card
button doesn’t workA common problem that causes Littlepay API failures is that the certificate expired. To resolve:
+If the Benefits application gets a 403 error when trying to make API calls to the Eligibility Server, it may be because the outbound IP addresses changed, and the Eligibility Server firewall is still restricting access to the old IP ranges.
+outbound_ip_ranges
output
values from the most recent Benefit deployment to the relevant environment.Edit
Variables
Note there is nightly downtime as the Eligibility Server restarts and loads new data.
+ + + + + + + + +The GitHub Actions deployment workflow configuration lives at .github/workflows/deploy.yml
.
Info
+The entire process from GitHub commit to full redeploy of the application can take from around 5 minutes to 10 minutes +or more depending on the deploy environment. Have patience!
+The workflow is triggered with a push
to the corresponding branch. It also responds to the workflow_dispatch
event to allow manually triggering via the GitHub Actions UI.
When a deployment workflow runs, the following steps are taken:
+From the tip of the corresponding branch (e.g. dev
)
Using the github.actor
and built-in GITHUB_TOKEN
secret
Build the root Dockerfile
, tagging with both the branch name (e.g. dev
) and the SHA from the HEAD commit.
Push this image:tag into GHCR.
+Each Azure App Service instance is configured to listen to a webhook from GitHub, then deploy the image.
+ + + + + + + + +This project enforces the Conventional Commits style for commit message formatting:
+<type>[(optional-scope)]: <description>
+
+[optional body]
+
Where <type>
indicates the nature of the commit, one of a list of possible values:
build
- related to the build or compile processchore
- administrative tasks, cleanups, dev environmentci
- related to automated builds/tests etc.docs
- updates to the documentationfeat
- new code, features, or interfacesfix
- bug fixesperf
- performance improvementsrefactor
- non-breaking logic refactorsrevert
- undo a prior changestyle
- code style and formattingtest
- having to do with testing of any kindE.g.
+git commit -m "feat(eligibility/urls): add path for start"
+
The default GitHub branch is dev
. All new feature work should be in the form of Pull Requests (PR) that target dev
as their
+base.
In addition to dev
, the repository has three other long-lived branches:
test
and prod
correspond to the Test and Production deploy environments, respectively.gh-pages
hosts the compiled documentation, and is always forced-pushed by the
+ docs build process.Branch protection rules are in place on three environment branches (dev
, test
, prod
) to:
PR branches are typically named with a conventional type prefix, a slash /
, and then descriptor in lower-dashed-case
:
<type>/<lower-dashed-descriptor>
+
E.g.
+git checkout -b feat/verifier-radio-buttons
+
and
+git checkout -b refactor/verifier-model
+
PR branches are deleted once their PR is merged.
+Merging of PRs should be done using the merge commit strategy. The PR author should utilize git rebase -i
to ensure
+their PR commit history is clean, logical, and free of typos.
When merging a PR into dev
, it is customary to format the merge commit message like:
Title of PR (#number)
+
instead of the default:
+Merge pull request #number from source-repo/source-branch
+
Docker dynamically assigns host machine ports that map into container application ports.
+Info
+The Devcontainer can bind to a single container’s port(s) and present those to your localhost machine via VS Code. +Other services started along with the Devcontainer are not visible in VS Code. See +Outside the Devconatiner for how to find information on those.
+Once started with F5, the benefits
Django application runs on port 8000
inside the Devcontainer. To find the localhost
+address, look on the PORTS tab in VS Code’s Terminal window. The Local Address
corresponding to the record where
+8000
is in the Port
column is where the site is accessible on your host machine.
Replace 0.0.0.0
with localhost
and use the same port number shown in the Local Address
column. This is highlighted by the
+red box in the image below:
When running a docker compose ...
command, or in other scenarios outside of the Devcontainer, there are multiple ways to find
+the http://localhost
port corresponding to the service in question.
The Docker Desktop application shows information about running containers and services/groups, including information about +bound ports. In most cases, the application provides a button to launch a container/service directly in your browser when a +port binding is available.
+In the Containers / Apps tab, expand the service group if needed to find the container in question, where you should see
+labels indicating the container is RUNNING
and bound to PORT: XYZ
.
Hover over the container in question, and click the Open in Browser button to launch the app in your web browser.
+ +Using the docker
command line interface, you can find the bound port(s) of running containers.
docker ps -f name=<service>
+
e.g. for the docs
service:
docker ps -f name=docs
+
This prints output like the following:
+CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
+0d5b2e1fb910 benefits_client:dev "mkdocs serve --dev-…" 2 minutes ago Up 2 minutes 0.0.0.0:62093->8000/tcp benefits_docs_1
+
Looking at the PORTS
column:
PORTS
+0.0.0.0:62093->8000/tcp
+
We can see that locally, port 62093
is bound to the container port 8000
.
In this case, entering http://localhost:62093
in the web browser navigates to the docs
site homepage.
Message files
+English messages: benefits/locale/en/LC_MESSAGES/django.po
+The Cal-ITP Benefits application is fully internationalized and available in both English and Spanish.
+It uses Django’s built-in support for translation using message files, which contain entries of msgid
/msgstr
pairs. The msgid
is referenced in source code so that Django takes care of showing the msgstr
for the user’s language.
Django has a utility command called makemessages
to help maintain message files. It ensures that msgid
s in the message files are actually used somewhere in source code and also detects new msgid
s.
There is a helper script that runs this command with some arguments: bin/makemessages.sh
bin/makemessages.sh
+
Developers should use this script to update message files in a consistent way.
+Add English copy to templates directly first. Then, run the helper script, bin/makemessages.sh
, so Django can update the django.po
files for English and Spanish with the new copy.
Find the English copy in the Spanish django.po
file as a msgid
, and add the corresponding Spanish translation as the msgstr
. Again, run the helper script for formatting and bin/init.sh
to confirm the translation is rendered properly.
When templates have different copy per agency, create a new template for that agency-specific copy to live in. See the example of the MST-specific agency index page file, named index--mst.html
. Include the agency-specific template file name in the migration object, as done here for MST, with eligibility_index_template="eligibility/index--mst.html"
.
From Django docs:
++++
makemessages
sometimes generates translation entries marked as fuzzy, e.g. when translations are inferred from previously translated strings.
Usually, the inferred translation is not correct, so make sure to review the msgstr
and fix it if necessary. Then, remove the commented lines starting with #, fuzzy
(otherwise the entry will not be used).
Info
+VS Code with Devcontainers is the recommended development setup
+Warning
+You must build the base Docker image benefits_client:latest
before running the devcontainer.
+See Local Setup
Remote - Containers
extension ¶VS Code can be used together with Docker via the Remote - Containers extension to enable a
+container-based development environment. This repository includes a .devcontainer.json
file that configures
+remote container development and debugging.
With the Remote - Containers extension enabled, open the folder containing this repository inside Visual +Studio Code.
+You should receive a prompt in the Visual Studio Code window; click Reopen in Container
to run the development environment
+inside a container.
If you do not receive a prompt, or when you feel like starting from a fresh environment:
+Remote-Containers
to filter the commandsRebuild and Reopen in Container
to completely rebuild the devcontainerReopen in Container
to reopen the most recent devcontainer buildOnce running inside a container, press F5 to attach a debugger to the client, running on http://localhost
at a port
+dynamically assigned by Docker. See Docker dynamic ports for more information.
Add breakpoints in the code and browse the local site to trigger a pause. Press F5 to continue execution from the breakpoint.
+By default, the application is launched with DJANGO_DEBUG=True
, causing Django to provide additional logging and error output and to relax certain security settings.
Alternatively, you may attach to an instance launched with DJANGO_DEBUG=False
, to allow debugging the app in a state more similar to production.
In VS Code, press Ctrl/Cmd + Shift + D to open the Run and Debug
pane, where you can select between the various configurations (disregard the duplicate entry, selecting either will work):
The environment can also be overridden for the debug session by editing the configuration in .vscode/launch.json
, where any of the supported environment variables may be specified in the env
block. For example, to test the app with reCAPTCHA environment variables:
{
+ "name": "Django: Benefits Client",
+ "type": "python",
+ "request": "launch",
+ "program": "${workspaceFolder}/manage.py",
+ "args": ["runserver", "--insecure", "0.0.0.0:8000"],
+ "django": true,
+ "env": {
+ // existing field...
+ "DJANGO_DEBUG": "true",
+ // add these 2 entries with the values for reCAPTCHA
+ "DJANGO_RECAPTCHA_SITE_KEY": "<SITE KEY HERE>",
+ "DJANGO_RECAPTCHA_SECRET_KEY": "<SECRET KEY HERE>"
+ }
+}
+
See #1071 for more examples and context.
+To close out of the container and re-open the directory locally in Visual Studio Code:
+Remote-Containers
to filter the commandsReopen Locally
black
provides Python code formatting via the [ms-python.python
][python] VS Code extension.
prettier
provides code formatting for front-end (CSS/JavaScript) via the esbenp.prettier-vscode
VS Code extension.
See the .vscode/settings.json
file for more information on how this is configured in the devcontainer.
flake8
provides Python code linting via the [ms-python.python
][python] VS Code extension.
See the .vscode/settings.json
file for more information on how this is configured in the devcontainer.
This repository uses pre-commit
hooks to check and format code. The .pre-commit-config.yaml
file defines a
+number of pre-commit
hooks, including black
, flake8
, line ending and whitespace checkers, and more.
pre-commit
is installed and activated within the devcontainer and runs automatically with each commit.
Branch protection rules on the environment branches in GitHub ensure that pre-commit
checks have passed before a merge is
+allowed. See the workflow file at .github/workflows/pre-commit.yml
.
Models and migrations
+ + + +Cal-ITP Benefits defines a number of models in the core application, used throughout the codebase to configure +different parts of the UI and logic.
+The Cal-ITP Benefits database is a simple read-only Sqlite database, initialized from the data migration files.
+The database is rebuilt from scratch each time the container starts. We maintain a few migration files that set up the schema and load initial data.
+These files always represent the current schema and data for the database and match the current structure of the model classes.
+When models are updated, the migration should be updated as well.
+A simple helper script exists to regenerate the migration file based on the current state of models in the local directory:
+ +bin/makemigrations.sh
+
This script:
+makemigrations
commandblack
This will result in a simple diff of changes on the schema migration file. Commit these changes (including the timestamp!) along with the model changes.
+ + + + + + + + +A basic eligibility verification server is available for testing. The server code is available on GitHub, with its own set of documentation.
+docker compose up [-d] server
+
The optional -d
flag will start in detatched mode and allow you to continue using the terminal session. Otherwise your
+terminal will be attached to the container’s terminal, showing the startup and runtime output.
The API server is running on http://localhost
at a port dynamically assigned by Docker. See
+Docker dynamic ports for more information on accessing the server on localhost.
From within another Compose service container, the server is at http://server:5000
using the service-forwarding features of
+Compose.
In either case, the endpoint /verify
serves as the Eligibility Verification API endpoint.
When running the Devcontainer, the server is automatically started.
+See Docker dynamic ports for more information on accessing the server on localhost.
+The server is accessible from within the Devcontainer at its Compose service address: http://server:5000
.
This website is built using mkdocs
from the contents of the dev
(default) branch.
The mkdocs.yml
file in the repository root configures the build process, including the available plugins.
All content lives under the docs/
directory in the repository.
To add new sections/articles, create new directories and files under the docs/
directory, in Markdown format.
The pencil icon is a shortcut to quickly edit the content of the page you are viewing on the website:
+ +Above: Screenshot showing the edit pencil circled in red
+See mkdocs.yml
for enabled plugins/features
Use code fences with mermaid
type to render Mermaid diagrams within docs. For example, this markdown:
```mermaid
+graph LR
+ Start --> Stop
+```
+
Yields this diagram:
+graph LR
+ Start --> Stop
+The documentation website can be run locally using Docker Compose:
+# from inside the .devcontainer/ directory
+docker compose up docs
+
The site is served from http://localhost
at a port dynamically assigned by Docker. See
+Docker dynamic ports for more information.
The website is automatically rebuilt as changes are made to docs/
files.
When running the Devcontainer, the docs site is automatically started.
+See Docker dynamic ports for more information on accessing the site on localhost.
+A GitHub Action watches for pushes to dev
, and uses
+mhausenblas/mkdocs-deploy-gh-pages
to build the mkdocs
content, force-pushing to the gh-pages
+branch. At that point, GitHub Pages redeploys the docs site.
Running the Benefits application in a local, non-production environment requires Docker.
+The following commands should be run in a terminal program like bash
.
git clone https://github.com/cal-itp/benefits
+
The application is configured with defaults to run locally, but an .env
file is required to run with Docker Compose. This file can be empty, or environment overrides can be added as needed:
touch .env
+
E.g. to change the localhost port from the default 8000
to 9000
, add the following line to your .env
file:
DJANGO_LOCAL_PORT=9000
+
See Configuration for more details on supported environment variables and their settings.
+This builds the runtime and devcontainer images:
+bin/build.sh
+
If you need all layers to rebuild, use:
+docker compose build --no-cache client
+
The optional -d
flag will start in detatched mode and allow you to continue using the terminal session.
docker compose up -d client
+
Otherwise attach your terminal to the container’s terminal, showing the startup and runtime output:
+docker compose up client
+
After initialization, the client is running running on http://localhost:8000
by default.
If DJANGO_ADMIN=true
, the backend administrative interface can be accessed at the /admin
route using the superuser account
+you setup as part of initialization.
By default, sample values are used to initialize Django. Alternatively you may:
+Stop the running services with:
+docker compose down
+
This website provides technical documentation for the benefits
application from the
+California Integrated Travel Project (Cal-ITP).
Documentation for the dev
(default) branch is available online at: https://docs.calitp.org/benefits.
Cal-ITP Benefits is an application that enables automated eligibility verification and enrollment for transit +benefits onto customers’ existing contactless bank (credit/debit) cards.
+The development of this publicly-accessible client is being managed by Caltrans’ California Integrated Travel Project (Cal-ITP), in partnership with the California Department of Technology (CDT). From the Cal-ITP site:
+++Our Cal-ITP Benefits web application streamlines the process for transit riders to instantly qualify for and receive discounts, starting with Monterey-Salinas Transit (MST), which offers a half-price Senior Fare. Now older adults (65+) who are able to electronically verify their identity are able to access MST’s reduced fares without the hassle of paperwork.
+We worked with state partners on this product launch, and next we’re working to bring youth, lower-income riders, veterans, people with disabilities, and others the same instant access to free or reduced fares across all California transit providers, without having to prove eligibility to each agency.
+
The application is accessible to the public at benefits.calitp.org.
+benefits
is a Django 4 web application. The application talks to one or more Eligibility Verification APIs or authentication providers. These APIs and the application are
+designed for privacy and security of user information:
Running the application locally is possible with Docker and Docker Compose. Hosting information.
+The user interface and content is available in both English and Spanish. Additional language support is possible via Django’s +i18n and l10n features.
+The application communicates with external services like Littlepay via API calls and others like the Identity Gateway via redirects, both over the public internet. See all the system interconnections.
+Cal-ITP takes security and privacy seriously. Below is an overview of how the system is designed with security in mind.
+The Benefits application is deployed to Microsoft Azure. Traffic is encrypted between the user and the application, as well as between the application and external systems.
+The network is managed by the California Department of Technology (CDT), who provide a firewall and distributed denial-of-service (DDoS) protection.
+You can find more technical details on our infrastructure page.
+The Benefits application doesn’t collect or store any user data directly, and we minimize the information exchanged between systems. The following information is temporarily stored in an encrypted session in the user’s browser:
+Sensitive user information exists in the following places:
+None of that information is accessible to the Benefits system/team.
+Learn more about the security/privacy practices of some of our third-party integrations:
+ +Benefits collects analytics on usage, without any identifying information. (IP addresses are filtered out.)
+Dependabot immediately notifies the team of vulnerabilities in application dependencies.
+Upon doing new major integrations, features, or architectural changes, the Benefits team has a penetration test performed by a third party to ensure the security of the system.
+All code changes are reviewed by at least one other member of the engineering team, which is enforced through branch protections.
+ + + + + + + + +The locale
folder in this repository contain the django.po
message files for English and Spanish translation strings for the Benefits application.
Translation strings include all application copy, including:
+The human-readable version of the English and Spanish translation strings for the application are delivered to Design and Engineering by Product, and live at this link: Cal-ITP Benefits Application Copy (Configurable Strings).
+By tabs:
+EN-USA
tab contains all copy for English, which each row representing a page. This copy uses a sample agency, called California State Transit (CST) with an Agency Card. This copy is used in Figma.forTranslation
and All Agencies
tab contains the English and Spanish translation side by side, with agency-specific copy.Subtitle
, ButtonLabel
)django.po
message files. Developer-specific instructions in the Django message files technical documentation.This website provides technical documentation for the benefits
application from the California Integrated Travel Project (Cal-ITP).
Documentation for the dev
(default) branch is available online at: https://docs.calitp.org/benefits.
Cal-ITP Benefits is an application that enables automated eligibility verification and enrollment for transit benefits onto customers\u2019 existing contactless bank (credit/debit) cards.
The development of this publicly-accessible client is being managed by Caltrans\u2019 California Integrated Travel Project (Cal-ITP), in partnership with the California Department of Technology (CDT). From the Cal-ITP site:
Our Cal-ITP Benefits web application streamlines the process for transit riders to instantly qualify for and receive discounts, starting with Monterey-Salinas Transit (MST), which offers a half-price Senior Fare. Now older adults (65+) who are able to electronically verify their identity are able to access MST\u2019s reduced fares without the hassle of paperwork.
We worked with state partners on this product launch, and next we\u2019re working to bring youth, lower-income riders, veterans, people with disabilities, and others the same instant access to free or reduced fares across all California transit providers, without having to prove eligibility to each agency.
The application is accessible to the public at benefits.calitp.org.
"},{"location":"#technical-details","title":"Technical details","text":"benefits
is a Django 4 web application. The application talks to one or more Eligibility Verification APIs or authentication providers. These APIs and the application are designed for privacy and security of user information:
Running the application locally is possible with Docker and Docker Compose. Hosting information.
The user interface and content is available in both English and Spanish. Additional language support is possible via Django\u2019s i18n and l10n features.
The application communicates with external services like Littlepay via API calls and others like the Identity Gateway via redirects, both over the public internet. See all the system interconnections.
"},{"location":"#security","title":"Security","text":"Cal-ITP takes security and privacy seriously. Below is an overview of how the system is designed with security in mind.
"},{"location":"#architecture","title":"Architecture","text":"The Benefits application is deployed to Microsoft Azure. Traffic is encrypted between the user and the application, as well as between the application and external systems.
The network is managed by the California Department of Technology (CDT), who provide a firewall and distributed denial-of-service (DDoS) protection.
You can find more technical details on our infrastructure page.
"},{"location":"#data-storage","title":"Data storage","text":"The Benefits application doesn\u2019t collect or store any user data directly, and we minimize the information exchanged between systems. The following information is temporarily stored in an encrypted session in the user\u2019s browser:
Sensitive user information exists in the following places:
None of that information is accessible to the Benefits system/team.
Learn more about the security/privacy practices of some of our third-party integrations:
Benefits collects analytics on usage, without any identifying information. (IP addresses are filtered out.)
"},{"location":"#practices","title":"Practices","text":"Dependabot immediately notifies the team of vulnerabilities in application dependencies.
Upon doing new major integrations, features, or architectural changes, the Benefits team has a penetration test performed by a third party to ensure the security of the system.
All code changes are reviewed by at least one other member of the engineering team, which is enforced through branch protections.
"},{"location":"configuration/","title":"Configuring the Benefits app","text":"The Getting Started section and sample configuration values in the repository give enough detail to run the app locally, but further configuration is required before many of the integrations and features are active.
There are two primary components of the application configuration:
Many (but not all) of the environment variables are read into Django settings during application startup.
The model objects defined in the data migration file are also loaded into and seed Django\u2019s database at application startup time.
See the Setting secrets section for how to set secret values for a deployment.
"},{"location":"configuration/#django-settings","title":"Django settings","text":"Settings file
benefits/settings.py
Django docs
Django settings
The Django entrypoint for production runs is defined in benefits/wsgi.py
.
This file names the module that tells Django which settings file to use:
import os\n\nos.environ.setdefault(\"DJANGO_SETTINGS_MODULE\", \"benefits.settings\")\n
Elsewhere, e.g. in manage.py
, this same environment variable is set to ensure benefits.settings
are loaded for every app command and run.
Django docs
Using settings in Python code
From within the application, the Django settings object and the Django models are the two interfaces for application code to read configuration data.
Rather than importing the app\u2019s settings module, Django recommends importing the django.conf.settings
object, which provides an abstraction and better handles default values:
from django.config import settings\n\n# ...\n\nif settings.ADMIN:\n # do something when admin is enabled\nelse:\n # do something else when admin is disabled\n
Through the Django model framework, benefits.core.models
instances are used to access the configuration data:
from benefits.core.models import TransitAgency\n\nagency = TransitAgency.objects.get(short_name=\"ABC\")\n\nif agency.active:\n # do something when this agency is active\nelse:\n # do something when this agency is inactive\n
"},{"location":"configuration/content-security-policy/","title":"Configuring the Content Security Policy","text":"MDN docs
The Mozilla Developer Network has more on Content Security Policy
The HTTP Content-Security-Policy
response header allows web site administrators to control resources the user agent is allowed to load for a given page.
With a few exceptions, policies mostly involve specifying server origins and script endpoints. This helps guard against cross-site scripting attacks
Strict CSP
Benefits configures a Strict Content Security Policy. Read more about Strict CSP from Google: https://csp.withgoogle.com/docs/strict-csp.html.
"},{"location":"configuration/content-security-policy/#django-csp","title":"django-csp
","text":"django-csp docs
Configuring django-csp
Benefits uses the open source django-csp
library for helping to configure the correct response headers.
DJANGO_CSP_CONNECT_SRC
","text":"Comma-separated list of URIs. Configures the connect-src
Content Security Policy directive.
DJANGO_CSP_FONT_SRC
","text":"Comma-separated list of URIs. Configures the font-src
Content Security Policy directive.
DJANGO_CSP_FRAME_SRC
","text":"Comma-separated list of URIs. Configures the frame-src
Content Security Policy directive.
DJANGO_CSP_SCRIPT_SRC
","text":"Comma-separated list of URIs. Configures the script-src
Content Security Policy directive.
DJANGO_CSP_STYLE_SRC
","text":"Comma-separated list of URIs. Configures the style-src
Content Security Policy directive.
Data migration file
benefits/core/migrations/0002_data.py
Django docs
How to provide initial data for models
"},{"location":"configuration/data/#introduction","title":"Introduction","text":"Django data migrations are used to load the database with instances of the app\u2019s model classes, defined in benefits/core/models.py
.
Migrations are run as the application starts up. See the bin/init.sh
script.
The sample values provided in the repository are sufficient to run the app locally and interact with e.g. the sample Transit Agencies.
During the deployment process, environment-specific values are set in environment variables and are read by the data migration file to build that environment\u2019s configuration database. See the data migration file for the environment variable names.
"},{"location":"configuration/data/#sample-data","title":"Sample data","text":"The sample data included in the repository is enough to bootstrap the application with basic functionality:
Some configuration data is not available with the samples in the repository:
ABC
","text":"DefTL
","text":"When the data migration changes, the configuration database needs to be rebuilt.
The file is called django.db
and the following commands will rebuild it.
Run these commands from within the repository root, inside the devcontainer:
bin/init.sh\n
"},{"location":"configuration/environment-variables/","title":"Environment variables","text":"The first steps of the Getting Started guide mention creating an .env
file.
The sections below outline in more detail the application environment variables that you may want to override, and their purpose. In App Service, this is more generally called the \u201cconfiguration\u201d.
See other topic pages in this section for more specific environment variable configurations.
"},{"location":"configuration/environment-variables/#amplitude","title":"Amplitude","text":"Amplitude API docs
Read more at https://developers.amplitude.com/docs/http-api-v2#request-format
"},{"location":"configuration/environment-variables/#analytics_key","title":"ANALYTICS_KEY
","text":"Deployment configuration
You may change this setting when deploying the app to a non-localhost domain
Amplitude API key for the project where the app will direct events.
If blank or an invalid key, analytics events aren\u2019t captured (though may still be logged).
"},{"location":"configuration/environment-variables/#django","title":"Django","text":""},{"location":"configuration/environment-variables/#django_admin","title":"DJANGO_ADMIN
","text":"Boolean:
True
: activates Django\u2019s built-in admin interface for content authoring.False
(default): skips this activation.DJANGO_ALLOWED_HOSTS
","text":"Deployment configuration
You must change this setting when deploying the app to a non-localhost domain
Django docs
Settings: ALLOWS_HOSTS
A list of strings representing the host/domain names that this Django site can serve.
"},{"location":"configuration/environment-variables/#django_db_dir","title":"DJANGO_DB_DIR
","text":"Deployment configuration
You may change this setting when deploying the app to a non-localhost domain
The directory where Django creates its Sqlite database file. Must exist and be writable by the Django process.
By default, the base project directory (i.e. the root of the repository).
"},{"location":"configuration/environment-variables/#django_db_reset","title":"DJANGO_DB_RESET
","text":"Deployment configuration
You may change this setting when deploying the app to a non-localhost domain
Boolean:
True
(default): deletes the existing database file and runs fresh Django migrations.False
: Django uses the existing database file.DJANGO_DEBUG
","text":"Deployment configuration
Do not enable this in production
Django docs
Settings: DEBUG
Boolean:
True
: the application is launched with debug mode turned on, allows pausing on breakpoints in the code, changes how static files are servedFalse
(default): the application is launched with debug mode turned off, similar to how it runs in productionDJANGO_LOCAL_PORT
","text":"Local configuration
This setting only affects the app running on localhost
The port used to serve the Django application from the host machine (that is running the application container).
i.e. if you are running the app in Docker on your local machine, this is the port that the app will be accessible from at http://localhost:$DJANGO_LOCAL_PORT
From inside the container, the app is always listening on port 8000
.
DJANGO_LOG_LEVEL
","text":"Deployment configuration
You may change this setting when deploying the app to a non-localhost domain
Django docs
Settings: LOGGING_CONFIG
The log level used in the application\u2019s logging configuration.
By default the application sends logs to stdout
.
DJANGO_SECRET_KEY
","text":"Deployment configuration
You must change this setting when deploying the app to a non-localhost domain
Django docs
Settings: SECRET_KEY
Django\u2019s primary secret, keep this safe!
"},{"location":"configuration/environment-variables/#django_superuser_email","title":"DJANGO_SUPERUSER_EMAIL
","text":"Deployment configuration
You may change this setting when deploying the app to a non-localhost domain
Required configuration
This setting is required when DJANGO_ADMIN
is true
The email address of the Django Admin superuser created during initialization.
"},{"location":"configuration/environment-variables/#django_superuser_password","title":"DJANGO_SUPERUSER_PASSWORD
","text":"Deployment configuration
You may change this setting when deploying the app to a non-localhost domain
Required configuration
This setting is required when DJANGO_ADMIN
is true
The password of the Django Admin superuser created during initialization.
"},{"location":"configuration/environment-variables/#django_superuser_username","title":"DJANGO_SUPERUSER_USERNAME
","text":"Deployment configuration
You may change this setting when deploying the app to a non-localhost domain
Required configuration
This setting is required when DJANGO_ADMIN
is true
The username of the Django Admin superuser created during initialization.
"},{"location":"configuration/environment-variables/#django_trusted_origins","title":"DJANGO_TRUSTED_ORIGINS
","text":"Deployment configuration
You must change this setting when deploying the app to a non-localhost domain
Django docs
Settings: CSRF_TRUSTED_ORIGINS
Comma-separated list of hosts which are trusted origins for unsafe requests (e.g. POST)
"},{"location":"configuration/environment-variables/#healthcheck_user_agents","title":"HEALTHCHECK_USER_AGENTS
","text":"Deployment configuration
You must change this setting when deploying the app to a non-localhost domain
Comma-separated list of User-Agent strings which, when present as an HTTP header, should only receive healthcheck responses. Used by our HealthcheckUserAgents
middleware.
requests
configuration","text":"requests
docs
Docs for timeouts
"},{"location":"configuration/environment-variables/#requests_connect_timeout","title":"REQUESTS_CONNECT_TIMEOUT
","text":"The number of seconds requests
will wait for the client to establish a connection to a remote machine. Defaults to 3 seconds.
REQUESTS_READ_TIMEOUT
","text":"The number of seconds the client will wait for the server to send a response. Defaults to 1 second.
"},{"location":"configuration/environment-variables/#cypress-tests","title":"Cypress tests","text":"Cypress docs
CYPRESS_*
variables
CYPRESS_baseUrl
","text":"The base URL for the (running) application, against which all Cypress .visit()
etc. commands are run.
When Cypress is running inside the devcontainer, this should be http://localhost:8000
. When Cypress is running outside the devcontainer, check the DJANGO_LOCAL_PORT
.
SENTRY_DSN
","text":"Sentry docs
Data Source Name (DSN)
Enables sending events to Sentry.
"},{"location":"configuration/environment-variables/#sentry_environment","title":"SENTRY_ENVIRONMENT
","text":"Sentry docs
environment
config value
Segments errors by which deployment they occur in. This defaults to local
, and can be set to match one of the environment names.
SENTRY_REPORT_URI
","text":"Sentry docs
Security Policy Reporting
Collect information on Content-Security-Policy (CSP) violations. Read more about CSP configuration in Benefits.
To enable report collection, set this env var to the authenticated Sentry endpoint.
"},{"location":"configuration/environment-variables/#sentry_traces_sample_rate","title":"SENTRY_TRACES_SAMPLE_RATE
","text":"Sentry docs
traces_sample_rate
Control the volume of transactions sent to Sentry. Value must be a float in the range [0.0, 1.0]
.
The default is 0.0
(i.e. no transactions are tracked).
Benefits can be configured to require users to authenticate with an OAuth Open ID Connect (OIDC) provider, before allowing the user to begin the Eligibility Verification process.
This section describes the related settings and how to configure the application to enable this feature.
"},{"location":"configuration/oauth/#authlib","title":"Authlib","text":"Authlib docs
Read more about configuring Authlib for Django
Benefits uses the open-source Authlib for OAuth and OIDC client implementation. See the Authlib docs for more details about what features are available. Specifically, from Authlib we:
client.authorize_redirect()
to send the user into the OIDC server\u2019s authentication flow, with our authorization callback URLclient.authorize_access_token()
to get a validated id token from the OIDC serverOAuth settings are configured as instances of the AuthProvider
model.
The data migration file contains sample values for an AuthProvider
configuration. You can set values for a real Open ID Connect provider in environment variables so that they are used instead of the sample values.
The benefits.oauth.client
module defines helpers for registering OAuth clients, and creating instances for use in e.g. views.
register_providers(oauth_registry)
uses data from AuthProvider
instances to register clients into the given registryoauth
is an authlib.integrations.django_client.OAuth
instanceProviders are registered into this instance once in the OAuthAppConfig.ready()
function at application startup.
Consumers call oauth.create_client(client_name)
with the name of a previously registered client to obtain an Authlib client instance.
The benefits application has a simple, single-configuration Rate Limit that acts per-IP to limit the number of consecutive requests in a given time period, via nginx limit_req_zone
and limit_req
directives.
The configured rate limit is 12 requests/minute, or 1 request/5 seconds:
limit_req_zone $limit zone=rate_limit:10m rate=12r/m;\n
"},{"location":"configuration/rate-limit/#http-method-selection","title":"HTTP method selection","text":"An NGINX map variable lists HTTP methods that will be rate limited:
map $request_method $limit {\ndefault \"\";\nPOST $binary_remote_addr;\n}\n
The default
means don\u2019t apply a rate limit.
To add a new method, add a new line:
map $request_method $limit {\ndefault \"\";\nOPTIONS $binary_remote_addr;\nPOST $binary_remote_addr;\n}\n
"},{"location":"configuration/rate-limit/#app-path-selection","title":"App path selection","text":"The limit_req
is applied to an NGINX location
block with a case-insensitive regex to match paths:
location ~* ^/(eligibility/confirm)$ {\nlimit_req zone=rate_limit;\n# config...\n}\n
To add a new path, add a regex OR |
with the new path (omitting the leading slash):
location ~* ^/(eligibility/confirm|new/path)$ {\nlimit_req zone=rate_limit;\n# config...\n}\n
"},{"location":"configuration/recaptcha/","title":"Configuring reCAPTCHA","text":"reCAPTCHA docs
See the reCAPTCHA Developer\u2019s Guide for more information
reCAPTCHA v3 is a free Google-provided service that helps protect the app from spam and abuse by using advanced risk analysis techniques to tell humans and bots apart.
reCAPTCHA is applied to all forms in the Benefits app that collect user-provided information. Version 3 works silently in the background, with no additional interaction required by the user.
"},{"location":"configuration/recaptcha/#environment-variables","title":"Environment variables","text":"Warning
The following environment variables are all required to activate the reCAPTCHA feature
"},{"location":"configuration/recaptcha/#django_recaptcha_api_url","title":"DJANGO_RECAPTCHA_API_URL
","text":"URL to the reCAPTCHA JavaScript API library.
By default, https://www.google.com/recaptcha/api.js
DJANGO_RECAPTCHA_SITE_KEY
","text":"Site key for the reCAPTCHA configuration.
"},{"location":"configuration/recaptcha/#django_recaptcha_secret_key","title":"DJANGO_RECAPTCHA_SECRET_KEY
","text":"Secret key for the reCAPTCHA configuration.
"},{"location":"configuration/recaptcha/#django_recaptcha_verify_url","title":"DJANGO_RECAPTCHA_VERIFY_URL
","text":"reCAPTCHA docs
Verifying the user\u2019s response
URL for the reCAPTCHA verify service.
By default, https://www.google.com/recaptcha/api/siteverify
Before starting any configuration, the Cal-ITP team and transit agency staff should have a kickoff meeting to confirm that information provided is complete, implementation plan is feasible, and any approvals needed have been obtained.
Then, the following steps are done by the Cal-ITP team to configure a new transit agency in the Benefits application.
Note that a TransitAgency
model requires:
EligibilityType
sEligibilityVerifier
s used to verify one of those supported eligibility typesPaymentProcessor
for enrolling the user\u2019s contactless card for discountsinfo_url
and phone
for users to contact customer serviceAlso note that these steps assume the transit agency is using Littlepay as their payment processor. Support for integration with other payment processors may be added in the future.
"},{"location":"configuration/transit-agency/#configuration-for-development-and-testing","title":"Configuration for development and testing","text":"For development and testing, only a Littlepay customer group is needed since there is no need to interact with any discount product. (We don\u2019t have a way to tap a card against the QA system to trigger a discount and therefore have no reason to associate the group with any product.)
"},{"location":"configuration/transit-agency/#steps","title":"Steps","text":"group_id
on a new EligibilityType
in the Benefits database. (See Configuration data for more on loading the database.)EligibilityVerifier
in the database for each supported eligibility type. This will require configuration for either API-based verification or verification through an OAuth Open ID Connect provider (e.g. sandbox Login.gov) \u2013 either way, this resource should be meant for testing.TransitAgency
in the database and associates it with the new EligibilityType
s and EligibilityVerifier
s as well as the existing Littlepay PaymentProcessor
.For production validation, both a customer group and discount product are needed. The customer group used here is a temporary one for testing only. Production validation is done against the Benefits test environment to avoid disruption of the production environment.
"},{"location":"configuration/transit-agency/#steps_1","title":"Steps","text":"EligibilityType
for testing purposes in the Benefits database and sets the group_id
to the ID of the newly-created group.EligibilityVerifier
with configuration for a testing environment to ensure successful eligibility verification. (For example, use sandbox Login.gov instead of production Login.gov.)PaymentProcessor
for testing purposes with configuration for production Littlepay.TransitAgency
(created previously) with associations to the eligibility types, verifiers, and payment processor that were just created for testing.At this point, Cal-ITP and transit agency staff can coordinate to do on-the-ground testing where a live card is tapped on a live payment validator.
"},{"location":"configuration/transit-agency/#production-validation-testing","title":"Production validation testing","text":"Once production validation is done, the transit agency can be added to the production Benefits database.
"},{"location":"configuration/transit-agency/#steps_2","title":"Steps","text":"group_id
for a new EligibilityType
in the Benefits database.EligibilityVerifier
with configuration for the production eligibility verification system.TransitAgency
in the database with proper associations to eligibility types, verifiers, and payment processor.At this point, the customer group that was created in production Littlepay for testing purposes can be deleted. The temporary production validation objects in the Benefits database can also be deleted.
EligibilityType
s, EligibilityVerifier
s, and PaymentProcessor
that were created in the Benefits test environment.dev-benefits.calitp.org is currently deployed into a Microsoft Azure account provided by California Department of Technology (CDT)\u2019s Office of Enterprise Technology (OET), a.k.a. the \u201cDevSecOps\u201d team. More specifically, it uses custom containers on Azure App Service. More about the infrastructure.
"},{"location":"deployment/#deployment-process","title":"Deployment process","text":"The Django application gets built into a Docker image with NGINX and Gunicorn. SQLite is used within that same container to store configuration data; there is no external database.
The application is deployed to an Azure Web App Container using three separate environments for dev
, test
, and prod
.
A GitHub Action per environment is responsible for building that branch\u2019s image and pushing to GitHub Container Registry (GHCR).
GitHub POSTs a webhook to the Azure Web App when an image is published to GHCR, telling Azure to restart the app and pull the latest image.
You can view what Git commit is deployed for a given environment by visitng the URL path /static/sha.txt
.
Configuration settings are stored as Application Configuration variables in Azure. Data is loaded via Django data migrations.
"},{"location":"deployment/#docker-images","title":"Docker images","text":"Docker images for each of the deploy branches are available from GitHub Container Registry (GHCR):
ghcr.io/cal-itp/benefits
dev
, test
, prod
The infrastructure is configured as code via Terraform, for various reasons.
"},{"location":"deployment/infrastructure/#architecture","title":"Architecture","text":""},{"location":"deployment/infrastructure/#system-interconnections","title":"System interconnections","text":"flowchart LR\n benefits[Benefits application]\n style benefits stroke-width:5px\n recaptcha[Google reCAPTCHA]\n rider((User's browser))\n idg[Identity Gateway]\n elig_server[Eligibility Server]\n ac_data[(Agency Card data)]\n cookies[(Cookies)]\n\n benefits -->|Errors| sentry\n elig_server -->|Errors| sentry\n\n rider --> benefits\n rider -->|Credentials and identity proofing| Login.gov\n rider --> recaptcha\n rider -->|Payment card info| Littlepay\n rider -->|Events| Amplitude\n rider -->|Session| cookies\n\n benefits --> idg\n benefits <--> recaptcha\n benefits -->|Events| Amplitude\n benefits -->|Group enrollment| Littlepay\n benefits --> elig_server\n\n subgraph \"Agency Cards (e.g. MST Courtesy Cards)\"\n elig_server --> ac_data\n end\n\n idg --> Login.gov\n Login.gov -->|User attributes| idg\n idg -->|User attributes| benefits
"},{"location":"deployment/infrastructure/#benefits-application","title":"Benefits application","text":"flowchart LR\n internet[Public internet]\n frontdoor[Front Door]\n django[Django application]\n interconnections[Other system interconnections]\n\n internet --> Cloudflare\n Cloudflare --> frontdoor\n django <--> interconnections\n\n subgraph Azure\n frontdoor --> NGINX\n\n subgraph App Service\n subgraph Custom container\n direction TB\n NGINX --> django\n end\n end\n end
Front Door also includes the Web Application Firewall (WAF) and handles TLS termination. Front Door is managed by the DevSecOps team.
"},{"location":"deployment/infrastructure/#ownership","title":"Ownership","text":"The following things in Azure are managed by the California Department of Technology (CDT)\u2019s DevSecOps (OET) team:
Within the CDT Digital CA
directory (how to switch), there are two Subscriptions, with Resource Groups under each. Each environment corresponds to a single Resource Group, Terraform Workspace, and branch.
CDT/ODI Development
RG-CDT-PUB-VIP-CALITP-D-001
dev
dev
Test CDT/ODI Development
RG-CDT-PUB-VIP-CALITP-T-001
test
test
Prod CDT/ODI Production
RG-CDT-PUB-VIP-CALITP-P-001
default
prod
All resources in these Resource Groups should be reflected in Terraform in this repository. The exceptions are:
prevent_destroy
is used on these Resources.You\u2019ll see these referenced in Terraform as data sources.
For browsing the Azure portal, you can switch your Default subscription filter
.
Terraform is plan
\u2018d when code is pushed to any branch on GitHub, then apply
\u2018d when merged to dev
. While other automation for this project is done through GitHub Actions, we use an Azure Pipeline (above) for a couple of reasons:
Install dependencies:
Azure CLI
Terraform - see exact version in deploy.yml
Authenticate using the Azure CLI.
az login\n
terraform/
directory../init.sh <env>\n
terraform plan\n
For Azure resources, you need to ignore changes to tags, since they are automatically created by Azure Policy.
lifecycle {\n ignore_changes = [tags]\n}\n
"},{"location":"deployment/infrastructure/#naming-conventions","title":"Naming conventions","text":"The DevSecOps team sets the following naming convention for Resources:
<<Resource Type>>-<<Department>>-<<Public/Private>>-<<Project Category>>-<<Project Name>>-<<Region>><<OS Type>>-<<Environment>>-<<Sequence Number>>\n
"},{"location":"deployment/infrastructure/#sample-names","title":"Sample Names","text":"RG-CDT-PUB-VIP-BNSCN-E-D-001
ASP-CDT-PUB-VIP-BNSCN-EL-P-001
AS-CDT-PUB-VIP-BNSCN-EL-D-001
Use the following shorthand for conveying the Resource Type as part of the Resource Name:
Resource Convention App ServiceAS
App Service Plan ASP
Virtual Network VNET
Resource Group RG
Virtual Machine VM
Database DB
Subnet SNET
Front Door FD
"},{"location":"deployment/infrastructure/#azure-environment-setup","title":"Azure environment setup","text":"The following steps are required to set up the environment, with linked issues to automate them:
terraform apply
slack-benefits-notify-email
Packages
eventThis is not a complete step-by-step guide; more a list of things to remember. This may be useful as part of incident response.
"},{"location":"deployment/release/","title":"Making a release","text":"This list outlines the manual steps needed to make a new release of the benefits
app.
A release is made by merging changes into the prod
branch, which kicks off a deployment to the production environment. More details on the deployment steps can be found under Workflows.
The list of releases can be found on the repository Releases page on GitHub.
Start a new Release on Github
"},{"location":"deployment/release/#0-decide-on-the-new-version-number","title":"0. Decide on the new version number","text":"A new release implies a new version.
benefits
uses the CalVer versioning scheme, where version numbers look like: YYYY.0M.R
YYYY
is the 4-digit year of the release; e.g. 2021
, 2022
0M
is the 2-digit, 0-padded month of the release; e.g. 02
is February, 12
is December.R
is the 1-based release counter for the given year and month; e.g. 1
for the first release of the month, 2
for the second, and so on.Typically changes for a release will move from dev
, to test
, to prod
. This assumes dev
is in a state that it can be deployed without disruption. (This is called a Regular
release.)
If dev
or test
contain in-progress work that is not ready for production, and a hotfix is needed in production, a separate process to test the changes before deploying to prod
must be undertaken. (This is called a Hotfix
release.)
As implied in the previous step, all releases follow the same version number format.
The following diagram shows how a release should propagate to prod
under different circumstances:
graph LR\n A(Release branch) --> B{Are dev and test ready to deploy?};\n B -->|Yes| C(dev);\n C --> D(test);\n D --> E(prod);\n B -->|No| E;
By convention the release branch is called release/YYYY.0M.R
using the upcoming version number.
The app code maintains a version number in benefits/__init__.py
, used by the instrumentation and logging systems.
This version number must be updated to match the new version in the same format: YYYY.0M.R
Initially from the release branch to the target environment branch, following the merge sequence in the diagram above.
"},{"location":"deployment/release/#4-merge-the-pr","title":"4. Merge the PR","text":"After checks pass and review approval is given, merge the PR to kick off the deployment.
Repeat steps 3 and 4 for each deployment environment target, again following the merge sequence in the diagram above.
"},{"location":"deployment/release/#5-tag-the-release","title":"5. Tag the release","text":"Once the deploy has completed to prod
, the version can be tagged and pushed to GitHub.
From a local terminal:
git fetch\n\ngit checkout prod\n\ngit reset --hard origin/prod\n\ngit tag YYYY.0M.R\n\ngit push origin YYYY.0M.R\n
"},{"location":"deployment/release/#6-generate-release-notes","title":"6. Generate release notes","text":"Also add a written description, and include screenshots/animations of new/updated pages/workflows.
"},{"location":"deployment/secrets/","title":"Setting secrets","text":"Secret values used by the Benefits application (such as API keys, private keys, certificates, etc.) are stored in an Azure Key Vault for each environment.
To set a secret, you can use the Azure portal or the Azure CLI.
There are helper scripts under terraform/secrets
which build up the Azure CLI command, given some inputs. The usage is as follows:
First, make sure you are set up for local development and that you are in the terraform/secrets
directory.
cd terraform/secrets\n
To set a secret by providing a value:
./value.sh <environment_letter> <secret_name> <secret_value>\n
where environment_letter
is D
for development, T
for test, and P
for production.
To set a secret by providing the path of a file containing the secret (useful for multi-line secrets):
./file.sh <environment_letter> <secret_name> <file_path>\n
To verify the value of a secret, you can use the helper script named read.sh
.
./read.sh <environment_letter> <secret_name>\n
"},{"location":"deployment/secrets/#refreshing-secrets","title":"Refreshing secrets","text":"To make sure the Benefits application uses the latest secret values in Key Vault, you will need to make a change to the app service\u2019s configuration. If you don\u2019t do this step, the application will instead use cached values, which may not be what you expect. See the Azure docs for more details.
The steps are:
change_me_to_refresh_secrets
.The effects of following those steps should be:
change_me_to_refresh_secrets
is set back to the value defined in our Terraform file for the App Service resource.We have ping tests set up to notify about availability of each environment. Alerts go to #benefits-notify.
"},{"location":"deployment/troubleshooting/#logs","title":"Logs","text":""},{"location":"deployment/troubleshooting/#azure-app-service-logs","title":"Azure App Service Logs","text":"Open the Logs
for the environment you are interested in. The following tables are likely of interest:
AppServiceConsoleLogs
: stdout
and stderr
coming from the containerAppServiceHTTPLogs
: requests coming through App ServiceAppServicePlatformLogs
: deployment informationFor some pre-defined queries, click Queries
, then Group by: Query type
, and look under Query pack queries
.
After setting up the Azure CLI, you can use the following command to stream live logs:
az webapp log tail --resource-group RG-CDT-PUB-VIP-CALITP-P-001 --name AS-CDT-PUB-VIP-CALITP-P-001 2>&1 | grep -v /healthcheck\n
"},{"location":"deployment/troubleshooting/#scm","title":"SCM","text":"https://as-cdt-pub-vip-calitp-p-001-dev.scm.azurewebsites.net/api/logs/docker
"},{"location":"deployment/troubleshooting/#sentry","title":"Sentry","text":"Cal-ITP\u2019s Sentry instance collects both errors (\u201cIssues\u201d) and app performance info.
Alerts are sent to #benefits-notify in Slack. Others can be configured.
You can troubleshoot Sentry itself by turning on debug mode and visiting /error/
.
This section serves as the runbook for Benefits.
"},{"location":"deployment/troubleshooting/#terraform-lock","title":"Terraform lock","text":"General info
If Terraform commands fail (locally or in the Pipeline) due to an Error acquiring the state lock
:
Lock Info
for the Created
timestamp. If it\u2019s in the past ten minutes or so, that probably means Terraform is still running elsewhere, and you should wait (stop here).apply
and it\u2019s sitting waiting for them to approve it. They will need to (gracefully) exit for the lock to be released.Created
time is more than ten minutes ago, that probably means the last Terraform command didn\u2019t release the lock. You\u2019ll need to grab the ID
from the Lock Info
output and force unlock.If the container fails to start, you should see a downtime alert. Assuming this app version was working in another environment, the issue is likely due to misconfiguration. Some things you can do:
Littlepay API issues may show up as:
Connect your card
button doesn\u2019t workA common problem that causes Littlepay API failures is that the certificate expired. To resolve:
If the Benefits application gets a 403 error when trying to make API calls to the Eligibility Server, it may be because the outbound IP addresses changed, and the Eligibility Server firewall is still restricting access to the old IP ranges.
outbound_ip_ranges
output
values from the most recent Benefit deployment to the relevant environment.Edit
Variables
Note there is nightly downtime as the Eligibility Server restarts and loads new data.
"},{"location":"deployment/workflows/","title":"Workflows","text":"The GitHub Actions deployment workflow configuration lives at .github/workflows/deploy.yml
.
Info
The entire process from GitHub commit to full redeploy of the application can take from around 5 minutes to 10 minutes or more depending on the deploy environment. Have patience!
"},{"location":"deployment/workflows/#deployment-steps","title":"Deployment steps","text":"The workflow is triggered with a push
to the corresponding branch. It also responds to the workflow_dispatch
event to allow manually triggering via the GitHub Actions UI.
When a deployment workflow runs, the following steps are taken:
"},{"location":"deployment/workflows/#1-checkout-code","title":"1. Checkout code","text":"From the tip of the corresponding branch (e.g. dev
)
Using the github.actor
and built-in GITHUB_TOKEN
secret
Build the root Dockerfile
, tagging with both the branch name (e.g. dev
) and the SHA from the HEAD commit.
Push this image:tag into GHCR.
"},{"location":"deployment/workflows/#4-app-service-deploy","title":"4. App Service deploy","text":"Each Azure App Service instance is configured to listen to a webhook from GitHub, then deploy the image.
"},{"location":"development/","title":"VS Code with devcontainers","text":"Info
VS Code with Devcontainers is the recommended development setup
Warning
You must build the base Docker image benefits_client:latest
before running the devcontainer. See Local Setup
Remote - Containers
extension","text":"VS Code can be used together with Docker via the Remote - Containers extension to enable a container-based development environment. This repository includes a .devcontainer.json
file that configures remote container development and debugging.
With the Remote - Containers extension enabled, open the folder containing this repository inside Visual Studio Code.
You should receive a prompt in the Visual Studio Code window; click Reopen in Container
to run the development environment inside a container.
If you do not receive a prompt, or when you feel like starting from a fresh environment:
Remote-Containers
to filter the commandsRebuild and Reopen in Container
to completely rebuild the devcontainerReopen in Container
to reopen the most recent devcontainer buildOnce running inside a container, press F5 to attach a debugger to the client, running on http://localhost
at a port dynamically assigned by Docker. See Docker dynamic ports for more information.
Add breakpoints in the code and browse the local site to trigger a pause. Press F5 to continue execution from the breakpoint.
"},{"location":"development/#changing-launch-configuration","title":"Changing launch configuration","text":"By default, the application is launched with DJANGO_DEBUG=True
, causing Django to provide additional logging and error output and to relax certain security settings.
Alternatively, you may attach to an instance launched with DJANGO_DEBUG=False
, to allow debugging the app in a state more similar to production.
In VS Code, press Ctrl/Cmd + Shift + D to open the Run and Debug
pane, where you can select between the various configurations (disregard the duplicate entry, selecting either will work):
The environment can also be overridden for the debug session by editing the configuration in .vscode/launch.json
, where any of the supported environment variables may be specified in the env
block. For example, to test the app with reCAPTCHA environment variables:
{\n \"name\": \"Django: Benefits Client\",\n \"type\": \"python\",\n \"request\": \"launch\",\n \"program\": \"${workspaceFolder}/manage.py\",\n \"args\": [\"runserver\", \"--insecure\", \"0.0.0.0:8000\"],\n \"django\": true,\n \"env\": {\n // existing field...\n \"DJANGO_DEBUG\": \"true\",\n // add these 2 entries with the values for reCAPTCHA\n \"DJANGO_RECAPTCHA_SITE_KEY\": \"<SITE KEY HERE>\",\n \"DJANGO_RECAPTCHA_SECRET_KEY\": \"<SECRET KEY HERE>\"\n }\n}\n
See #1071 for more examples and context.
"},{"location":"development/#exiting-devcontainers","title":"Exiting devcontainers","text":"To close out of the container and re-open the directory locally in Visual Studio Code:
Remote-Containers
to filter the commandsReopen Locally
This project enforces the Conventional Commits style for commit message formatting:
<type>[(optional-scope)]: <description>\n\n[optional body]\n
Where <type>
indicates the nature of the commit, one of a list of possible values:
build
- related to the build or compile processchore
- administrative tasks, cleanups, dev environmentci
- related to automated builds/tests etc.docs
- updates to the documentationfeat
- new code, features, or interfacesfix
- bug fixesperf
- performance improvementsrefactor
- non-breaking logic refactorsrevert
- undo a prior changestyle
- code style and formattingtest
- having to do with testing of any kindE.g.
git commit -m \"feat(eligibility/urls): add path for start\"\n
"},{"location":"development/commits-branches-merging/#branches","title":"Branches","text":"The default GitHub branch is dev
. All new feature work should be in the form of Pull Requests (PR) that target dev
as their base.
In addition to dev
, the repository has three other long-lived branches:
test
and prod
correspond to the Test and Production deploy environments, respectively.gh-pages
hosts the compiled documentation, and is always forced-pushed by the docs build process.Branch protection rules are in place on three environment branches (dev
, test
, prod
) to:
PR branches are typically named with a conventional type prefix, a slash /
, and then descriptor in lower-dashed-case
:
<type>/<lower-dashed-descriptor>\n
E.g.
git checkout -b feat/verifier-radio-buttons\n
and
git checkout -b refactor/verifier-model\n
PR branches are deleted once their PR is merged.
"},{"location":"development/commits-branches-merging/#merging","title":"Merging","text":"Merging of PRs should be done using the merge commit strategy. The PR author should utilize git rebase -i
to ensure their PR commit history is clean, logical, and free of typos.
When merging a PR into dev
, it is customary to format the merge commit message like:
Title of PR (#number)\n
instead of the default:
Merge pull request #number from source-repo/source-branch\n
"},{"location":"development/docker-dynamic-ports/","title":"Docker dynamic ports","text":"Docker dynamically assigns host machine ports that map into container application ports.
"},{"location":"development/docker-dynamic-ports/#inside-the-devcontainer","title":"Inside the Devcontainer","text":"Info
The Devcontainer can bind to a single container\u2019s port(s) and present those to your localhost machine via VS Code. Other services started along with the Devcontainer are not visible in VS Code. See Outside the Devconatiner for how to find information on those.
Once started with F5, the benefits
Django application runs on port 8000
inside the Devcontainer. To find the localhost address, look on the PORTS tab in VS Code\u2019s Terminal window. The Local Address
corresponding to the record where 8000
is in the Port
column is where the site is accessible on your host machine.
Replace 0.0.0.0
with localhost
and use the same port number shown in the Local Address
column. This is highlighted by the red box in the image below:
When running a docker compose ...
command, or in other scenarios outside of the Devcontainer, there are multiple ways to find the http://localhost
port corresponding to the service in question.
The Docker Desktop application shows information about running containers and services/groups, including information about bound ports. In most cases, the application provides a button to launch a container/service directly in your browser when a port binding is available.
In the Containers / Apps tab, expand the service group if needed to find the container in question, where you should see labels indicating the container is RUNNING
and bound to PORT: XYZ
.
Hover over the container in question, and click the Open in Browser button to launch the app in your web browser.
"},{"location":"development/docker-dynamic-ports/#docker-cli-commands","title":"Docker CLI commands","text":"Using the docker
command line interface, you can find the bound port(s) of running containers.
docker ps -f name=<service>\n
e.g. for the docs
service:
docker ps -f name=docs\n
This prints output like the following:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES\n0d5b2e1fb910 benefits_client:dev \"mkdocs serve --dev-\u2026\" 2 minutes ago Up 2 minutes 0.0.0.0:62093->8000/tcp benefits_docs_1\n
Looking at the PORTS
column:
PORTS\n0.0.0.0:62093->8000/tcp\n
We can see that locally, port 62093
is bound to the container port 8000
.
In this case, entering http://localhost:62093
in the web browser navigates to the docs
site homepage.
Django docs
Internationalization and localization
Translation
Message files
English messages: benefits/locale/en/LC_MESSAGES/django.po
The Cal-ITP Benefits application is fully internationalized and available in both English and Spanish.
It uses Django\u2019s built-in support for translation using message files, which contain entries of msgid
/msgstr
pairs. The msgid
is referenced in source code so that Django takes care of showing the msgstr
for the user\u2019s language.
Django has a utility command called makemessages
to help maintain message files. It ensures that msgid
s in the message files are actually used somewhere in source code and also detects new msgid
s.
There is a helper script that runs this command with some arguments: bin/makemessages.sh
bin/makemessages.sh\n
Developers should use this script to update message files in a consistent way.
"},{"location":"development/i18n/#workflow","title":"Workflow","text":""},{"location":"development/i18n/#updating-english","title":"Updating English","text":"Add English copy to templates directly first. Then, run the helper script, bin/makemessages.sh
, so Django can update the django.po
files for English and Spanish with the new copy.
Find the English copy in the Spanish django.po
file as a msgid
, and add the corresponding Spanish translation as the msgstr
. Again, run the helper script for formatting and bin/init.sh
to confirm the translation is rendered properly.
When templates have different copy per agency, create a new template for that agency-specific copy to live in. See the example of the MST-specific agency index page file, named index--mst.html
. Include the agency-specific template file name in the migration object, as done here for MST, with eligibility_index_template=\"eligibility/index--mst.html\"
.
From Django docs:
makemessages
sometimes generates translation entries marked as fuzzy, e.g. when translations are inferred from previously translated strings.
Usually, the inferred translation is not correct, so make sure to review the msgstr
and fix it if necessary. Then, remove the commented lines starting with #, fuzzy
(otherwise the entry will not be used).
black
provides Python code formatting via the [ms-python.python
][python] VS Code extension.
prettier
provides code formatting for front-end (CSS/JavaScript) via the esbenp.prettier-vscode
VS Code extension.
See the .vscode/settings.json
file for more information on how this is configured in the devcontainer.
flake8
provides Python code linting via the [ms-python.python
][python] VS Code extension.
See the .vscode/settings.json
file for more information on how this is configured in the devcontainer.
This repository uses pre-commit
hooks to check and format code. The .pre-commit-config.yaml
file defines a number of pre-commit
hooks, including black
, flake8
, line ending and whitespace checkers, and more.
pre-commit
is installed and activated within the devcontainer and runs automatically with each commit.
Branch protection rules on the environment branches in GitHub ensure that pre-commit
checks have passed before a merge is allowed. See the workflow file at .github/workflows/pre-commit.yml
.
Models and migrations
benefits/core/models.py
benefits/core/migrations/0001_initial.py
benefits/core/migrations/0002_data.py
Cal-ITP Benefits defines a number of models in the core application, used throughout the codebase to configure different parts of the UI and logic.
The Cal-ITP Benefits database is a simple read-only Sqlite database, initialized from the data migration files.
"},{"location":"development/models-migrations/#migrations","title":"Migrations","text":"The database is rebuilt from scratch each time the container starts. We maintain a few migration files that set up the schema and load initial data.
These files always represent the current schema and data for the database and match the current structure of the model classes.
"},{"location":"development/models-migrations/#updating-models","title":"Updating models","text":"When models are updated, the migration should be updated as well.
A simple helper script exists to regenerate the migration file based on the current state of models in the local directory:
bin/makemigrations.sh
bin/makemigrations.sh\n
This script:
makemigrations
commandblack
This will result in a simple diff of changes on the schema migration file. Commit these changes (including the timestamp!) along with the model changes.
"},{"location":"development/test-server/","title":"Test Eligibility Verification server","text":"A basic eligibility verification server is available for testing. The server code is available on GitHub, with its own set of documentation.
"},{"location":"development/test-server/#running-locally","title":"Running locally","text":"docker compose up [-d] server\n
The optional -d
flag will start in detatched mode and allow you to continue using the terminal session. Otherwise your terminal will be attached to the container\u2019s terminal, showing the startup and runtime output.
The API server is running on http://localhost
at a port dynamically assigned by Docker. See Docker dynamic ports for more information on accessing the server on localhost.
From within another Compose service container, the server is at http://server:5000
using the service-forwarding features of Compose.
In either case, the endpoint /verify
serves as the Eligibility Verification API endpoint.
When running the Devcontainer, the server is automatically started.
See Docker dynamic ports for more information on accessing the server on localhost.
The server is accessible from within the Devcontainer at its Compose service address: http://server:5000
.
Running the Benefits application in a local, non-production environment requires Docker.
The following commands should be run in a terminal program like bash
.
git clone https://github.com/cal-itp/benefits\n
"},{"location":"getting-started/#create-an-environment-file","title":"Create an environment file","text":"The application is configured with defaults to run locally, but an .env
file is required to run with Docker Compose. This file can be empty, or environment overrides can be added as needed:
touch .env\n
E.g. to change the localhost port from the default 8000
to 9000
, add the following line to your .env
file:
DJANGO_LOCAL_PORT=9000\n
See Configuration for more details on supported environment variables and their settings.
"},{"location":"getting-started/#run-the-build-script","title":"Run the build script","text":"This builds the runtime and devcontainer images:
bin/build.sh\n
If you need all layers to rebuild, use:
docker compose build --no-cache client\n
"},{"location":"getting-started/#start-the-client","title":"Start the client","text":"The optional -d
flag will start in detatched mode and allow you to continue using the terminal session.
docker compose up -d client\n
Otherwise attach your terminal to the container\u2019s terminal, showing the startup and runtime output:
docker compose up client\n
After initialization, the client is running running on http://localhost:8000
by default.
If DJANGO_ADMIN=true
, the backend administrative interface can be accessed at the /admin
route using the superuser account you setup as part of initialization.
By default, sample values are used to initialize Django. Alternatively you may:
Stop the running services with:
docker compose down\n
"},{"location":"getting-started/documentation/","title":"Documentation","text":"This website is built using mkdocs
from the contents of the dev
(default) branch.
The mkdocs.yml
file in the repository root configures the build process, including the available plugins.
All content lives under the docs/
directory in the repository.
To add new sections/articles, create new directories and files under the docs/
directory, in Markdown format.
The pencil icon is a shortcut to quickly edit the content of the page you are viewing on the website:
Above: Screenshot showing the edit pencil circled in red
"},{"location":"getting-started/documentation/#features","title":"Features","text":"See mkdocs.yml
for enabled plugins/features
Use code fences with mermaid
type to render Mermaid diagrams within docs. For example, this markdown:
```mermaid\ngraph LR\n Start --> Stop\n```\n
Yields this diagram:
graph LR\n Start --> Stop
"},{"location":"getting-started/documentation/#running-locally","title":"Running locally","text":"The documentation website can be run locally using Docker Compose:
# from inside the .devcontainer/ directory\ndocker compose up docs\n
The site is served from http://localhost
at a port dynamically assigned by Docker. See Docker dynamic ports for more information.
The website is automatically rebuilt as changes are made to docs/
files.
When running the Devcontainer, the docs site is automatically started.
See Docker dynamic ports for more information on accessing the site on localhost.
"},{"location":"getting-started/documentation/#deploying","title":"Deploying","text":"A GitHub Action watches for pushes to dev
, and uses mhausenblas/mkdocs-deploy-gh-pages
to build the mkdocs
content, force-pushing to the gh-pages
branch. At that point, GitHub Pages redeploys the docs site.
The locale
folder in this repository contain the django.po
message files for English and Spanish translation strings for the Benefits application.
Translation strings include all application copy, including:
The human-readable version of the English and Spanish translation strings for the application are delivered to Design and Engineering by Product, and live at this link: Cal-ITP Benefits Application Copy (Configurable Strings).
By tabs:
EN-USA
tab contains all copy for English, which each row representing a page. This copy uses a sample agency, called California State Transit (CST) with an Agency Card. This copy is used in Figma.forTranslation
and All Agencies
tab contains the English and Spanish translation side by side, with agency-specific copy.Subtitle
, ButtonLabel
)django.po
message files. Developer-specific instructions in the Django message files technical documentation.Feature and user interface tests are implemented with cypress
and can be found in the tests/cypress
directory in the repository.
See the cypress
Command Line guide for more information.
These are instructions for running cypress
locally on your machine, without the devcontainer. These steps will install cypress
and its dependencies on your machine. Make sure to run these commands in a Terminal.
node -v\nnpm -v\n
If not, install Node.js locally.
docker compose up -d client\n
cypress
directory:cd tests/cypress\n
cypress
. Verify cypress
installation succeeds:npm install\n
cypress
with test environment variables and configuration variables:CYPRESS_baseUrl=http://localhost:8000 npm run cypress:open\n
See tests/cypress/package.json
for more cypress scripts.
As of Cypress 12.5.1 with Firefox 109, there is a CSRF issue that prevents the tests from passing; unclear if this is a bug in Cypress or what. Use one of the other browser options.
"},{"location":"tests/#pytest","title":"Pytest","text":"The tests done at a request/unit level are run via pytest-django.
To run locally, start the Devcontainer and run:
tests/pytest/run.sh\n
The helper script:
pytest
coverage
coverage
report in HTML in the app\u2019s static/
directoryThe report can be viewed by launching the app and navigating to http://localhost:$DJANGO_LOCAL_PORT/static/coverage/index.html
The report files include a local .gitignore
file, so the entire directory is hidden from source control.
We also make the latest (from dev
) coverage report available online here: Coverage report
This section describes in more detail some of the use cases with current or planned support in the Benefits application.
"},{"location":"use-cases/#current-work","title":"Current work","text":"We do sprint planning and track day-to-day work on our Project Board.
See our Milestones for current work tracked against specific features and use cases.
"},{"location":"use-cases/#product-roadmap","title":"Product roadmap","text":"See our Product Roadmap for more information on planned feature development and prioritization.
Loading\u2026"},{"location":"use-cases/Veterans/","title":"Veterans enrollment pathway","text":""},{"location":"use-cases/Veterans/#overview","title":"Overview","text":"This use case describes a feature in the Cal-ITP Benefits app that allows US veterans who use public transit to verify their veteran status and receive reduced fares when paying by contactless debit or credit card at participating transit providers in California.
Actor:\u00a0A US veteran who uses public transit in California. For benefit eligibility, a veteran is defined as \u201ca person who served in the active military, naval, or air service, and was discharged or released therefrom under conditions other than dishonorable.\u201d (source)
Goal:\u00a0To verify a transit rider\u2019s veteran status and enable the rider to receive reduced fares when paying by contactless debit or credit card.
Precondition:\u00a0The California transit provider delivering fixed route service has installed and tested validator hardware necessary to collect fares using contactless payment on bus or rail lines, and the provider has a policy to offer a transit discount for US veterans.
"},{"location":"use-cases/Veterans/#basic-flow","title":"Basic flow","text":"sequenceDiagram\n%% Veteran Enrollment Pathway\n actor Transit Rider\n participant Benefits as Benefits app\n participant IdG as Identity Gateway\n participant Login.gov\n participant VA.gov\n participant Littlepay\nTransit Rider->>Benefits: visits benefits.calitp.org\n activate Benefits\nBenefits-->>IdG: eligibility verification\n activate IdG\nTransit Rider->>Login.gov: account authentication\n activate Login.gov\nIdG-->>Login.gov: requests required PII\n activate Login.gov\n Note right of Login.gov: transit rider first name<br>transit rider last name<br>home address<br>date of birth\nLogin.gov-->>IdG: returns required PII\n deactivate Login.gov\nIdG-->>VA.gov: check veteran status\n activate VA.gov\nVA.gov-->>IdG: return veteran status\n deactivate VA.gov\nIdG-->>Benefits: eligibility response\n deactivate IdG\n deactivate Login.gov\nBenefits-->>Littlepay: payment enrollment start\n activate Littlepay\nTransit Rider->>Littlepay: provides debit or credit card details\nLittlepay-->>Benefits: payment method enrollment confirmation\n deactivate Littlepay\n deactivate Benefits
The transit rider receives a fare reduction each time they use the debit or credit card they registered to pay for transit rides. The number of times they can use the card to pay for transit is unlimited and the benefit never expires.\u00a0
"},{"location":"use-cases/Veterans/#benefits","title":"Benefits","text":"A veteran in California uses public transit regularly. They don\u2019t have a car and depend on buses to get to appointments and do errands that take too long to use their bicycle. They receive a 50% fare reduction for being a US veteran but have to pay for transit rides using the closed loop card provided by the agency to receive the reduced fare. It\u2019s frustrating and inconvenient to reload this agency card in $10 payments every week, especially because they sometimes need the money tied up on the card to pay for groceries and medication.\u00a0
The transit provider serving their part of California implements contactless payments on fixed bus routes throughout the service area. This rider uses\u00a0benefits.calitp.org\u00a0to confirm their veteran status and register their debit card for reduced fares. They tap to pay when boarding buses in their area and are automatically charged the reduced fare. They no longer need to carry one card to pay for transit and another for other purchases. Best of all, they have complete access to all funds in their weekly budget. If food and medication costs are higher one week, they can allocate additional funds to those areas and ride transit less.
"},{"location":"use-cases/agency-cards/","title":"Agency Cards","text":"Agency Cards is a generic term for reduced fare programs offered by Transit Providers, such as the Courtesy Card program from Monterey-Salinas Transit (MST).
Agency cards are different from our other use cases in that eligibility verification happens on the agency side (offline) rather than through the Benefits app, and the Benefits app then checks for a valid Agency Card via an Eligibility API call.
"},{"location":"use-cases/agency-cards/#architecture","title":"Architecture","text":"In order to support an Agency Cards deployment, the Transit Provider produces a list of eligible users (CSV format) that is loaded into an instance of Eligibility Server running in the Transit Provider\u2019s cloud.
Cal-ITP makes the hashfields
tool to facilitate masking user data before it leaves Transit Provider on-premises systems.
The complete system architecture looks like:
flowchart LR\n rider((User's browser))\n api[Eligibility Server]\n data[Hashed Agency Card data]\n cardsystem[Data source]\n\n rider --> Benefits\n\n subgraph CDT Azure\n Benefits\n end\n\n Benefits --> api\n\n subgraph Transit Provider cloud\n api --> data\n end\n\n subgraph Transit Provider on-prem\n cardsystem --> hashfields\n end\n\n hashfields --> data
Notes:
Data Source
is Velocity, the system MST uses to manage and print Courtesy CardssequenceDiagram\n actor Rider\n participant Benefits as Benefits app\n participant elig_server as Eligibility Server\n participant cc_data as Hashed data\n participant Data Source\n participant Littlepay\n\n Data Source-->>cc_data: exports nightly\n cc_data-->>elig_server: gets loaded on Server start\n\n Rider->>Benefits: visits site\n Benefits-->>elig_server: passes entered Agency Card details\n elig_server-->>Benefits: confirms eligibility\n\n Benefits-->>Littlepay: enrollment start\n Rider->>Littlepay: enters payment card details\n Littlepay-->>Benefits: enrollment complete
"},{"location":"use-cases/college/","title":"College Discount","text":"We have another potential transit discount use case, which is for students/faculty/staff from the Monterey-Salinas Transit (MST) area. We will be taking the existing program where students from certain schools ride free, expanding it to faculty and staff in some cases, and allowing those riders to enroll their contactless bank (credit/debit) cards for half-price (50%) discounts during fall and winter breaks.
"},{"location":"use-cases/college/#prototype","title":"Prototype","text":"Here\u2019s a clickable prototype showing the planned flow, having users enroll via their college\u2019s single sign-on (SSO) system:
"},{"location":"use-cases/college/#process","title":"Process","text":"Here\u2019s what will happen behind the scenes in a success flow:
sequenceDiagram\n actor rider\n participant Benefits as Benefits app\n participant IdG as Identity Gateway\n participant SSO\n participant Littlepay\n\n rider->>Benefits: visits site\n Benefits-->>IdG: redirected to sign in\n IdG-->>SSO: redirected to sign in\n rider->>SSO: enters credentials\n SSO-->>IdG: user attributes\n IdG-->>Benefits: user attributes\n Benefits-->>Littlepay: enrollment start\n rider->>Littlepay: enters payment card details\n Littlepay-->>Benefits: enrollment complete
The plan is to determine whether the rider is eligible via SAML attributes and/or membership in a group on the college side.
"},{"location":"use-cases/seniors/","title":"Seniors","text":"One Benefits application use case is for riders age 65 years and older. The Benefits application verifies the person\u2019s age to confirm eligibility and allows those eligible to enroll their contactless payment card for their transit benefit.
Currently, the app uses Login.gov\u2019s Identity Assurance Level 2 (IAL2) to confirm age, which requires a person to have a Social Security number, a valid state-issued ID card and a phone number with a phone plan associated with the person\u2019s name. Adding ways to confirm eligibility for people without a Social Security number, for people who are part of a transit agency benefit program are on the roadmap.
"},{"location":"use-cases/seniors/#demonstration","title":"Demonstration","text":"Here\u2019s a GIF showing what the flow looks like, having seniors confirm eligibility via Login.gov and enroll via LittlePay:
"},{"location":"use-cases/seniors/#process","title":"Process","text":"sequenceDiagram\n actor Rider\n participant Benefits as Benefits app\n participant IdG as Identity Gateway\n participant Login.gov\n participant Littlepay\n\n Rider->>Benefits: visits site\n Benefits-->>IdG: identity proofing\n IdG-->>Login.gov: identity proofing\n Rider->>Login.gov: enters SSN and ID\n Login.gov-->>IdG: eligibility verification\n IdG-->>Benefits: eligibility verification\n Benefits-->>Littlepay: enrollment start\n Rider->>Littlepay: enters payment card details\n Littlepay-->>Benefits: enrollment complete
"}]}
\ No newline at end of file
diff --git a/sitemap.xml b/sitemap.xml
new file mode 100644
index 0000000000..0487a8cca9
--- /dev/null
+++ b/sitemap.xml
@@ -0,0 +1,158 @@
+
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ ++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The enrollment application: analytics implementation.
+3"""
+4from benefits.core import analytics as core
+ + +7class ReturnedEnrollmentEvent(core.Event):
+8 """Analytics event representing the end of payment processor enrollment request."""
+ +10 def __init__(self, request, status, error=None, payment_group=None):
+11 super().__init__(request, "returned enrollment")
+12 if str(status).lower() in ("error", "retry", "success"): 12 ↛ 14line 12 didn't jump to line 14, because the condition on line 12 was never false
+13 self.update_event_properties(status=status, error=error)
+14 if payment_group is not None: 14 ↛ 15line 14 didn't jump to line 15, because the condition on line 14 was never true
+15 self.update_event_properties(payment_group=payment_group)
+ + +18def returned_error(request, error):
+19 """Send the "returned enrollment" analytics event with an error status and message."""
+20 core.send_event(ReturnedEnrollmentEvent(request, status="error", error=error))
+ + +23def returned_retry(request):
+24 """Send the "returned enrollment" analytics event with a retry status."""
+25 core.send_event(ReturnedEnrollmentEvent(request, status="retry"))
+ + +28def returned_success(request, payment_group):
+29 """Send the "returned enrollment" analytics event with a success status."""
+30 core.send_event(ReturnedEnrollmentEvent(request, status="success", payment_group=payment_group))
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The enrollment application: Benefits Enrollment API implementation.
+3"""
+4import logging
+5from tempfile import NamedTemporaryFile
+6import time
+ +8from django.conf import settings
+9import requests
+ + +12logger = logging.getLogger(__name__)
+ + +15class ApiError(Exception):
+16 """Error calling the enrollment APIs."""
+ +18 pass
+ + +21class AccessTokenResponse:
+22 """Benefits Enrollment API Access Token response."""
+ +24 def __init__(self, response):
+25 logger.info("Read access token from response")
+ +27 try:
+28 payload = response.json()
+29 except ValueError:
+30 raise ApiError("Invalid response format")
+ +32 self.access_token = payload.get("access_token")
+33 self.token_type = payload.get("token_type")
+34 self.expires_in = payload.get("expires_in")
+35 if self.expires_in is not None:
+36 logger.debug("Access token has expiry")
+37 self.expiry = time.time() + self.expires_in
+38 else:
+39 logger.debug("Access token has no expiry")
+40 self.expiry = None
+ +42 logger.info("Access token successfully read from response")
+ + +45class CustomerResponse:
+46 """Benefits Enrollment Customer API response."""
+ +48 def __init__(self, response):
+49 logger.info("Read customer details from response")
+ +51 try:
+52 payload = response.json()
+53 self.id = payload["id"]
+54 except (KeyError, ValueError):
+55 raise ApiError("Invalid response format")
+ +57 if self.id is None:
+58 raise ApiError("Invalid response format")
+ +60 self.is_registered = str(payload.get("is_registered", "false")).lower() == "true"
+ +62 logger.info("Customer details successfully read from response")
+ + +65class GroupResponse:
+66 """Benefits Enrollment Customer Group API response."""
+ +68 def __init__(self, response, requested_id, payload=None):
+69 if payload is None:
+70 try:
+71 payload = response.json()
+72 except ValueError:
+73 raise ApiError("Invalid response format")
+74 else:
+75 try:
+76 # Group API uses an error response (500) to indicate that the customer already exists in the group (!!!)
+77 # The error message should contain the customer ID we sent via payload and start with "Duplicate"
+78 error = response.json()["errors"][0]
+79 customer_id = payload[0]
+80 detail = error["detail"]
+ +82 failure = (
+83 customer_id is None
+84 or detail is None
+85 or customer_id not in detail
+86 or customer_id in detail
+87 and not detail.startswith("Duplicate")
+88 )
+ +90 if failure:
+91 raise ApiError("Invalid response format")
+92 except (KeyError, ValueError):
+93 raise ApiError("Invalid response format")
+ +95 self.customer_ids = list(payload)
+96 self.updated_customer_id = self.customer_ids[0] if len(self.customer_ids) == 1 else None
+97 self.success = requested_id == self.updated_customer_id
+98 self.message = "Updated customer_id does not match enrolled customer_id" if not self.success else ""
+ + +101class Client:
+102 """Benefits Enrollment API client."""
+ +104 def __init__(self, agency):
+105 logger.debug("Initialize Benefits Enrollment API Client")
+ +107 if agency is None:
+108 raise ValueError("agency")
+109 if agency.payment_processor is None:
+110 raise ValueError("agency.payment_processor")
+ +112 self.agency = agency
+113 self.payment_processor = agency.payment_processor
+114 self.headers = {"Accept": "application/json", "Content-type": "application/json"}
+ +116 def _headers(self, headers=None):
+117 h = dict(self.headers)
+118 if headers:
+119 h.update(headers)
+120 return h
+ +122 def _make_url(self, *parts):
+123 return "/".join((self.payment_processor.api_base_url, self.agency.merchant_id, *parts))
+ +125 def _get(self, url, payload, headers=None):
+126 h = self._headers(headers)
+127 return self._cert_request(
+128 lambda verify, cert: requests.get(
+129 url,
+130 headers=h,
+131 params=payload,
+132 verify=verify,
+133 cert=cert,
+134 timeout=settings.REQUESTS_TIMEOUT,
+135 )
+136 )
+ +138 def _patch(self, url, payload, headers=None):
+139 h = self._headers(headers)
+140 return self._cert_request(
+141 lambda verify, cert: requests.patch(
+142 url,
+143 headers=h,
+144 json=payload,
+145 verify=verify,
+146 cert=cert,
+147 timeout=settings.REQUESTS_TIMEOUT,
+148 )
+149 )
+ +151 def _post(self, url, payload, headers=None):
+152 h = self._headers(headers)
+153 return self._cert_request(
+154 lambda verify, cert: requests.post(
+155 url,
+156 headers=h,
+157 json=payload,
+158 verify=verify,
+159 cert=cert,
+160 timeout=settings.REQUESTS_TIMEOUT,
+161 )
+162 )
+ +164 def _cert_request(self, request_func):
+165 """
+166 Creates named (on-disk) temp files for client cert auth.
+167 * request_func: curried callable from `requests` library (e.g. `requests.get`).
+168 """
+169 # requests library reads temp files from file path
+170 # The "with" context destroys temp files when response comes back
+171 with NamedTemporaryFile("w+") as cert, NamedTemporaryFile("w+") as key, NamedTemporaryFile("w+") as ca:
+172 # write client cert data to temp files
+173 # resetting so they can be read again by requests
+174 cert.write(self.payment_processor.client_cert.data)
+175 cert.seek(0)
+ +177 key.write(self.payment_processor.client_cert_private_key.data)
+178 key.seek(0)
+ +180 ca.write(self.payment_processor.client_cert_root_ca.data)
+181 ca.seek(0)
+ +183 # request using temp file paths
+184 return request_func(verify=ca.name, cert=(cert.name, key.name))
+ +186 def _get_customer(self, token):
+187 """Get a customer record from Payment Processor's system"""
+188 logger.info("Check for existing customer record")
+ +190 if token is None:
+191 raise ValueError("token")
+ +193 url = self._make_url(self.payment_processor.customers_endpoint)
+194 payload = {"token": token}
+ +196 try:
+197 r = self._get(url, payload)
+198 r.raise_for_status()
+ +200 logger.debug("Customer record exists")
+201 customer = CustomerResponse(r)
+202 if customer.is_registered:
+203 logger.debug("Customer is registered, skip update")
+204 return customer
+205 else:
+206 logger.debug("Customer is not registered, update")
+207 return self._update_customer(customer.id)
+ +209 except requests.ConnectionError:
+210 raise ApiError("Connection to enrollment server failed")
+211 except requests.Timeout:
+212 raise ApiError("Connection to enrollment server timed out")
+213 except requests.TooManyRedirects:
+214 raise ApiError("Too many redirects to enrollment server")
+215 except requests.HTTPError as e:
+216 raise ApiError(e)
+ +218 def _update_customer(self, customer_id):
+219 """Update a customer using their unique info."""
+220 logger.info("Update existing customer record")
+ +222 if customer_id is None:
+223 raise ValueError("customer_id")
+ +225 url = self._make_url(self.payment_processor.customer_endpoint, customer_id)
+226 payload = {"is_registered": True, "id": customer_id}
+ +228 r = self._patch(url, payload)
+229 r.raise_for_status()
+ +231 return CustomerResponse(r)
+ +233 def access_token(self):
+234 """Obtain an access token to use for integrating with other APIs."""
+235 logger.info("Get new access token")
+ +237 url = self._make_url(self.payment_processor.api_access_token_endpoint)
+238 payload = {self.payment_processor.api_access_token_request_key: self.payment_processor.api_access_token_request_val}
+ +240 try:
+241 r = self._post(url, payload)
+242 r.raise_for_status()
+243 except requests.ConnectionError:
+244 raise ApiError("Connection to enrollment server failed")
+245 except requests.Timeout:
+246 raise ApiError("Connection to enrollment server timed out")
+247 except requests.TooManyRedirects:
+248 raise ApiError("Too many redirects to enrollment server")
+249 except requests.HTTPError as e:
+250 raise ApiError(e)
+ +252 return AccessTokenResponse(r)
+ +254 def enroll(self, customer_token, group_id):
+255 """Enroll a customer in a product group using the token that represents that customer."""
+256 logger.info("Enroll customer in product group")
+ +258 if customer_token is None:
+259 raise ValueError("customer_token")
+260 if group_id is None:
+261 raise ValueError("group_id")
+ +263 customer = self._get_customer(customer_token)
+264 url = self._make_url(self.payment_processor.group_endpoint, group_id)
+265 payload = [customer.id]
+ +267 try:
+268 r = self._patch(url, payload)
+ +270 if r.status_code in (200, 201):
+271 logger.info("Customer enrolled in group")
+272 return GroupResponse(r, customer.id)
+273 elif r.status_code == 500:
+274 logger.info("Customer already exists in group")
+275 return GroupResponse(r, customer.id, payload=payload)
+276 else:
+277 r.raise_for_status()
+278 except requests.ConnectionError:
+279 raise ApiError("Connection to enrollment server failed")
+280 except requests.Timeout:
+281 raise ApiError("Connection to enrollment server timed out")
+282 except requests.TooManyRedirects:
+283 raise ApiError("Too many redirects to enrollment server")
+284 except requests.HTTPError as e:
+285 raise ApiError(e)
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The enrollment application: Allows user to enroll payment device for benefits.
+3"""
+4from django.apps import AppConfig
+ + +7class EnrollmentAppConfig(AppConfig):
+8 name = "benefits.enrollment"
+9 label = "enrollment"
+10 verbose_name = "Benefits Enrollment"
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The enrollment application: Form definitions for results from Hosted Card Verification Flow.
+3"""
+4from django import forms
+ + +7class CardTokenizeSuccessForm(forms.Form):
+8 """Form to bring client card token back to server."""
+ +10 action_url = "enrollment:index"
+11 id = "form-card-tokenize-success"
+12 method = "POST"
+ +14 # hidden input with no label
+15 card_token = forms.CharField(widget=forms.HiddenInput(), label="")
+ + +18class CardTokenizeFailForm(forms.Form):
+19 """Form to indicate card tokenization failure to server."""
+ +21 id = "form-card-tokenize-fail"
+22 method = "POST"
+ +24 def __init__(self, action_url, *args, **kwargs):
+25 # init super with an empty data dict
+26 # binds and makes immutable this form's data
+27 # since there are no form fields, the form is also marked as valid
+28 super().__init__({}, *args, **kwargs)
+29 self.action_url = action_url
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The enrollment application: URLConf for the benefits enrollment flow.
+3"""
+4from django.urls import path
+ +6from . import views
+ + +9app_name = "enrollment"
+10urlpatterns = [
+11 # /enrollment
+12 path("", views.index, name="index"),
+13 path("token", views.token, name="token"),
+14 path("retry", views.retry, name="retry"),
+15 path("success", views.success, name="success"),
+16]
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The enrollment application: view definitions for the benefits enrollment flow.
+3"""
+4import logging
+ +6from django.http import JsonResponse
+7from django.template.response import TemplateResponse
+8from django.urls import reverse
+9from django.utils.decorators import decorator_from_middleware
+ +11from benefits.core import models, session
+12from benefits.core.middleware import (
+13 EligibleSessionRequired,
+14 VerifierSessionRequired,
+15 pageview_decorator,
+16)
+17from benefits.core.views import ROUTE_LOGGED_OUT
+18from . import analytics, api, forms
+ + +21ROUTE_INDEX = "enrollment:index"
+22ROUTE_RETRY = "enrollment:retry"
+23ROUTE_SUCCESS = "enrollment:success"
+24ROUTE_TOKEN = "enrollment:token"
+ +26TEMPLATE_INDEX = "enrollment/index.html"
+27TEMPLATE_RETRY = "enrollment/retry.html"
+28TEMPLATE_SUCCESS = "enrollment/success.html"
+ + +31logger = logging.getLogger(__name__)
+ + +34@decorator_from_middleware(EligibleSessionRequired)
+35def token(request):
+36 """View handler for the enrollment auth token."""
+37 if not session.enrollment_token_valid(request):
+38 agency = session.agency(request)
+39 response = api.Client(agency).access_token()
+40 session.update(request, enrollment_token=response.access_token, enrollment_token_exp=response.expiry)
+ +42 data = {"token": session.enrollment_token(request)}
+ +44 return JsonResponse(data)
+ + +47@decorator_from_middleware(EligibleSessionRequired)
+48def index(request):
+49 """View handler for the enrollment landing page."""
+50 session.update(request, origin=reverse(ROUTE_INDEX))
+ +52 agency = session.agency(request)
+ +54 # POST back after payment processor form, process card token
+55 if request.method == "POST":
+56 form = forms.CardTokenizeSuccessForm(request.POST)
+57 if not form.is_valid():
+58 raise Exception("Invalid card token form")
+ +60 eligibility = session.eligibility(request)
+61 logger.debug(f"Session contains an {models.EligibilityType.__name__}")
+ +63 logger.debug("Read tokenized card")
+64 card_token = form.cleaned_data.get("card_token")
+ +66 response = api.Client(agency).enroll(card_token, eligibility.group_id)
+67 if response.success:
+68 analytics.returned_success(request, eligibility.group_id)
+69 return success(request)
+70 else:
+71 analytics.returned_error(request, response.message)
+72 raise Exception(response.message)
+ +74 # GET enrollment index
+75 else:
+76 tokenize_retry_form = forms.CardTokenizeFailForm(ROUTE_RETRY)
+77 tokenize_success_form = forms.CardTokenizeSuccessForm(auto_id=True, label_suffix="")
+ +79 context = {
+80 "forms": [tokenize_retry_form, tokenize_success_form],
+81 "cta_button": "tokenize_card",
+82 "card_tokenize_env": agency.payment_processor.card_tokenize_env,
+83 "card_tokenize_func": agency.payment_processor.card_tokenize_func,
+84 "card_tokenize_url": agency.payment_processor.card_tokenize_url,
+85 "token_field": "card_token",
+86 "form_retry": tokenize_retry_form.id,
+87 "form_success": tokenize_success_form.id,
+88 }
+ +90 logger.debug(f'card_tokenize_url: {context["card_tokenize_url"]}')
+ +92 return TemplateResponse(request, TEMPLATE_INDEX, context)
+ + +95@decorator_from_middleware(EligibleSessionRequired)
+96def retry(request):
+97 """View handler for a recoverable failure condition."""
+98 if request.method == "POST":
+99 analytics.returned_retry(request)
+100 form = forms.CardTokenizeFailForm(request.POST)
+101 if form.is_valid():
+102 return TemplateResponse(request, TEMPLATE_RETRY)
+103 else:
+104 analytics.returned_error(request, "Invalid retry submission.")
+105 raise Exception("Invalid retry submission.")
+106 else:
+107 analytics.returned_error(request, "This view method only supports POST.")
+108 raise Exception("This view method only supports POST.")
+ + +111@pageview_decorator
+112@decorator_from_middleware(VerifierSessionRequired)
+113def success(request):
+114 """View handler for the final success page."""
+115 request.path = "/enrollment/success"
+116 session.update(request, origin=reverse(ROUTE_SUCCESS))
+ +118 agency = session.agency(request)
+119 verifier = session.verifier(request)
+ +121 if session.logged_in(request) and verifier.auth_provider.supports_sign_out:
+122 # overwrite origin for a logged in user
+123 # if they click the logout button, they are taken to the new route
+124 session.update(request, origin=reverse(ROUTE_LOGGED_OUT))
+ +126 return TemplateResponse(request, agency.enrollment_success_template)
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ ++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The oauth application: analytics implementation.
+3"""
+4from benefits.core import analytics as core, session
+ + +7class OAuthEvent(core.Event):
+8 """Base OAuth analytics event."""
+ +10 def __init__(self, request, event_type):
+11 super().__init__(request, event_type)
+12 verifier = session.verifier(request)
+13 if verifier and verifier.uses_auth_verification:
+14 self.update_event_properties(auth_provider=verifier.auth_provider.client_name)
+ + +17class StartedSignInEvent(OAuthEvent):
+18 """Analytics event representing the beginning of the OAuth sign in flow."""
+ +20 def __init__(self, request):
+21 super().__init__(request, "started sign in")
+ + +24class CanceledSignInEvent(OAuthEvent):
+25 """Analytics event representing the canceling of application sign in."""
+ +27 def __init__(self, request):
+28 super().__init__(request, "canceled sign in")
+ + +31class FinishedSignInEvent(OAuthEvent):
+32 """Analytics event representing the end of the OAuth sign in flow."""
+ +34 def __init__(self, request):
+35 super().__init__(request, "finished sign in")
+ + +38class StartedSignOutEvent(OAuthEvent):
+39 """Analytics event representing the beginning of application sign out."""
+ +41 def __init__(self, request):
+42 super().__init__(request, "started sign out")
+ + +45class FinishedSignOutEvent(OAuthEvent):
+46 """Analytics event representing the end of application sign out."""
+ +48 def __init__(self, request):
+49 super().__init__(request, "finished sign out")
+50 self.update_event_properties(origin=session.origin(request))
+ + +53def started_sign_in(request):
+54 """Send the "started sign in" analytics event."""
+55 core.send_event(StartedSignInEvent(request))
+ + +58def canceled_sign_in(request):
+59 """Send the "canceled sign in" analytics event."""
+60 core.send_event(CanceledSignInEvent(request))
+ + +63def finished_sign_in(request):
+64 """Send the "finished sign in" analytics event."""
+65 core.send_event(FinishedSignInEvent(request))
+ + +68def started_sign_out(request):
+69 """Send the "started signed out" analytics event."""
+70 core.send_event(StartedSignOutEvent(request))
+ + +73def finished_sign_out(request):
+74 """Send the "finished sign out" analytics event."""
+75 core.send_event(FinishedSignOutEvent(request))
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The oauth application: Implements OAuth-based authentication
+3"""
+4from django.apps import AppConfig
+ + +7class OAuthAppConfig(AppConfig):
+8 name = "benefits.oauth"
+9 label = "oauth"
+10 verbose_name = "Benefits OAuth"
+ +12 def ready(self):
+13 # delay import until the ready() function is called, signaling that
+14 # Django has loaded all the apps and models
+15 from .client import oauth, register_providers
+ +17 # wrap registration in try/catch
+18 # even though we are in a ready() function, sometimes it's called early?
+19 try:
+20 register_providers(oauth)
+21 except Exception:
+22 pass
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The oauth application: helpers for working with OAuth clients.
+3"""
+ +5import logging
+ +7from authlib.integrations.django_client import OAuth
+ +9from benefits.core.models import AuthProvider
+ + +12logger = logging.getLogger(__name__)
+ +14oauth = OAuth()
+ + +17def _client_kwargs(scope=None):
+18 """
+19 Generate the OpenID Connect client_kwargs, with optional extra scope(s).
+ +21 `scope` should be a space-separated list of scopes to add.
+22 """
+23 scopes = ["openid", scope] if scope else ["openid"]
+24 return {"code_challenge_method": "S256", "scope": " ".join(scopes), "prompt": "login"}
+ + +27def _server_metadata_url(authority):
+28 """
+29 Generate the OpenID Connect server_metadata_url for an OAuth authority server.
+ +31 `authority` should be a fully qualified HTTPS domain name, e.g. https://example.com.
+32 """
+33 return f"{authority}/.well-known/openid-configuration"
+ + +36def _authorize_params(scheme):
+37 if scheme is not None:
+38 params = {"scheme": scheme}
+39 else:
+40 params = None
+ +42 return params
+ + +45def register_providers(oauth_registry):
+46 """
+47 Register OAuth clients into the given registry, using configuration from AuthProvider models.
+ +49 Adapted from https://stackoverflow.com/a/64174413.
+50 """
+51 logger.info("Registering OAuth clients")
+ +53 providers = AuthProvider.objects.all()
+ +55 for provider in providers:
+56 logger.debug(f"Registering OAuth client: {provider.client_name}")
+ +58 oauth_registry.register(
+59 provider.client_name,
+60 client_id=provider.client_id,
+61 server_metadata_url=_server_metadata_url(provider.authority),
+62 client_kwargs=_client_kwargs(provider.scope),
+63 authorize_params=_authorize_params(provider.scheme),
+64 )
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1import logging
+ +3from benefits.core import session
+4from benefits.core.middleware import VerifierSessionRequired, user_error
+ + +7logger = logging.getLogger(__name__)
+ + +10class VerifierUsesAuthVerificationSessionRequired(VerifierSessionRequired):
+11 """Middleware raises an exception for sessions lacking an eligibility verifier that uses auth verification."""
+ +13 def process_request(self, request):
+14 result = super().process_request(request)
+15 if result:
+16 # from the base middleware class, the session didn't have a verifier
+17 return result
+ +19 if session.verifier(request).uses_auth_verification:
+20 return None
+21 else:
+22 logger.debug("Session not configured with eligibility verifier that uses auth verification")
+23 return user_error(request)
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1from django.shortcuts import redirect
+2from django.utils.http import urlencode
+ + +5def deauthorize_redirect(oauth_client, token, redirect_uri):
+6 """Helper implements OIDC signout via the `end_session_endpoint`."""
+ +8 # Authlib has not yet implemented `end_session_endpoint` as the OIDC Session Management 1.0 spec is still in draft
+9 # See https://github.com/lepture/authlib/issues/331#issuecomment-827295954 for more
+10 #
+11 # The implementation here was adapted from the same ticket: https://github.com/lepture/authlib/issues/331#issue-838728145
+12 metadata = oauth_client.load_server_metadata()
+13 end_session_endpoint = metadata.get("end_session_endpoint")
+ +15 params = dict(id_token_hint=token, post_logout_redirect_uri=redirect_uri)
+16 encoded_params = urlencode(params)
+17 end_session_url = f"{end_session_endpoint}?{encoded_params}"
+ +19 return redirect(end_session_url)
+ + +22def generate_redirect_uri(request, redirect_path):
+23 redirect_uri = str(request.build_absolute_uri(redirect_path)).lower()
+ +25 # this is a temporary hack to ensure redirect URIs are HTTPS when the app is deployed
+26 # see https://github.com/cal-itp/benefits/issues/442 for more context
+27 # this follow-up is needed while we address the hosting architecture
+28 if not redirect_uri.startswith("http://localhost"):
+29 redirect_uri = redirect_uri.replace("http://", "https://")
+ +31 return redirect_uri
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1from django.urls import path
+ +3from . import views
+ + +6app_name = "oauth"
+7urlpatterns = [
+8 # /oauth
+9 path("login", views.login, name="login"),
+10 path("authorize", views.authorize, name="authorize"),
+11 path("cancel", views.cancel, name="cancel"),
+12 path("logout", views.logout, name="logout"),
+13 path("post_logout", views.post_logout, name="post_logout"),
+14]
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1import logging
+ +3from django.shortcuts import redirect
+4from django.urls import reverse
+5from django.utils.decorators import decorator_from_middleware
+ +7from benefits.core import session
+8from . import analytics, redirects
+9from .client import oauth
+10from .middleware import VerifierUsesAuthVerificationSessionRequired
+ + +13logger = logging.getLogger(__name__)
+ + +16ROUTE_AUTH = "oauth:authorize"
+17ROUTE_START = "eligibility:start"
+18ROUTE_CONFIRM = "eligibility:confirm"
+19ROUTE_UNVERIFIED = "eligibility:unverified"
+20ROUTE_POST_LOGOUT = "oauth:post_logout"
+ + +23@decorator_from_middleware(VerifierUsesAuthVerificationSessionRequired)
+24def login(request):
+25 """View implementing OIDC authorize_redirect."""
+26 verifier = session.verifier(request)
+27 oauth_client = oauth.create_client(verifier.auth_provider.client_name)
+ +29 if not oauth_client:
+30 raise Exception(f"oauth_client not registered: {verifier.auth_provider.client_name}")
+ +32 route = reverse(ROUTE_AUTH)
+33 redirect_uri = redirects.generate_redirect_uri(request, route)
+ +35 logger.debug(f"OAuth authorize_redirect with redirect_uri: {redirect_uri}")
+ +37 analytics.started_sign_in(request)
+ +39 return oauth_client.authorize_redirect(request, redirect_uri)
+ + +42@decorator_from_middleware(VerifierUsesAuthVerificationSessionRequired)
+43def authorize(request):
+44 """View implementing OIDC token authorization."""
+45 verifier = session.verifier(request)
+46 oauth_client = oauth.create_client(verifier.auth_provider.client_name)
+ +48 if not oauth_client:
+49 raise Exception(f"oauth_client not registered: {verifier.auth_provider.client_name}")
+ +51 logger.debug("Attempting to authorize OAuth access token")
+52 token = oauth_client.authorize_access_token(request)
+ +54 if token is None:
+55 logger.warning("Could not authorize OAuth access token")
+56 return redirect(ROUTE_START)
+ +58 logger.debug("OAuth access token authorized")
+ +60 # We store the id_token in the user's session. This is the minimal amount of information needed later to log the user out.
+61 id_token = token["id_token"]
+ +63 # We store the returned claim in case it can be used later in eligibility verification.
+64 verifier_claim = verifier.auth_provider.claim
+65 stored_claim = None
+ +67 if verifier_claim:
+68 userinfo = token.get("userinfo")
+ +70 if userinfo:
+71 claim_value = userinfo.get(verifier_claim)
+72 # the claim comes back in userinfo like { "claim": "True" | "False" }
+73 if claim_value is None:
+74 logger.warning(f"userinfo did not contain: {verifier_claim}")
+75 elif claim_value.lower() == "true":
+76 # if userinfo contains our claim and the flag is true, store the *claim*
+77 stored_claim = verifier_claim
+ +79 session.update(request, oauth_token=id_token, oauth_claim=stored_claim)
+ +81 analytics.finished_sign_in(request)
+ +83 return redirect(ROUTE_CONFIRM)
+ + +86@decorator_from_middleware(VerifierUsesAuthVerificationSessionRequired)
+87def cancel(request):
+88 """View implementing cancellation of OIDC authorization."""
+ +90 analytics.canceled_sign_in(request)
+ +92 return redirect(ROUTE_UNVERIFIED)
+ + +95@decorator_from_middleware(VerifierUsesAuthVerificationSessionRequired)
+96def logout(request):
+97 """View implementing OIDC and application sign out."""
+98 verifier = session.verifier(request)
+99 oauth_client = oauth.create_client(verifier.auth_provider.client_name)
+ +101 if not oauth_client:
+102 raise Exception(f"oauth_client not registered: {verifier.auth_provider.client_name}")
+ +104 analytics.started_sign_out(request)
+ +106 # overwrite the oauth session token, the user is signed out of the app
+107 token = session.oauth_token(request)
+108 session.logout(request)
+ +110 route = reverse(ROUTE_POST_LOGOUT)
+111 redirect_uri = redirects.generate_redirect_uri(request, route)
+ +113 logger.debug(f"OAuth end_session_endpoint with redirect_uri: {redirect_uri}")
+ +115 # send the user through the end_session_endpoint, redirecting back to
+116 # the post_logout route
+117 return redirects.deauthorize_redirect(oauth_client, token, redirect_uri)
+ + +120@decorator_from_middleware(VerifierUsesAuthVerificationSessionRequired)
+121def post_logout(request):
+122 """View routes the user to their origin after sign out."""
+ +124 analytics.finished_sign_out(request)
+ +126 origin = session.origin(request)
+127 return redirect(origin)
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1from importlib.metadata import version, PackageNotFoundError
+ +3try:
+4 __version__ = version("benefits")
+5except PackageNotFoundError:
+6 # package is not installed
+7 pass
+ + +10VERSION = __version__
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1import logging
+2import shutil
+3import os
+4import subprocess
+ +6import sentry_sdk
+7from sentry_sdk.integrations.django import DjangoIntegration
+8from sentry_sdk.scrubber import EventScrubber, DEFAULT_DENYLIST
+ +10from benefits import VERSION
+ +12logger = logging.getLogger(__name__)
+ +14SENTRY_ENVIRONMENT = os.environ.get("SENTRY_ENVIRONMENT", "local")
+15SENTRY_CSP_REPORT_URI = None
+ + +18def git_available():
+19 return bool(shutil.which("git"))
+ + +22# https://stackoverflow.com/a/24584384/358804
+23def is_git_directory(path="."):
+24 with open(os.devnull, "w") as dev_null:
+25 return subprocess.call(["git", "-C", path, "status"], stderr=dev_null, stdout=dev_null) == 0
+ + +28# https://stackoverflow.com/a/21901260/358804
+29def get_git_revision_hash():
+30 return subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("ascii").strip()
+ + +33def get_sha_file_path():
+34 current_file = os.path.dirname(os.path.abspath(__file__))
+35 return os.path.join(current_file, "..", "static", "sha.txt")
+ + +38def get_sha_from_file():
+39 sha_path = get_sha_file_path()
+40 if os.path.isfile(sha_path):
+41 with open(sha_path) as f:
+42 return f.read().strip()
+43 else:
+44 return None
+ + +47def get_release() -> str:
+48 """Returns the first available: the SHA from Git, the value from sha.txt, or the VERSION."""
+ +50 if git_available() and is_git_directory():
+51 return get_git_revision_hash()
+52 else:
+53 sha = get_sha_from_file()
+54 if sha:
+55 return sha
+56 else:
+57 # one of the above *should* always be available, but including this just in case
+58 return VERSION
+ + +61def get_denylist():
+62 # custom denylist
+63 denylist = DEFAULT_DENYLIST + ["sub", "name"]
+64 return denylist
+ + +67def get_traces_sample_rate():
+68 try:
+69 rate = float(os.environ.get("SENTRY_TRACES_SAMPLE_RATE", "0.0"))
+70 if rate < 0.0 or rate > 1.0:
+71 logger.warning("SENTRY_TRACES_SAMPLE_RATE was not in the range [0.0, 1.0], defaulting to 0.0")
+72 rate = 0.0
+73 else:
+74 logger.info(f"SENTRY_TRACES_SAMPLE_RATE set to: {rate}")
+75 except ValueError:
+76 logger.warning("SENTRY_TRACES_SAMPLE_RATE did not parse to float, defaulting to 0.0")
+77 rate = 0.0
+ +79 return rate
+ + +82def configure():
+83 SENTRY_DSN = os.environ.get("SENTRY_DSN")
+84 if SENTRY_DSN: 84 ↛ 85line 84 didn't jump to line 85, because the condition on line 84 was never true
+85 release = get_release()
+86 logger.info(f"Enabling Sentry for environment '{SENTRY_ENVIRONMENT}', release '{release}'...")
+ +88 # https://docs.sentry.io/platforms/python/configuration/
+89 sentry_sdk.init(
+90 dsn=SENTRY_DSN,
+91 integrations=[
+92 DjangoIntegration(),
+93 ],
+94 traces_sample_rate=get_traces_sample_rate(),
+95 environment=SENTRY_ENVIRONMENT,
+96 release=release,
+97 in_app_include=["benefits"],
+98 # send_default_pii must be False (the default) for a custom EventScrubber/denylist
+99 # https://docs.sentry.io/platforms/python/data-management/sensitive-data/#event_scrubber
+100 send_default_pii=False,
+101 event_scrubber=EventScrubber(denylist=get_denylist()),
+102 )
+ +104 # override the module-level variable when configuration happens, if set
+105 global SENTRY_CSP_REPORT_URI
+106 SENTRY_CSP_REPORT_URI = os.environ.get("SENTRY_REPORT_URI", "")
+107 else:
+108 logger.info("SENTRY_DSN not set, so won't send events")
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2Django settings for benefits project.
+3"""
+4import os
+ +6from benefits import sentry
+ + +9def _filter_empty(ls):
+10 return [s for s in ls if s]
+ + +13# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
+14BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ +16# SECURITY WARNING: keep the secret key used in production secret!
+17SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "secret")
+ +19# SECURITY WARNING: don't run with debug turned on in production!
+20DEBUG = os.environ.get("DJANGO_DEBUG", "False").lower() == "true"
+ +22ADMIN = os.environ.get("DJANGO_ADMIN", "False").lower() == "true"
+ +24ALLOWED_HOSTS = _filter_empty(os.environ.get("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1").split(","))
+ +26# Application definition
+ +28INSTALLED_APPS = [
+29 "django.contrib.messages",
+30 "django.contrib.sessions",
+31 "django.contrib.staticfiles",
+32 "benefits.core",
+33 "benefits.enrollment",
+34 "benefits.eligibility",
+35 "benefits.oauth",
+36]
+ +38if ADMIN: 38 ↛ 39line 38 didn't jump to line 39, because the condition on line 38 was never true
+39 INSTALLED_APPS.extend(
+40 [
+41 "django.contrib.admin",
+42 "django.contrib.auth",
+43 "django.contrib.contenttypes",
+44 ]
+45 )
+ +47MIDDLEWARE = [
+48 "django.middleware.security.SecurityMiddleware",
+49 "django.contrib.sessions.middleware.SessionMiddleware",
+50 "django.contrib.messages.middleware.MessageMiddleware",
+51 "django.middleware.locale.LocaleMiddleware",
+52 "benefits.core.middleware.Healthcheck",
+53 "benefits.core.middleware.HealthcheckUserAgents",
+54 "django.middleware.common.CommonMiddleware",
+55 "django.middleware.csrf.CsrfViewMiddleware",
+56 "django.middleware.clickjacking.XFrameOptionsMiddleware",
+57 "csp.middleware.CSPMiddleware",
+58 "benefits.core.middleware.ChangedLanguageEvent",
+59]
+ +61if ADMIN: 61 ↛ 62line 61 didn't jump to line 62, because the condition on line 61 was never true
+62 MIDDLEWARE.extend(
+63 [
+64 "django.contrib.auth.middleware.AuthenticationMiddleware",
+65 "django.contrib.messages.middleware.MessageMiddleware",
+66 ]
+67 )
+ +69if DEBUG: 69 ↛ 70line 69 didn't jump to line 70, because the condition on line 69 was never true
+70 MIDDLEWARE.append("benefits.core.middleware.DebugSession")
+ +72HEALTHCHECK_USER_AGENTS = _filter_empty(os.environ.get("HEALTHCHECK_USER_AGENTS", "").split(","))
+ +74CSRF_COOKIE_AGE = None
+75CSRF_COOKIE_SAMESITE = "Strict"
+76CSRF_COOKIE_HTTPONLY = True
+77CSRF_TRUSTED_ORIGINS = _filter_empty(os.environ.get("DJANGO_TRUSTED_ORIGINS", "http://localhost,http://127.0.0.1").split(","))
+ +79# With `Strict`, the user loses their Django session between leaving our app to
+80# sign in with OAuth, and coming back into our app from the OAuth redirect.
+81# This is because `Strict` disallows our cookie being sent from an external
+82# domain and so the session cookie is lost.
+83#
+84# `Lax` allows the cookie to travel with the user and be sent back to us by the
+85# OAuth server, as long as the request is "safe" i.e. GET
+86SESSION_COOKIE_SAMESITE = "Lax"
+87SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies"
+88SESSION_EXPIRE_AT_BROWSER_CLOSE = True
+89SESSION_COOKIE_NAME = "_benefitssessionid"
+ +91if not DEBUG: 91 ↛ 96line 91 didn't jump to line 96, because the condition on line 91 was never false
+92 CSRF_COOKIE_SECURE = True
+93 CSRF_FAILURE_VIEW = "benefits.core.views.csrf_failure"
+94 SESSION_COOKIE_SECURE = True
+ +96SECURE_BROWSER_XSS_FILTER = True
+ +98# required so that cross-origin pop-ups (like the enrollment overlay) have access to parent window context
+99# https://github.com/cal-itp/benefits/pull/793
+100SECURE_CROSS_ORIGIN_OPENER_POLICY = "same-origin-allow-popups"
+ +102# the NGINX reverse proxy sits in front of the application in deployed environments
+103# SSL terminates before getting to Django, and NGINX adds this header to indicate
+104# if the original request was secure or not
+105#
+106# See https://docs.djangoproject.com/en/4.0/ref/settings/#secure-proxy-ssl-header
+107if not DEBUG: 107 ↛ 110line 107 didn't jump to line 110, because the condition on line 107 was never false
+108 SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
+ +110ROOT_URLCONF = "benefits.urls"
+ +112template_ctx_processors = [
+113 "django.template.context_processors.request",
+114 "django.contrib.messages.context_processors.messages",
+115 "benefits.core.context_processors.agency",
+116 "benefits.core.context_processors.active_agencies",
+117 "benefits.core.context_processors.analytics",
+118 "benefits.core.context_processors.authentication",
+119 "benefits.core.context_processors.origin",
+120]
+ +122if DEBUG: 122 ↛ 123line 122 didn't jump to line 123, because the condition on line 122 was never true
+123 template_ctx_processors.extend(
+124 [
+125 "django.template.context_processors.debug",
+126 "benefits.core.context_processors.debug",
+127 ]
+128 )
+ +130if ADMIN: 130 ↛ 131line 130 didn't jump to line 131, because the condition on line 130 was never true
+131 template_ctx_processors.extend(
+132 [
+133 "django.contrib.auth.context_processors.auth",
+134 "django.contrib.messages.context_processors.messages",
+135 ]
+136 )
+ +138TEMPLATES = [
+139 {
+140 "BACKEND": "django.template.backends.django.DjangoTemplates",
+141 "DIRS": [os.path.join(BASE_DIR, "benefits", "templates")],
+142 "APP_DIRS": True,
+143 "OPTIONS": {
+144 "context_processors": template_ctx_processors,
+145 },
+146 },
+147]
+ +149WSGI_APPLICATION = "benefits.wsgi.application"
+ +151DATABASE_DIR = os.environ.get("DJANGO_DB_DIR", BASE_DIR)
+152DATABASES = {
+153 "default": {
+154 "ENGINE": "django.db.backends.sqlite3",
+155 "NAME": os.path.join(DATABASE_DIR, "django.db"),
+156 }
+157}
+ +159# Password validation
+ +161AUTH_PASSWORD_VALIDATORS = []
+ +163if ADMIN: 163 ↛ 164line 163 didn't jump to line 164, because the condition on line 163 was never true
+164 AUTH_PASSWORD_VALIDATORS.extend(
+165 [
+166 {
+167 "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
+168 },
+169 {
+170 "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
+171 },
+172 {
+173 "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
+174 },
+175 {
+176 "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
+177 },
+178 ]
+179 )
+ +181# Internationalization
+ +183LANGUAGE_CODE = "en"
+ +185LANGUAGE_COOKIE_HTTPONLY = True
+186LANGUAGE_COOKIE_SAMESITE = "Strict"
+187LANGUAGE_COOKIE_SECURE = True
+ +189LANGUAGES = [("en", "English"), ("es", "Español")]
+ +191LOCALE_PATHS = [os.path.join(BASE_DIR, "benefits", "locale")]
+ +193USE_I18N = True
+194USE_L10N = True
+ +196TIME_ZONE = "UTC"
+197USE_TZ = True
+ +199# Static files (CSS, JavaScript, Images)
+ +201STATIC_URL = "/static/"
+202STATICFILES_DIRS = [os.path.join(BASE_DIR, "benefits", "static")]
+203# use Manifest Static Files Storage by default
+204STORAGES = {
+205 "staticfiles": {
+206 "BACKEND": os.environ.get(
+207 "DJANGO_STATICFILES_STORAGE", "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"
+208 )
+209 }
+210}
+211STATIC_ROOT = os.path.join(BASE_DIR, "static")
+ +213# Logging configuration
+214LOG_LEVEL = os.environ.get("DJANGO_LOG_LEVEL", "DEBUG" if DEBUG else "WARNING")
+215LOGGING = {
+216 "version": 1,
+217 "disable_existing_loggers": False,
+218 "formatters": {
+219 "default": {
+220 "format": "[{asctime}] {levelname} {name}:{lineno} {message}",
+221 "datefmt": "%d/%b/%Y %H:%M:%S",
+222 "style": "{",
+223 },
+224 },
+225 "handlers": {
+226 "console": {
+227 "class": "logging.StreamHandler",
+228 "formatter": "default",
+229 },
+230 },
+231 "root": {
+232 "handlers": ["console"],
+233 "level": LOG_LEVEL,
+234 },
+235 "loggers": {
+236 "django": {
+237 "handlers": ["console"],
+238 "propagate": False,
+239 },
+240 },
+241}
+ +243sentry.configure()
+ +245# Analytics configuration
+ +247ANALYTICS_KEY = os.environ.get("ANALYTICS_KEY")
+ +249# reCAPTCHA configuration
+ +251RECAPTCHA_API_URL = os.environ.get("DJANGO_RECAPTCHA_API_URL", "https://www.google.com/recaptcha/api.js")
+252RECAPTCHA_SITE_KEY = os.environ.get("DJANGO_RECAPTCHA_SITE_KEY")
+253RECAPTCHA_API_KEY_URL = f"{RECAPTCHA_API_URL}?render={RECAPTCHA_SITE_KEY}"
+254RECAPTCHA_SECRET_KEY = os.environ.get("DJANGO_RECAPTCHA_SECRET_KEY")
+255RECAPTCHA_VERIFY_URL = os.environ.get("DJANGO_RECAPTCHA_VERIFY_URL", "https://www.google.com/recaptcha/api/siteverify")
+256RECAPTCHA_ENABLED = all((RECAPTCHA_API_URL, RECAPTCHA_SITE_KEY, RECAPTCHA_SECRET_KEY, RECAPTCHA_VERIFY_URL))
+ +258# Content Security Policy
+259# Configuration docs at https://django-csp.readthedocs.io/en/latest/configuration.html
+ +261# In particular, note that the inner single-quotes are required!
+262# https://django-csp.readthedocs.io/en/latest/configuration.html#policy-settings
+ +264CSP_BASE_URI = ["'none'"]
+ +266CSP_DEFAULT_SRC = ["'self'"]
+ +268CSP_CONNECT_SRC = ["'self'", "https://api.amplitude.com/"]
+269env_connect_src = _filter_empty(os.environ.get("DJANGO_CSP_CONNECT_SRC", "").split(","))
+270CSP_CONNECT_SRC.extend(env_connect_src)
+ +272CSP_FONT_SRC = ["'self'", "https://california.azureedge.net/", "https://fonts.gstatic.com/"]
+273env_font_src = _filter_empty(os.environ.get("DJANGO_CSP_FONT_SRC", "").split(","))
+274CSP_FONT_SRC.extend(env_font_src)
+ +276CSP_FRAME_ANCESTORS = ["'none'"]
+ +278CSP_FRAME_SRC = ["'none'"]
+279env_frame_src = _filter_empty(os.environ.get("DJANGO_CSP_FRAME_SRC", "").split(","))
+280if RECAPTCHA_ENABLED: 280 ↛ 281line 280 didn't jump to line 281, because the condition on line 280 was never true
+281 env_frame_src.append("https://www.google.com")
+282if len(env_frame_src) > 0: 282 ↛ 283line 282 didn't jump to line 283, because the condition on line 282 was never true
+283 CSP_FRAME_SRC = env_frame_src
+ +285CSP_IMG_SRC = ["'self'", "data:"]
+ +287# Configuring strict Content Security Policy
+288# https://django-csp.readthedocs.io/en/latest/nonce.html
+289CSP_INCLUDE_NONCE_IN = ["script-src"]
+ +291CSP_OBJECT_SRC = ["'none'"]
+ +293if sentry.SENTRY_CSP_REPORT_URI: 293 ↛ 294line 293 didn't jump to line 294, because the condition on line 293 was never true
+294 CSP_REPORT_URI = [sentry.SENTRY_CSP_REPORT_URI]
+ +296CSP_SCRIPT_SRC = [
+297 "https://cdn.amplitude.com/libs/",
+298 "https://cdn.jsdelivr.net/",
+299 "*.littlepay.com",
+300]
+301env_script_src = _filter_empty(os.environ.get("DJANGO_CSP_SCRIPT_SRC", "").split(","))
+302CSP_SCRIPT_SRC.extend(env_script_src)
+303if RECAPTCHA_ENABLED: 303 ↛ 304line 303 didn't jump to line 304, because the condition on line 303 was never true
+304 CSP_SCRIPT_SRC.extend(["https://www.google.com/recaptcha/", "https://www.gstatic.com/recaptcha/releases/"])
+ +306CSP_STYLE_SRC = [
+307 "'self'",
+308 "'unsafe-inline'",
+309 "https://california.azureedge.net/",
+310 "https://fonts.googleapis.com/css",
+311]
+312env_style_src = _filter_empty(os.environ.get("DJANGO_CSP_STYLE_SRC", "").split(","))
+313CSP_STYLE_SRC.extend(env_style_src)
+ +315# Configuration for requests
+316# https://requests.readthedocs.io/en/latest/user/advanced/#timeouts
+ +318try:
+319 REQUESTS_CONNECT_TIMEOUT = int(os.environ.get("REQUESTS_CONNECT_TIMEOUT"))
+320except Exception:
+321 REQUESTS_CONNECT_TIMEOUT = 3
+ +323try:
+324 REQUESTS_READ_TIMEOUT = int(os.environ.get("REQUESTS_READ_TIMEOUT"))
+325except Exception:
+326 REQUESTS_READ_TIMEOUT = 20
+ +328REQUESTS_TIMEOUT = (REQUESTS_CONNECT_TIMEOUT, REQUESTS_READ_TIMEOUT)
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2benefits URL Configuration
+ +4The `urlpatterns` list routes URLs to views. For more information please see:
+5 https://docs.djangoproject.com/en/4.0/topics/http/urls/
+6"""
+7import logging
+ +9from django.conf import settings
+10from django.urls import include, path
+ +12logger = logging.getLogger(__name__)
+ +14handler400 = "benefits.core.views.bad_request"
+15handler403 = "benefits.core.views.bad_request"
+16handler404 = "benefits.core.views.page_not_found"
+17handler500 = "benefits.core.views.server_error"
+ +19urlpatterns = [
+20 path("", include("benefits.core.urls")),
+21 path("eligibility/", include("benefits.eligibility.urls")),
+22 path("enrollment/", include("benefits.enrollment.urls")),
+23 path("i18n/", include("django.conf.urls.i18n")),
+24 path("oauth/", include("benefits.oauth.urls")),
+25]
+ +27if settings.DEBUG: 27 ↛ 31line 27 didn't jump to line 31, because the condition on line 27 was never true
+28 # based on
+29 # https://docs.sentry.io/platforms/python/guides/django/#verify
+ +31 def trigger_error(request):
+32 raise RuntimeError("Test error")
+ +34 urlpatterns.append(path("error/", trigger_error))
+ +36if settings.ADMIN: 36 ↛ 37line 36 didn't jump to line 37, because the condition on line 36 was never true
+37 from django.contrib import admin
+ +39 logger.debug("Register admin urls")
+40 urlpatterns.append(path("admin/", admin.site.urls))
+41else:
+42 logger.debug("Skip url registrations for admin")
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2WSGI config for benefits project.
+ +4It exposes the WSGI callable as a module-level variable named ``application``.
+ +6For more information on this file, see
+7https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/
+8"""
+9import os
+ +11from django.core.wsgi import get_wsgi_application
+ + +14os.environ.setdefault("DJANGO_SETTINGS_MODULE", "benefits.settings")
+ +16application = get_wsgi_application()
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ ++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The core application: Admin interface configuration.
+3"""
+4from django.conf import settings
+ + +7if settings.ADMIN:
+8 import logging
+9 from django.contrib import admin
+10 from . import models
+ +12 logger = logging.getLogger(__name__)
+ +14 for model in [
+15 models.EligibilityType,
+16 models.EligibilityVerifier,
+17 models.PaymentProcessor,
+18 models.PemData,
+19 models.TransitAgency,
+20 ]:
+21 logger.debug(f"Register {model.__name__}")
+22 admin.site.register(model)
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The core application: analytics implementation.
+3"""
+4import itertools
+5import json
+6import logging
+7import re
+8import time
+9import uuid
+ +11from django.conf import settings
+12import requests
+ +14from benefits import VERSION
+15from benefits.core.models import EligibilityType
+16from . import session
+ + +19logger = logging.getLogger(__name__)
+ + +22class Event:
+23 """Base analytics event of a given type, including attributes from request's session."""
+ +25 _counter = itertools.count()
+26 _domain_re = re.compile(r"^(?:https?:\/\/)?(?:[^@\n]+@)?(?:www\.)?([^:\/\n?]+)", re.IGNORECASE)
+ +28 def __init__(self, request, event_type, **kwargs):
+29 self.app_version = VERSION
+30 # device_id is generated based on the user_id, and both are set explicitly (per session)
+31 self.device_id = session.did(request)
+32 self.event_properties = {}
+33 self.event_type = str(event_type).lower()
+34 self.insert_id = str(uuid.uuid4())
+35 self.language = session.language(request)
+36 # Amplitude tracks sessions using the start time as the session_id
+37 self.session_id = session.start(request)
+38 self.time = int(time.time() * 1000)
+39 # Although Amplitude advises *against* setting user_id for anonymous users, here a value is set on anonymous
+40 # users anyway, as the users never sign-in and become de-anonymized to this app / Amplitude.
+41 self.user_id = session.uid(request)
+42 self.user_properties = {}
+43 self.__dict__.update(kwargs)
+ +45 agency = session.agency(request)
+46 agency_name = agency.long_name if agency else None
+47 verifier = session.verifier(request)
+48 verifier_name = verifier.name if verifier else None
+49 eligibility_types = session.eligibility(request)
+50 eligibility_types = EligibilityType.get_names(eligibility_types) if eligibility_types else None
+ +52 self.update_event_properties(
+53 path=request.path,
+54 transit_agency=agency_name,
+55 eligibility_types=eligibility_types,
+56 eligibility_verifier=verifier_name,
+57 )
+ +59 uagent = request.headers.get("user-agent")
+ +61 ref = request.headers.get("referer")
+62 match = Event._domain_re.match(ref) if ref else None
+63 refdom = match.group(1) if match else None
+ +65 self.update_user_properties(
+66 referrer=ref,
+67 referring_domain=refdom,
+68 user_agent=uagent,
+69 transit_agency=agency_name,
+70 eligibility_types=eligibility_types,
+71 eligibility_verifier=verifier_name,
+72 )
+ +74 # event is initialized, consume next counter
+75 self.event_id = next(Event._counter)
+ +77 def __str__(self):
+78 return json.dumps(self.__dict__)
+ +80 def update_event_properties(self, **kwargs):
+81 """Merge kwargs into the self.event_properties dict."""
+82 self.event_properties.update(kwargs)
+ +84 def update_user_properties(self, **kwargs):
+85 """Merge kwargs into the self.user_properties dict."""
+86 self.user_properties.update(kwargs)
+ + +89class ViewedPageEvent(Event):
+90 """Analytics event representing a single page view."""
+ +92 def __init__(self, request):
+93 super().__init__(request, "viewed page")
+ + +96class ChangedLanguageEvent(Event):
+97 """Analytics event representing a change in the app's language."""
+ +99 def __init__(self, request, new_lang):
+100 super().__init__(request, "changed language")
+101 self.update_event_properties(language=new_lang)
+ + +104class Client:
+105 """Analytics API client"""
+ +107 def __init__(self, api_key):
+108 self.api_key = api_key
+109 self.headers = {"Accept": "*/*", "Content-type": "application/json"}
+110 self.url = "https://api2.amplitude.com/2/httpapi"
+111 logger.debug(f"Initialize Client for {self.url}")
+ +113 def _payload(self, events):
+114 if not isinstance(events, list):
+115 events = [events]
+116 return {"api_key": self.api_key, "events": [e.__dict__ for e in events]}
+ +118 def send(self, event):
+119 """Send an analytics event."""
+120 if not isinstance(event, Event): 120 ↛ 121line 120 didn't jump to line 121, because the condition on line 120 was never true
+121 raise ValueError("event must be an Event instance")
+ +123 if not self.api_key: 123 ↛ 127line 123 didn't jump to line 127, because the condition on line 123 was never false
+124 logger.warning(f"api_key is not configured, cannot send event: {event}")
+125 return
+ +127 try:
+128 payload = self._payload(event)
+129 logger.debug(f"Sending event payload: {payload}")
+ +131 r = requests.post(
+132 self.url,
+133 headers=self.headers,
+134 json=payload,
+135 timeout=settings.REQUESTS_TIMEOUT,
+136 )
+137 if r.status_code == 200:
+138 logger.debug(f"Event sent successfully: {r.json()}")
+139 elif r.status_code == 400:
+140 logger.error(f"Event request was invalid: {r.json()}")
+141 elif r.status_code == 413:
+142 logger.error(f"Event payload was too large: {r.json()}")
+143 elif r.status_code == 429:
+144 logger.error(f"Event contained too many requests for some users: {r.json()}")
+145 else:
+146 logger.error(f"Failed to send event: {r.json()}")
+ +148 except Exception:
+149 logger.error(f"Failed to send event: {event}")
+ + +152client = Client(settings.ANALYTICS_KEY)
+ + +155def send_event(event):
+156 """Send an analytics event."""
+157 if isinstance(event, Event): 157 ↛ 160line 157 didn't jump to line 160, because the condition on line 157 was never false
+158 client.send(event)
+159 else:
+160 raise ValueError("event must be an Event instance")
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The core application: Houses base templates and reusable models and components.
+3"""
+4from django.apps import AppConfig
+ + +7class CoreAppConfig(AppConfig):
+8 name = "benefits.core"
+9 label = "core"
+10 verbose_name = "Core"
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The core application: context processors for enriching request context data.
+3"""
+4from django.conf import settings
+ +6from . import models, session
+ + +9def _agency_context(agency):
+10 return {
+11 "eligibility_index_url": agency.eligibility_index_url,
+12 "help_template": agency.help_template,
+13 "info_url": agency.info_url,
+14 "long_name": agency.long_name,
+15 "phone": agency.phone,
+16 "short_name": agency.short_name,
+17 "slug": agency.slug,
+18 }
+ + +21def agency(request):
+22 """Context processor adds some information about the active agency to the request context."""
+23 agency = session.agency(request)
+ +25 if agency is None:
+26 return {}
+ +28 return {"agency": _agency_context(agency)}
+ + +31def active_agencies(request):
+32 """Context processor adds some information about all active agencies to the request context."""
+33 agencies = models.TransitAgency.all_active()
+ +35 return {"active_agencies": [_agency_context(agency) for agency in agencies]}
+ + +38def analytics(request):
+39 """Context processor adds some analytics information to request context."""
+40 return {"analytics": {"api_key": settings.ANALYTICS_KEY, "uid": session.uid(request), "did": session.did(request)}}
+ + +43def authentication(request):
+44 """Context processor adds authentication information to request context."""
+45 verifier = session.verifier(request)
+ +47 if verifier:
+48 data = {
+49 "logged_in": session.logged_in(request),
+50 }
+ +52 if verifier.is_auth_required:
+53 data["sign_out_button_template"] = verifier.auth_provider.sign_out_button_template
+54 data["sign_out_link_template"] = verifier.auth_provider.sign_out_link_template
+ +56 return {"authentication": data}
+57 else:
+58 return {}
+ + +61def debug(request):
+62 """Context processor adds debug information to request context."""
+63 return {"debug": session.context_dict(request)}
+ + +66def origin(request):
+67 """Context processor adds session.origin to request context."""
+68 origin = session.origin(request)
+ +70 if origin: 70 ↛ 73line 70 didn't jump to line 73, because the condition on line 70 was never false
+71 return {"origin": origin}
+72 else:
+73 return {}
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The core application: middleware definitions for request/response cycle.
+3"""
+4import logging
+ +6from django.conf import settings
+7from django.http import HttpResponse
+8from django.shortcuts import redirect
+9from django.template.response import TemplateResponse
+10from django.urls import reverse
+11from django.utils.decorators import decorator_from_middleware
+12from django.utils.deprecation import MiddlewareMixin
+13from django.views import i18n
+ +15from . import analytics, recaptcha, session
+ + +18logger = logging.getLogger(__name__)
+ +20HEALTHCHECK_PATH = "/healthcheck"
+21ROUTE_INDEX = "core:index"
+22TEMPLATE_USER_ERROR = "200-user-error.html"
+ + +25def user_error(request):
+26 return TemplateResponse(request, TEMPLATE_USER_ERROR)
+ + +29class AgencySessionRequired(MiddlewareMixin):
+30 """Middleware raises an exception for sessions lacking an agency configuration."""
+ +32 def process_request(self, request):
+33 if session.active_agency(request): 33 ↛ 37line 33 didn't jump to line 37, because the condition on line 33 was never false
+34 logger.debug("Session configured with agency")
+35 return None
+36 else:
+37 logger.debug("Session not configured with agency")
+38 return user_error(request)
+ + +41class EligibleSessionRequired(MiddlewareMixin):
+42 """Middleware raises an exception for sessions lacking confirmed eligibility."""
+ +44 def process_request(self, request):
+45 if session.eligible(request):
+46 logger.debug("Session has confirmed eligibility")
+47 return None
+48 else:
+49 logger.debug("Session has no confirmed eligibility")
+50 return user_error(request)
+ + +53class DebugSession(MiddlewareMixin):
+54 """Middleware to configure debug context in the request session."""
+ +56 def process_request(self, request):
+57 session.update(request, debug=settings.DEBUG)
+58 return None
+ + +61class Healthcheck:
+62 """Middleware intercepts and accepts /healthcheck requests."""
+ +64 def __init__(self, get_response):
+65 self.get_response = get_response
+ +67 def __call__(self, request):
+68 if request.path == HEALTHCHECK_PATH:
+69 return HttpResponse("Healthy", content_type="text/plain")
+70 return self.get_response(request)
+ + +73class HealthcheckUserAgents(MiddlewareMixin):
+74 """Middleware to return healthcheck for user agents specified in HEALTHCHECK_USER_AGENTS."""
+ +76 def process_request(self, request):
+77 if hasattr(request, "META"): 77 ↛ 82line 77 didn't jump to line 82, because the condition on line 77 was never false
+78 user_agent = request.META.get("HTTP_USER_AGENT", "")
+79 if user_agent in settings.HEALTHCHECK_USER_AGENTS:
+80 return HttpResponse("Healthy", content_type="text/plain")
+ +82 return self.get_response(request)
+ + +85class VerifierSessionRequired(MiddlewareMixin):
+86 """Middleware raises an exception for sessions lacking an eligibility verifier configuration."""
+ +88 def process_request(self, request):
+89 if session.verifier(request):
+90 logger.debug("Session configured with eligibility verifier")
+91 return None
+92 else:
+93 logger.debug("Session not configured with eligibility verifier")
+94 return user_error(request)
+ + +97class ViewedPageEvent(MiddlewareMixin):
+98 """Middleware sends an analytics event for page views."""
+ +100 def process_response(self, request, response):
+101 event = analytics.ViewedPageEvent(request)
+102 try:
+103 analytics.send_event(event)
+104 except Exception:
+105 logger.warning(f"Failed to send event: {event}")
+106 finally:
+107 return response
+ + +110pageview_decorator = decorator_from_middleware(ViewedPageEvent)
+ + +113class ChangedLanguageEvent(MiddlewareMixin):
+114 """Middleware hooks into django.views.i18n.set_language to send an analytics event."""
+ +116 def process_view(self, request, view_func, view_args, view_kwargs):
+117 if view_func == i18n.set_language:
+118 new_lang = request.POST.get("language")
+119 if new_lang:
+120 event = analytics.ChangedLanguageEvent(request, new_lang)
+121 analytics.send_event(event)
+122 else:
+123 logger.warning("i18n.set_language POST without language")
+124 return None
+ + +127class LoginRequired(MiddlewareMixin):
+128 """Middleware that checks whether a user is logged in."""
+ +130 def process_view(self, request, view_func, view_args, view_kwargs):
+131 # only require login if verifier requires it
+132 verifier = session.verifier(request)
+133 if not verifier or not verifier.is_auth_required or session.logged_in(request):
+134 # pass through
+135 return None
+ +137 return redirect("oauth:login")
+ + +140class RecaptchaEnabled(MiddlewareMixin):
+141 """Middleware configures the request with required reCAPTCHA settings."""
+ +143 def process_request(self, request):
+144 if settings.RECAPTCHA_ENABLED: 144 ↛ 145line 144 didn't jump to line 145
+145 request.recaptcha = {
+146 "data_field": recaptcha.DATA_FIELD,
+147 "script_api": settings.RECAPTCHA_API_KEY_URL,
+148 "site_key": settings.RECAPTCHA_SITE_KEY,
+149 }
+150 return None
+ + +153class IndexOrAgencyIndexOrigin(MiddlewareMixin):
+154 """Middleware sets the session.origin to either the core:index or core:agency_index depending on agency config."""
+ +156 def process_request(self, request):
+157 if session.active_agency(request):
+158 session.update(request, origin=session.agency(request).index_url)
+159 else:
+160 session.update(request, origin=reverse(ROUTE_INDEX))
+161 return None
+ + +164index_or_agencyindex_origin_decorator = decorator_from_middleware(IndexOrAgencyIndexOrigin)
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The core application: Common model definitions.
+3"""
+4import importlib
+5import logging
+ +7from django.conf import settings
+8from django.db import models
+9from django.urls import reverse
+ +11import requests
+ + +14logger = logging.getLogger(__name__)
+ + +17class PemData(models.Model):
+18 """API Certificate or Key in PEM format."""
+ +20 id = models.AutoField(primary_key=True)
+21 # Human description of the PEM data
+22 label = models.TextField()
+23 # The data in utf-8 encoded PEM text format
+24 text = models.TextField(null=True)
+25 # Public URL hosting the utf-8 encoded PEM text
+26 remote_url = models.TextField(null=True)
+ +28 def __str__(self):
+29 return self.label
+ +31 @property
+32 def data(self):
+33 if self.text:
+34 return self.text
+35 elif self.remote_url: 35 ↛ 38line 35 didn't jump to line 38, because the condition on line 35 was never false
+36 self.text = requests.get(self.remote_url, timeout=settings.REQUESTS_TIMEOUT).text
+ +38 self.save()
+39 return self.text
+ + +42class AuthProvider(models.Model):
+43 """An entity that provides authentication for eligibility verifiers."""
+ +45 id = models.AutoField(primary_key=True)
+46 sign_out_button_template = models.TextField(null=True)
+47 sign_out_link_template = models.TextField(null=True)
+48 client_name = models.TextField()
+49 client_id = models.TextField()
+50 authority = models.TextField()
+51 scope = models.TextField(null=True)
+52 claim = models.TextField(null=True)
+53 scheme = models.TextField()
+ +55 @property
+56 def supports_claims_verification(self):
+57 return bool(self.scope) and bool(self.claim)
+ +59 @property
+60 def supports_sign_out(self):
+61 return bool(self.sign_out_button_template) or bool(self.sign_out_link_template)
+ + +64class EligibilityType(models.Model):
+65 """A single conditional eligibility type."""
+ +67 id = models.AutoField(primary_key=True)
+68 name = models.TextField()
+69 label = models.TextField()
+70 group_id = models.TextField()
+ +72 def __str__(self):
+73 return self.label
+ +75 @staticmethod
+76 def get(id):
+77 """Get an EligibilityType instance by its id."""
+78 logger.debug(f"Get {EligibilityType.__name__} by id: {id}")
+79 return EligibilityType.objects.get(pk=id)
+ +81 @staticmethod
+82 def get_many(ids):
+83 """Get a list of EligibilityType instances from a list of ids."""
+84 logger.debug(f"Get {EligibilityType.__name__} list by ids: {ids}")
+85 return EligibilityType.objects.filter(id__in=ids)
+ +87 @staticmethod
+88 def get_names(eligibility_types):
+89 """Convert a list of EligibilityType to a list of their names"""
+90 if isinstance(eligibility_types, EligibilityType):
+91 eligibility_types = [eligibility_types]
+92 return [t.name for t in eligibility_types if isinstance(t, EligibilityType)]
+ + +95class EligibilityVerifier(models.Model):
+96 """An entity that verifies eligibility."""
+ +98 id = models.AutoField(primary_key=True)
+99 name = models.TextField()
+100 active = models.BooleanField(default=False)
+101 api_url = models.TextField(null=True)
+102 api_auth_header = models.TextField(null=True)
+103 api_auth_key = models.TextField(null=True)
+104 eligibility_type = models.ForeignKey(EligibilityType, on_delete=models.PROTECT)
+105 # public key is used to encrypt requests targeted at this Verifier and to verify signed responses from this verifier
+106 public_key = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT, null=True)
+107 # The JWE-compatible Content Encryption Key (CEK) key-length and mode
+108 jwe_cek_enc = models.TextField(null=True)
+109 # The JWE-compatible encryption algorithm
+110 jwe_encryption_alg = models.TextField(null=True)
+111 # The JWS-compatible signing algorithm
+112 jws_signing_alg = models.TextField(null=True)
+113 auth_provider = models.ForeignKey(AuthProvider, on_delete=models.PROTECT, null=True)
+114 selection_label_template = models.TextField()
+115 start_template = models.TextField(null=True)
+116 # reference to a form class used by this Verifier, e.g. benefits.app.forms.FormClass
+117 form_class = models.TextField(null=True)
+ +119 def __str__(self):
+120 return self.name
+ +122 @property
+123 def public_key_data(self):
+124 """This Verifier's public key as a string."""
+125 return self.public_key.data
+ +127 @property
+128 def is_auth_required(self):
+129 """True if this Verifier requires authentication. False otherwise."""
+130 return self.auth_provider is not None
+ +132 @property
+133 def uses_auth_verification(self):
+134 """True if this Verifier verifies via the auth provider. False otherwise."""
+135 return self.is_auth_required and self.auth_provider.supports_claims_verification
+ +137 def form_instance(self, *args, **kwargs):
+138 """Return an instance of this verifier's form, or None."""
+139 if not bool(self.form_class):
+140 return None
+ +142 # inspired by https://stackoverflow.com/a/30941292
+143 module_name, class_name = self.form_class.rsplit(".", 1)
+144 FormClass = getattr(importlib.import_module(module_name), class_name)
+ +146 return FormClass(*args, **kwargs)
+ +148 @staticmethod
+149 def by_id(id):
+150 """Get an EligibilityVerifier instance by its ID."""
+151 logger.debug(f"Get {EligibilityVerifier.__name__} by id: {id}")
+152 return EligibilityVerifier.objects.get(id=id)
+ + +155class PaymentProcessor(models.Model):
+156 """An entity that processes payments for transit agencies."""
+ +158 id = models.AutoField(primary_key=True)
+159 name = models.TextField()
+160 api_base_url = models.TextField()
+161 api_access_token_endpoint = models.TextField()
+162 api_access_token_request_key = models.TextField()
+163 api_access_token_request_val = models.TextField()
+164 card_tokenize_url = models.TextField()
+165 card_tokenize_func = models.TextField()
+166 card_tokenize_env = models.TextField()
+167 # The certificate used for client certificate authentication to the API
+168 client_cert = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT)
+169 # The private key, used to sign the certificate
+170 client_cert_private_key = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT)
+171 # The root CA bundle, used to verify the server.
+172 client_cert_root_ca = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT)
+173 customer_endpoint = models.TextField()
+174 customers_endpoint = models.TextField()
+175 group_endpoint = models.TextField()
+ +177 def __str__(self):
+178 return self.name
+ + +181class TransitAgency(models.Model):
+182 """An agency offering transit service."""
+ +184 id = models.AutoField(primary_key=True)
+185 slug = models.TextField()
+186 short_name = models.TextField()
+187 long_name = models.TextField()
+188 agency_id = models.TextField()
+189 merchant_id = models.TextField()
+190 info_url = models.URLField()
+191 phone = models.TextField()
+192 active = models.BooleanField(default=False)
+193 eligibility_types = models.ManyToManyField(EligibilityType)
+194 eligibility_verifiers = models.ManyToManyField(EligibilityVerifier)
+195 payment_processor = models.ForeignKey(PaymentProcessor, on_delete=models.PROTECT)
+196 # The Agency's private key, used to sign tokens created on behalf of this Agency
+197 private_key = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT)
+198 # The public key corresponding to the Agency's private key, used by Eligibility Verification servers to encrypt responses
+199 public_key = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT)
+200 # The JWS-compatible signing algorithm
+201 jws_signing_alg = models.TextField()
+202 index_template = models.TextField()
+203 eligibility_index_template = models.TextField()
+204 enrollment_success_template = models.TextField()
+205 help_template = models.TextField(null=True)
+ +207 def __str__(self):
+208 return self.long_name
+ +210 def get_type_id(self, name):
+211 """Get the id of the EligibilityType identified by the given name for this agency."""
+212 eligibility = self.eligibility_types.all().filter(name=name)
+213 if eligibility.count() == 1:
+214 return eligibility[0].id
+215 else:
+216 raise Exception("name does not correspond to a single eligibility type for agency")
+ +218 def supports_type(self, eligibility_type):
+219 """True if the eligibility_type is one of this agency's types. False otherwise."""
+220 return isinstance(eligibility_type, EligibilityType) and eligibility_type in self.eligibility_types.all()
+ +222 def types_to_verify(self, eligibility_verifier):
+223 """List of eligibility types to verify for this agency."""
+224 # compute set intersection of agency and verifier type ids
+225 agency_types = set(self.eligibility_types.values_list("id", flat=True))
+226 verifier_types = {eligibility_verifier.eligibility_type.id}
+227 supported_types = list(agency_types & verifier_types)
+228 return EligibilityType.get_many(supported_types)
+ +230 def type_names_to_verify(self, verifier):
+231 """List of names of the eligibility types to check for this agency."""
+232 return EligibilityType.get_names(self.types_to_verify(verifier))
+ +234 @property
+235 def index_url(self):
+236 """Public-facing URL to the TransitAgency's landing page."""
+237 return reverse("core:agency_index", args=[self.slug])
+ +239 @property
+240 def eligibility_index_url(self):
+241 """Public facing URL to the TransitAgency's eligibility page."""
+242 return reverse("eligibility:agency_index", args=[self.slug])
+ +244 @property
+245 def public_key_url(self):
+246 """Public-facing URL to the TransitAgency's public key."""
+247 return reverse("core:agency_public_key", args=[self.slug])
+ +249 @property
+250 def private_key_data(self):
+251 """This Agency's private key as a string."""
+252 return self.private_key.data
+ +254 @property
+255 def public_key_data(self):
+256 """This Agency's public key as a string."""
+257 return self.public_key.data
+ +259 @staticmethod
+260 def by_id(id):
+261 """Get a TransitAgency instance by its ID."""
+262 logger.debug(f"Get {TransitAgency.__name__} by id: {id}")
+263 return TransitAgency.objects.get(id=id)
+ +265 @staticmethod
+266 def by_slug(slug):
+267 """Get a TransitAgency instance by its slug."""
+268 logger.debug(f"Get {TransitAgency.__name__} by slug: {slug}")
+269 return TransitAgency.objects.filter(slug=slug).first()
+ +271 @staticmethod
+272 def all_active():
+273 """Get all TransitAgency instances marked active."""
+274 logger.debug(f"Get all active {TransitAgency.__name__}")
+275 return TransitAgency.objects.filter(active=True)
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The core application: helpers to work with reCAPTCHA.
+3"""
+4import requests
+ +6from django.conf import settings
+ + +9DATA_FIELD = "g-recaptcha-response"
+ + +12def has_error(form) -> bool:
+13 """True if the given form has a reCAPTCHA error. False otherwise."""
+14 return any([s for (_, v) in form.errors.items() for s in v if "reCAPTCHA" in s])
+ + +17def verify(form_data: dict) -> bool:
+18 """
+19 Check with Google reCAPTCHA if the given response is a valid user.
+20 See https://developers.google.com/recaptcha/docs/verify
+21 """
+22 if not settings.RECAPTCHA_ENABLED: 22 ↛ 25line 22 didn't jump to line 25, because the condition on line 22 was never false
+23 return True
+ +25 if not form_data or DATA_FIELD not in form_data:
+26 return False
+ +28 payload = dict(secret=settings.RECAPTCHA_SECRET_KEY, response=form_data[DATA_FIELD])
+29 response = requests.post(settings.RECAPTCHA_VERIFY_URL, payload, timeout=settings.REQUESTS_TIMEOUT).json()
+ +31 return bool(response["success"])
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The core application: helpers to work with request sessions.
+3"""
+4import hashlib
+5import logging
+6import time
+7import uuid
+ +9from django.urls import reverse
+ +11from . import models
+ + +14logger = logging.getLogger(__name__)
+ + +17_AGENCY = "agency"
+18_DEBUG = "debug"
+19_DID = "did"
+20_ELIGIBILITY = "eligibility"
+21_ENROLLMENT_TOKEN = "enrollment_token"
+22_ENROLLMENT_TOKEN_EXP = "enrollment_token_exp"
+23_LANG = "lang"
+24_OAUTH_CLAIM = "oauth_claim"
+25_OAUTH_TOKEN = "oauth_token"
+26_ORIGIN = "origin"
+27_START = "start"
+28_UID = "uid"
+29_VERIFIER = "verifier"
+ + +32def agency(request):
+33 """Get the agency from the request's session, or None"""
+34 logger.debug("Get session agency")
+35 try:
+36 return models.TransitAgency.by_id(request.session[_AGENCY])
+37 except (KeyError, models.TransitAgency.DoesNotExist):
+38 logger.debug("Can't get agency from session")
+39 return None
+ + +42def active_agency(request):
+43 """True if the request's session is configured with an active agency. False otherwise."""
+44 logger.debug("Get session active agency flag")
+45 a = agency(request)
+46 return a and a.active
+ + +49def context_dict(request):
+50 """The request's session context as a dict."""
+51 logger.debug("Get session context dict")
+52 return {
+53 _AGENCY: agency(request).slug if active_agency(request) else None,
+54 _DEBUG: debug(request),
+55 _DID: did(request),
+56 _ELIGIBILITY: eligibility(request),
+57 _ENROLLMENT_TOKEN: enrollment_token(request),
+58 _ENROLLMENT_TOKEN_EXP: enrollment_token_expiry(request),
+59 _LANG: language(request),
+60 _OAUTH_TOKEN: oauth_token(request),
+61 _OAUTH_CLAIM: oauth_claim(request),
+62 _ORIGIN: origin(request),
+63 _START: start(request),
+64 _UID: uid(request),
+65 _VERIFIER: verifier(request),
+66 }
+ + +69def debug(request):
+70 """Get the DEBUG flag from the request's session."""
+71 logger.debug("Get session debug flag")
+72 return bool(request.session.get(_DEBUG, False))
+ + +75def did(request):
+76 """
+77 Get the session's device ID, a hashed version of the unique ID. If unset,
+78 the session is reset to initialize a value.
+ +80 This value, like UID, is randomly generated per session and is needed for
+81 Amplitude to accurately track that a sequence of events came from a unique
+82 user.
+ +84 See more: https://help.amplitude.com/hc/en-us/articles/115003135607-Track-unique-users-in-Amplitude
+85 """
+86 logger.debug("Get session did")
+87 d = request.session.get(_DID)
+88 if not d:
+89 reset(request)
+90 d = request.session.get(_DID)
+91 return str(d)
+ + +94def eligibility(request):
+95 """Get the confirmed models.EligibilityType from the request's session, or None"""
+96 logger.debug("Get session confirmed eligibility")
+97 eligibility = request.session.get(_ELIGIBILITY)
+98 if eligibility:
+99 return models.EligibilityType.get(eligibility)
+100 else:
+101 return None
+ + +104def eligible(request):
+105 """True if the request's session is configured with an active agency and has confirmed eligibility. False otherwise."""
+106 logger.debug("Get session eligible flag")
+107 return active_agency(request) and agency(request).supports_type(eligibility(request))
+ + +110def enrollment_token(request):
+111 """Get the enrollment token from the request's session, or None."""
+112 logger.debug("Get session enrollment token")
+113 return request.session.get(_ENROLLMENT_TOKEN)
+ + +116def enrollment_token_expiry(request):
+117 """Get the enrollment token's expiry time from the request's session, or None."""
+118 logger.debug("Get session enrollment token expiry")
+119 return request.session.get(_ENROLLMENT_TOKEN_EXP)
+ + +122def enrollment_token_valid(request):
+123 """True if the request's session is configured with a valid token. False otherwise."""
+124 if bool(enrollment_token(request)):
+125 logger.debug("Session contains an enrollment token")
+126 exp = enrollment_token_expiry(request)
+ +128 # ensure token does not expire in the next 5 seconds
+129 valid = exp is None or exp > (time.time() + 5)
+ +131 logger.debug(f"Session enrollment token is {'valid' if valid else 'expired'}")
+132 return valid
+133 else:
+134 logger.debug("Session does not contain a valid enrollment token")
+135 return False
+ + +138def language(request):
+139 """Get the language configured for the request."""
+140 logger.debug("Get session language")
+141 return request.LANGUAGE_CODE
+ + +144def logged_in(request):
+145 """Check if the current session has an OAuth token."""
+146 return bool(oauth_token(request))
+ + +149def logout(request):
+150 """Reset the session claims and tokens."""
+151 update(request, oauth_claim=False, oauth_token=False, enrollment_token=False)
+ + +154def oauth_token(request):
+155 """Get the oauth token from the request's session, or None"""
+156 logger.debug("Get session oauth token")
+157 return request.session.get(_OAUTH_TOKEN)
+ + +160def oauth_claim(request):
+161 """Get the oauth claim from the request's session, or None"""
+162 logger.debug("Get session oauth claim")
+163 return request.session.get(_OAUTH_CLAIM)
+ + +166def origin(request):
+167 """Get the origin for the request's session, or the default core:index."""
+168 logger.debug("Get session origin")
+169 return request.session.get(_ORIGIN, reverse("core:index"))
+ + +172def reset(request):
+173 """Reset the session for the request."""
+174 logger.debug("Reset session")
+175 request.session[_AGENCY] = None
+176 request.session[_ELIGIBILITY] = None
+177 request.session[_ORIGIN] = reverse("core:index")
+178 request.session[_ENROLLMENT_TOKEN] = None
+179 request.session[_ENROLLMENT_TOKEN_EXP] = None
+180 request.session[_OAUTH_TOKEN] = None
+181 request.session[_OAUTH_CLAIM] = None
+182 request.session[_VERIFIER] = None
+ +184 if _UID not in request.session or not request.session[_UID]:
+185 logger.debug("Reset session time and uid")
+186 request.session[_START] = int(time.time() * 1000)
+187 u = str(uuid.uuid4())
+188 request.session[_UID] = u
+189 request.session[_DID] = str(uuid.UUID(hashlib.sha512(bytes(u, "utf8")).hexdigest()[:32]))
+ + +192def start(request):
+193 """
+194 Get the start time from the request's session, as integer milliseconds since
+195 Epoch. If unset, the session is reset to initialize a value.
+ +197 Once started, does not reset after subsequent calls to session.reset() or
+198 session.start(). This value is needed for Amplitude to accurately track
+199 sessions.
+ +201 See more: https://help.amplitude.com/hc/en-us/articles/115002323627-Tracking-Sessions
+202 """
+203 logger.debug("Get session time")
+204 s = request.session.get(_START)
+205 if not s:
+206 reset(request)
+207 s = request.session.get(_START)
+208 return s
+ + +211def uid(request):
+212 """
+213 Get the session's unique ID, a randomly generated UUID4 string. If unset,
+214 the session is reset to initialize a value.
+ +216 This value, like DID, is needed for Amplitude to accurately track that a
+217 sequence of events came from a unique user.
+ +219 See more: https://help.amplitude.com/hc/en-us/articles/115003135607-Track-unique-users-in-Amplitude
+ +221 Although Amplitude advises *against* setting user_id for anonymous users,
+222 here a value is set on anonymous users anyway, as the users never sign-in
+223 and become de-anonymized to this app / Amplitude.
+224 """
+225 logger.debug("Get session uid")
+226 u = request.session.get(_UID)
+227 if not u:
+228 reset(request)
+229 u = request.session.get(_UID)
+230 return u
+ + +233def update(
+234 request,
+235 agency=None,
+236 debug=None,
+237 eligibility_types=None,
+238 enrollment_token=None,
+239 enrollment_token_exp=None,
+240 oauth_token=None,
+241 oauth_claim=None,
+242 origin=None,
+243 verifier=None,
+244):
+245 """Update the request's session with non-null values."""
+246 if agency is not None and isinstance(agency, models.TransitAgency):
+247 logger.debug(f"Update session {_AGENCY}")
+248 request.session[_AGENCY] = agency.id
+249 if debug is not None:
+250 logger.debug(f"Update session {_DEBUG}")
+251 request.session[_DEBUG] = debug
+252 if eligibility_types is not None and isinstance(eligibility_types, list):
+253 logger.debug(f"Update session {_ELIGIBILITY}")
+254 if len(eligibility_types) > 1:
+255 raise NotImplementedError("Multiple eligibilities are not supported at this time.")
+256 elif len(eligibility_types) == 1:
+257 # get the eligibility corresponding to the session's agency
+258 a = models.TransitAgency.by_id(request.session[_AGENCY])
+259 t = str(eligibility_types[0]).strip()
+260 request.session[_ELIGIBILITY] = a.get_type_id(t)
+261 else:
+262 # empty list, clear session eligibility
+263 request.session[_ELIGIBILITY] = None
+264 if enrollment_token is not None:
+265 logger.debug(f"Update session {_ENROLLMENT_TOKEN}")
+266 request.session[_ENROLLMENT_TOKEN] = enrollment_token
+267 request.session[_ENROLLMENT_TOKEN_EXP] = enrollment_token_exp
+268 if oauth_token is not None:
+269 logger.debug(f"Update session {_OAUTH_TOKEN}")
+270 request.session[_OAUTH_TOKEN] = oauth_token
+271 if oauth_claim is not None:
+272 logger.debug(f"Update session {_OAUTH_CLAIM}")
+273 request.session[_OAUTH_CLAIM] = oauth_claim
+274 if origin is not None:
+275 logger.debug(f"Update session {_ORIGIN}")
+276 request.session[_ORIGIN] = origin
+277 if verifier is not None and isinstance(verifier, models.EligibilityVerifier):
+278 logger.debug(f"Update session {_VERIFIER}")
+279 request.session[_VERIFIER] = verifier.id
+ + +282def verifier(request):
+283 """Get the verifier from the request's session, or None"""
+284 logger.debug("Get session verifier")
+285 try:
+286 return models.EligibilityVerifier.by_id(request.session[_VERIFIER])
+287 except (KeyError, models.EligibilityVerifier.DoesNotExist):
+288 logger.debug("Can't get verifier from session")
+289 return None
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The core application: URLConf for the root of the webapp.
+3"""
+4import logging
+ +6from django.urls import path, register_converter
+ +8from . import models, views
+ + +11logger = logging.getLogger(__name__)
+ + +14class TransitAgencyPathConverter:
+15 """Path converter to parse valid TransitAgency objects from URL paths."""
+ +17 # used to test the url fragment, determines if this PathConverter is used
+18 regex = "[a-zA-Z]{3,5}"
+ +20 def to_python(self, value):
+21 """Determine if the matched fragment corresponds to an active Agency."""
+22 value = str(value).lower()
+23 logger.debug(f"Matched fragment from path: {value}")
+ +25 agency = models.TransitAgency.by_slug(value)
+26 if agency and agency.active:
+27 logger.debug("Path fragment is an active agency")
+28 return agency
+29 else:
+30 logger.error("Path fragment is not an active agency")
+31 raise ValueError("value")
+ +33 def to_url(self, agency):
+34 """Convert the Agency back into a string for a URL."""
+35 try:
+36 return agency.slug
+37 except AttributeError:
+38 return str(agency)
+ + +41logger.debug(f"Register path converter: {TransitAgencyPathConverter.__name__}")
+42register_converter(TransitAgencyPathConverter, "agency")
+ +44app_name = "core"
+ +46urlpatterns = [
+47 path("", views.index, name="index"),
+48 path("help", views.help, name="help"),
+49 path("<agency:agency>", views.agency_index, name="agency_index"),
+50 path("<agency:agency>/publickey", views.agency_public_key, name="agency_public_key"),
+51 path("logged_out", views.logged_out, name="logged_out"),
+52]
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The core application: view definition for the root of the webapp.
+3"""
+4from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseNotFound, HttpResponseServerError
+5from django.template import loader
+6from django.template.response import TemplateResponse
+ +8from . import session
+9from .middleware import pageview_decorator, index_or_agencyindex_origin_decorator
+ +11ROUTE_ELIGIBILITY = "eligibility:index"
+12ROUTE_HELP = "core:help"
+13ROUTE_LOGGED_OUT = "core:logged_out"
+ +15TEMPLATE_INDEX = "core/index.html"
+16TEMPLATE_AGENCY = "core/agency-index.html"
+17TEMPLATE_HELP = "core/help.html"
+18TEMPLATE_LOGGED_OUT = "core/logged-out.html"
+ +20TEMPLATE_BAD_REQUEST = "400.html"
+21TEMPLATE_NOT_FOUND = "404.html"
+22TEMPLATE_SERVER_ERROR = "500.html"
+ + +25@pageview_decorator
+26def index(request):
+27 """View handler for the main entry page."""
+28 session.reset(request)
+ +30 return TemplateResponse(request, TEMPLATE_INDEX)
+ + +33@pageview_decorator
+34def agency_index(request, agency):
+35 """View handler for an agency entry page."""
+36 session.reset(request)
+37 session.update(request, agency=agency, origin=agency.index_url)
+ +39 return TemplateResponse(request, agency.index_template)
+ + +42@pageview_decorator
+43def agency_public_key(request, agency):
+44 """View handler returns an agency's public key as plain text."""
+45 return HttpResponse(agency.public_key_data, content_type="text/plain")
+ + +48@pageview_decorator
+49def help(request):
+50 """View handler for the help page."""
+51 return TemplateResponse(request, TEMPLATE_HELP)
+ + +54@pageview_decorator
+55@index_or_agencyindex_origin_decorator
+56def bad_request(request, exception, template_name=TEMPLATE_BAD_REQUEST):
+57 """View handler for HTTP 400 Bad Request responses."""
+58 t = loader.get_template(template_name)
+ +60 return HttpResponseBadRequest(t.render(request=request))
+ + +63@pageview_decorator
+64@index_or_agencyindex_origin_decorator
+65def csrf_failure(request, reason):
+66 """
+67 View handler for CSRF_FAILURE_VIEW with custom data.
+68 """
+69 t = loader.get_template(TEMPLATE_BAD_REQUEST)
+ +71 return HttpResponseNotFound(t.render(request=request))
+ + +74@pageview_decorator
+75@index_or_agencyindex_origin_decorator
+76def page_not_found(request, exception, template_name=TEMPLATE_NOT_FOUND):
+77 """View handler for HTTP 404 Not Found responses."""
+78 t = loader.get_template(template_name)
+ +80 return HttpResponseNotFound(t.render(request=request))
+ + +83@pageview_decorator
+84@index_or_agencyindex_origin_decorator
+85def server_error(request, template_name=TEMPLATE_SERVER_ERROR):
+86 """View handler for HTTP 500 Server Error responses."""
+87 t = loader.get_template(template_name)
+ +89 return HttpResponseServerError(t.render(request=request))
+ + +92def logged_out(request):
+93 """View handler for the final log out confirmation message."""
+94 return TemplateResponse(request, TEMPLATE_LOGGED_OUT)
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The core application: Helper form widgets.
+3"""
+4import copy
+5from django.forms import widgets
+ + +8class FormControlTextInput(widgets.TextInput):
+9 """A styled text input."""
+ +11 def __init__(self, pattern=None, placeholder=None, **kwargs):
+12 super().__init__(**kwargs)
+ +14 self.attrs.update({"class": "form-control"})
+15 if pattern: 15 ↛ 16line 15 didn't jump to line 16, because the condition on line 15 was never true
+16 self.attrs.update({"pattern": pattern})
+17 if placeholder: 17 ↛ exitline 17 didn't return from function '__init__', because the condition on line 17 was never false
+18 self.attrs.update({"placeholder": placeholder})
+ + +21class VerifierRadioSelect(widgets.RadioSelect):
+22 """A radio select input styled for the Eligibility Verifier"""
+ +24 template_name = "core/widgets/verifier-radio-select.html"
+25 option_template_name = "core/widgets/verifier-radio-select-option.html"
+ +27 def __init__(self, selection_label_templates=(), *args, **kwargs):
+28 super().__init__(*args, **kwargs)
+29 self.selection_label_templates = list(selection_label_templates)
+ +31 def __deepcopy__(self, memo):
+32 obj = super().__deepcopy__(memo)
+33 obj.selection_label_templates = copy.copy(self.selection_label_templates)
+34 return obj
+ +36 def create_option(self, name, value, label, selected, index, subindex, attrs):
+37 option = super().create_option(name, value, label, selected, index, subindex, attrs)
+38 # this implementation does not support groups from ChoiceWidget.optgroups
+39 if value in self.selection_label_templates: 39 ↛ 42line 39 didn't jump to line 42, because the condition on line 39 was never false
+40 option.update({"selection_label_template": self.selection_label_templates[value]})
+ +42 return option
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ ++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The eligibility application: analytics implementation.
+3"""
+4from benefits.core import analytics as core
+ + +7class EligibilityEvent(core.Event):
+8 """Base analytics event for eligibility verification."""
+ +10 def __init__(self, request, event_type, eligibility_types):
+11 super().__init__(request, event_type)
+12 # overwrite core.Event eligibility_types
+13 self.update_event_properties(eligibility_types=eligibility_types)
+14 self.update_user_properties(eligibility_types=eligibility_types)
+ + +17class SelectedVerifierEvent(EligibilityEvent):
+18 """Analytics event representing the user selecting an eligibility verifier."""
+ +20 def __init__(self, request, eligibility_types):
+21 super().__init__(request, "selected eligibility verifier", eligibility_types)
+ + +24class StartedEligibilityEvent(EligibilityEvent):
+25 """Analytics event representing the beginning of an eligibility verification check."""
+ +27 def __init__(self, request, eligibility_types):
+28 super().__init__(request, "started eligibility", eligibility_types)
+ + +31class ReturnedEligibilityEvent(EligibilityEvent):
+32 """Analytics event representing the end of an eligibility verification check."""
+ +34 def __init__(self, request, eligibility_types, status, error=None):
+35 super().__init__(request, "returned eligibility", eligibility_types)
+36 status = str(status).lower()
+37 if status in ("error", "fail", "success"): 37 ↛ 39line 37 didn't jump to line 39, because the condition on line 37 was never false
+38 self.update_event_properties(status=status, error=error)
+39 if status == "success": 39 ↛ exitline 39 didn't return from function '__init__', because the condition on line 39 was never false
+40 self.update_user_properties(eligibility_types=eligibility_types)
+ + +43def selected_verifier(request, eligibility_types):
+44 """Send the "selected eligibility verifier" analytics event."""
+45 core.send_event(SelectedVerifierEvent(request, eligibility_types))
+ + +48def started_eligibility(request, eligibility_types):
+49 """Send the "started eligibility" analytics event."""
+50 core.send_event(StartedEligibilityEvent(request, eligibility_types))
+ + +53def returned_error(request, eligibility_types, error):
+54 """Send the "returned eligibility" analytics event with an error status."""
+55 core.send_event(ReturnedEligibilityEvent(request, eligibility_types, status="error", error=error))
+ + +58def returned_fail(request, eligibility_types):
+59 """Send the "returned eligibility" analytics event with a fail status."""
+60 core.send_event(ReturnedEligibilityEvent(request, eligibility_types, status="fail"))
+ + +63def returned_success(request, eligibility_types):
+64 """Send the "returned eligibility" analytics event with a success status."""
+65 core.send_event(ReturnedEligibilityEvent(request, eligibility_types, status="success"))
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The eligibility application: Verifies eligibility for benefits.
+3"""
+4from django.apps import AppConfig
+ + +7class EligibilityAppConfig(AppConfig):
+8 name = "benefits.eligibility"
+9 label = "eligibility"
+10 verbose_name = "Eligibility Verification"
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The eligibility application: Form definition for the eligibility verification flow.
+3"""
+4import logging
+ +6from django import forms
+7from django.utils.translation import gettext_lazy as _
+ +9from benefits.core import models, recaptcha, widgets
+ + +12logger = logging.getLogger(__name__)
+ + +15class EligibilityVerifierSelectionForm(forms.Form):
+16 """Form to capture eligibility verifier selection."""
+ +18 action_url = "eligibility:index"
+19 id = "form-verifier-selection"
+20 method = "POST"
+ +22 verifier = forms.ChoiceField(label="", widget=widgets.VerifierRadioSelect)
+23 # sets label to empty string so the radio_select template can override the label style
+24 submit_value = _("Choose this Benefit")
+ +26 def __init__(self, agency: models.TransitAgency, *args, **kwargs):
+27 super().__init__(*args, **kwargs)
+28 verifiers = agency.eligibility_verifiers.filter(active=True)
+ +30 self.classes = "col-lg-8"
+31 # second element is not used since we render the whole label using selection_label_template,
+32 # therefore set to None
+33 self.fields["verifier"].choices = [(v.id, None) for v in verifiers]
+34 self.fields["verifier"].widget.selection_label_templates = {v.id: v.selection_label_template for v in verifiers}
+ +36 def clean(self):
+37 if not recaptcha.verify(self.data): 37 ↛ 38line 37 didn't jump to line 38, because the condition on line 37 was never true
+38 raise forms.ValidationError("reCAPTCHA failed")
+ + +41class EligibilityVerificationForm(forms.Form):
+42 """Form to collect eligibility verification details."""
+ +44 action_url = "eligibility:confirm"
+45 id = "form-eligibility-verification"
+46 method = "POST"
+ +48 submit_value = _("Check eligibility")
+49 submitting_value = _("Checking")
+ +51 _error_messages = {
+52 "invalid": _("Check your input. The format looks wrong."),
+53 "missing": _("This field is required."),
+54 }
+ +56 def __init__(
+57 self,
+58 title,
+59 headline,
+60 blurb,
+61 name_label,
+62 name_placeholder,
+63 name_help_text,
+64 sub_label,
+65 sub_placeholder,
+66 sub_help_text,
+67 name_max_length=None,
+68 sub_input_mode=None,
+69 sub_max_length=None,
+70 sub_pattern=None,
+71 *args,
+72 **kwargs,
+73 ):
+74 """Initialize a new EligibilityVerifier form.
+ +76 Args:
+77 title (str): The page (i.e. tab) title for the form's page.
+ +79 headline (str): The <h1> on the form's page.
+ +81 blurb (str): Intro <p> on the form's page.
+ +83 name_label (str): Label for the name form field.
+ +85 name_placeholder (str): Field placeholder for the name form field.
+ +87 name_help_text (str): Extra help text for the name form field.
+ +89 sub_label (str): Label for the sub form field.
+ +91 sub_placeholder (str): Field placeholder for the sub form field.
+ +93 sub_help_text (str): Extra help text for the sub form field.
+ +95 name_max_length (int): The maximum length accepted for the 'name' API field before sending to this verifier
+ +97 sub_input_mode (str): Input mode can be "numeric", "tel", "search", etc. to override default "text" keyboard on
+98 mobile devices
+ +100 sub_max_length (int): The maximum length accepted for the 'sub' API field before sending to this verifier
+ +102 sub_pattern (str): A regular expression used to validate the 'sub' API field before sending to this verifier
+ +104 Extra args and kwargs are passed through to the underlying django.forms.Form.
+105 """
+106 super().__init__(auto_id=True, label_suffix="", *args, **kwargs)
+ +108 self.title = title
+109 self.headline = headline
+110 self.blurb = blurb
+ +112 self.classes = "col-lg-6"
+113 sub_widget = widgets.FormControlTextInput(placeholder=sub_placeholder)
+114 if sub_pattern: 114 ↛ 115line 114 didn't jump to line 115, because the condition on line 114 was never true
+115 sub_widget.attrs.update({"pattern": sub_pattern})
+116 if sub_input_mode: 116 ↛ 117line 116 didn't jump to line 117, because the condition on line 116 was never true
+117 sub_widget.attrs.update({"inputmode": sub_input_mode})
+118 if sub_max_length: 118 ↛ 119line 118 didn't jump to line 119, because the condition on line 118 was never true
+119 sub_widget.attrs.update({"maxlength": sub_max_length})
+ +121 self.fields["sub"] = forms.CharField(
+122 label=sub_label,
+123 widget=sub_widget,
+124 help_text=sub_help_text,
+125 )
+ +127 name_widget = widgets.FormControlTextInput(placeholder=name_placeholder)
+128 if name_max_length: 128 ↛ 129line 128 didn't jump to line 129, because the condition on line 128 was never true
+129 name_widget.attrs.update({"maxlength": name_max_length})
+ +131 self.fields["name"] = forms.CharField(label=name_label, widget=name_widget, help_text=name_help_text)
+ +133 def clean(self):
+134 if not recaptcha.verify(self.data): 134 ↛ 135line 134 didn't jump to line 135, because the condition on line 134 was never true
+135 raise forms.ValidationError("reCAPTCHA failed")
+ + +138class MSTCourtesyCard(EligibilityVerificationForm):
+139 """EligibilityVerification form for the MST Courtesy Card."""
+ +141 def __init__(self, *args, **kwargs):
+142 super().__init__(
+143 title=_("Agency card information"),
+144 headline=_("Let’s see if we can confirm your eligibility."),
+145 blurb=_("Please input your Courtesy Card number and last name below to confirm your eligibility."),
+146 name_label=_("Last name (as it appears on Courtesy Card)"),
+147 name_placeholder="Garcia",
+148 name_help_text=_("We use this to help confirm your Courtesy Card."),
+149 sub_label=_("MST Courtesy Card number"),
+150 sub_help_text=_("This is a 5-digit number on the front and back of your card."),
+151 sub_placeholder="12345",
+152 name_max_length=255,
+153 sub_input_mode="numeric",
+154 sub_max_length=5,
+155 sub_pattern=r"\d{5}",
+156 *args,
+157 **kwargs,
+158 )
+ + +161class SBMTDMobilityPass(EligibilityVerificationForm):
+162 """EligibilityVerification form for the SBMTD Mobility Pass."""
+ +164 def __init__(self, *args, **kwargs):
+165 super().__init__(
+166 title=_("Agency card information"),
+167 headline=_("Let’s see if we can confirm your eligibility."),
+168 blurb=_("Please input your Mobility Pass number and last name below to confirm your eligibility."),
+169 name_label=_("Last name (as it appears on Mobility Pass)"),
+170 name_placeholder="Garcia",
+171 name_help_text=_("We use this to help confirm your Mobility Pass."),
+172 sub_label=_("SBMTD Mobility Pass number"),
+173 sub_help_text=_("This is a 4-digit number on the front and back of your card."),
+174 sub_placeholder="1234",
+175 name_max_length=255,
+176 sub_input_mode="numeric",
+177 sub_max_length=4,
+178 sub_pattern=r"\d{4}",
+179 *args,
+180 **kwargs,
+181 )
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The eligibility application: URLConf for the eligibility verification flow.
+3"""
+4from django.urls import path
+ +6from . import views
+ + +9app_name = "eligibility"
+10urlpatterns = [
+11 # /eligibility
+12 path("", views.index, name="index"),
+13 path("<agency:agency>", views.index, name="agency_index"),
+14 path("start", views.start, name="start"),
+15 path("confirm", views.confirm, name="confirm"),
+16 path("unverified", views.unverified, name="unverified"),
+17]
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1from django.conf import settings
+ +3from eligibility_api.client import Client
+ + +6def eligibility_from_api(verifier, form, agency):
+7 sub, name = form.cleaned_data.get("sub"), form.cleaned_data.get("name")
+ +9 client = Client(
+10 verify_url=verifier.api_url,
+11 headers={verifier.api_auth_header: verifier.api_auth_key},
+12 issuer=settings.ALLOWED_HOSTS[0],
+13 agency=agency.agency_id,
+14 jws_signing_alg=agency.jws_signing_alg,
+15 client_private_key=agency.private_key_data,
+16 jwe_encryption_alg=verifier.jwe_encryption_alg,
+17 jwe_cek_enc=verifier.jwe_cek_enc,
+18 server_public_key=verifier.public_key_data,
+19 timeout=settings.REQUESTS_TIMEOUT,
+20 )
+ +22 response = client.verify(sub, name, agency.type_names_to_verify(verifier))
+ +24 if response.error and any(response.error):
+25 return None
+26 elif any(response.eligibility):
+27 return list(response.eligibility)
+28 else:
+29 return []
+ + +32def eligibility_from_oauth(verifier, oauth_claim, agency):
+33 if verifier.uses_auth_verification and verifier.auth_provider.claim == oauth_claim:
+34 return agency.type_names_to_verify(verifier)
+35 else:
+36 return []
++ « prev + ^ index + » next + + coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+ +1"""
+2The eligibility application: view definitions for the eligibility verification flow.
+3"""
+4from django.contrib import messages
+5from django.shortcuts import redirect
+6from django.template.response import TemplateResponse
+7from django.urls import reverse
+8from django.utils.decorators import decorator_from_middleware
+ +10from benefits.core import recaptcha, session
+11from benefits.core.middleware import AgencySessionRequired, LoginRequired, RecaptchaEnabled, VerifierSessionRequired
+12from benefits.core.models import EligibilityVerifier
+13from . import analytics, forms, verify
+ + +16ROUTE_CORE_INDEX = "core:index"
+17ROUTE_INDEX = "eligibility:index"
+18ROUTE_START = "eligibility:start"
+19ROUTE_LOGIN = "oauth:login"
+20ROUTE_CONFIRM = "eligibility:confirm"
+21ROUTE_UNVERIFIED = "eligibility:unverified"
+22ROUTE_ENROLLMENT = "enrollment:index"
+ +24TEMPLATE_START = "eligibility/start.html"
+25TEMPLATE_CONFIRM = "eligibility/confirm.html"
+26TEMPLATE_UNVERIFIED = "eligibility/unverified.html"
+ + +29@decorator_from_middleware(RecaptchaEnabled)
+30def index(request, agency=None):
+31 """View handler for the eligibility verifier selection form."""
+ +33 if agency is None: 33 ↛ 41line 33 didn't jump to line 41, because the condition on line 33 was never false
+34 # see if session has an agency
+35 agency = session.agency(request)
+36 if agency is None:
+37 return TemplateResponse(request, "200-user-error.html")
+38 else:
+39 session.update(request, eligibility_types=[], origin=agency.index_url)
+40 else:
+41 session.update(request, agency=agency, eligibility_types=[], origin=reverse(ROUTE_CORE_INDEX))
+ +43 # clear any prior OAuth token as the user is choosing their desired flow
+44 # this may or may not require OAuth, with a different set of scope/claims than what is already stored
+45 session.logout(request)
+ +47 context = {"form": forms.EligibilityVerifierSelectionForm(agency=agency)}
+ +49 if request.method == "POST":
+50 form = forms.EligibilityVerifierSelectionForm(data=request.POST, agency=agency)
+ +52 if form.is_valid():
+53 verifier_id = form.cleaned_data.get("verifier")
+54 verifier = EligibilityVerifier.objects.get(id=verifier_id)
+55 session.update(request, verifier=verifier)
+ +57 types_to_verify = agency.type_names_to_verify(verifier)
+58 analytics.selected_verifier(request, types_to_verify)
+ +60 eligibility_start = reverse(ROUTE_START)
+61 response = redirect(eligibility_start)
+62 else:
+63 # form was not valid, allow for correction/resubmission
+64 if recaptcha.has_error(form): 64 ↛ 65line 64 didn't jump to line 65, because the condition on line 64 was never true
+65 messages.error(request, "Recaptcha failed. Please try again.")
+66 context["form"] = form
+67 response = TemplateResponse(request, agency.eligibility_index_template, context)
+68 else:
+69 response = TemplateResponse(request, agency.eligibility_index_template, context)
+ +71 return response
+ + +74@decorator_from_middleware(AgencySessionRequired)
+75@decorator_from_middleware(VerifierSessionRequired)
+76def start(request):
+77 """View handler for the eligibility verification getting started screen."""
+78 session.update(request, eligibility_types=[], origin=reverse(ROUTE_START))
+ +80 verifier = session.verifier(request)
+81 template = verifier.start_template or TEMPLATE_START
+ +83 return TemplateResponse(request, template)
+ + +86@decorator_from_middleware(AgencySessionRequired)
+87@decorator_from_middleware(LoginRequired)
+88@decorator_from_middleware(RecaptchaEnabled)
+89@decorator_from_middleware(VerifierSessionRequired)
+90def confirm(request):
+91 """View handler for the eligibility verification form."""
+ +93 # GET from an already verified user, no need to verify again
+94 if request.method == "GET" and session.eligible(request):
+95 eligibility = session.eligibility(request)
+96 return verified(request, [eligibility.name])
+ +98 unverified_view = reverse(ROUTE_UNVERIFIED)
+ +100 agency = session.agency(request)
+101 verifier = session.verifier(request)
+102 types_to_verify = agency.type_names_to_verify(verifier)
+ +104 # GET for OAuth verification
+105 if request.method == "GET" and verifier.uses_auth_verification:
+106 analytics.started_eligibility(request, types_to_verify)
+ +108 verified_types = verify.eligibility_from_oauth(verifier, session.oauth_claim(request), agency)
+109 if verified_types:
+110 return verified(request, verified_types)
+111 else:
+112 return redirect(unverified_view)
+ +114 form = verifier.form_instance()
+ +116 # GET/POST for Eligibility API verification
+117 context = {"form": form}
+ +119 # GET from an unverified user, present the form
+120 if request.method == "GET":
+121 return TemplateResponse(request, TEMPLATE_CONFIRM, context)
+122 # POST form submission, process form data, make Eligibility Verification API call
+123 elif request.method == "POST": 123 ↛ exitline 123 didn't return from function 'confirm', because the condition on line 123 was never false
+124 analytics.started_eligibility(request, types_to_verify)
+ +126 form = verifier.form_instance(data=request.POST)
+127 # form was not valid, allow for correction/resubmission
+128 if not form.is_valid():
+129 if recaptcha.has_error(form):
+130 messages.error(request, "Recaptcha failed. Please try again.")
+131 context["form"] = form
+132 return TemplateResponse(request, TEMPLATE_CONFIRM, context)
+ +134 # form is valid, make Eligibility Verification request to get the verified types
+135 verified_types = verify.eligibility_from_api(verifier, form, agency)
+ +137 # form was not valid, allow for correction/resubmission
+138 if verified_types is None:
+139 analytics.returned_error(request, types_to_verify, form.errors)
+140 context["form"] = form
+141 return TemplateResponse(request, TEMPLATE_CONFIRM, context)
+142 # no types were verified
+143 elif len(verified_types) == 0:
+144 return redirect(unverified_view)
+145 # type(s) were verified
+146 else:
+147 return verified(request, verified_types)
+ + +150@decorator_from_middleware(AgencySessionRequired)
+151@decorator_from_middleware(LoginRequired)
+152def verified(request, verified_types):
+153 """View handler for the verified eligibility page."""
+ +155 analytics.returned_success(request, verified_types)
+ +157 session.update(request, eligibility_types=verified_types)
+ +159 return redirect(ROUTE_ENROLLMENT)
+ + +162@decorator_from_middleware(AgencySessionRequired)
+163@decorator_from_middleware(VerifierSessionRequired)
+164def unverified(request):
+165 """View handler for the unverified eligibility page."""
+ +167 agency = session.agency(request)
+168 verifier = session.verifier(request)
+169 types_to_verify = agency.type_names_to_verify(verifier)
+ +171 analytics.returned_fail(request, types_to_verify)
+ +173 return TemplateResponse(request, TEMPLATE_UNVERIFIED)
++ coverage.py v7.3.2, + created at 2023-11-15 22:17 +0000 +
+Module | +statements | +missing | +excluded | +branches | +partial | +coverage | +
---|---|---|---|---|---|---|
benefits/__init__.py | +6 | +2 | +0 | +0 | +0 | +67% | +
benefits/core/__init__.py | +0 | +0 | +0 | +0 | +0 | +100% | +
benefits/core/admin.py | +9 | +9 | +0 | +4 | +0 | +0% | +
benefits/core/analytics.py | +89 | +20 | +0 | +18 | +3 | +67% | +
benefits/core/apps.py | +5 | +0 | +0 | +0 | +0 | +100% | +
benefits/core/context_processors.py | +30 | +2 | +0 | +10 | +1 | +92% | +
benefits/core/middleware.py | +91 | +5 | +0 | +22 | +3 | +93% | +
benefits/core/models.py | +176 | +0 | +0 | +48 | +1 | +99% | +
benefits/core/recaptcha.py | +13 | +5 | +0 | +6 | +1 | +58% | +
benefits/core/session.py | +153 | +2 | +0 | +32 | +0 | +99% | +
benefits/core/urls.py | +24 | +0 | +0 | +2 | +0 | +100% | +
benefits/core/views.py | +52 | +0 | +0 | +32 | +0 | +100% | +
benefits/core/widgets.py | +25 | +1 | +0 | +6 | +3 | +87% | +
benefits/eligibility/__init__.py | +0 | +0 | +0 | +0 | +0 | +100% | +
benefits/eligibility/analytics.py | +30 | +4 | +0 | +4 | +2 | +82% | +
benefits/eligibility/apps.py | +5 | +0 | +0 | +0 | +0 | +100% | +
benefits/eligibility/forms.py | +54 | +8 | +0 | +16 | +6 | +80% | +
benefits/eligibility/urls.py | +4 | +0 | +0 | +0 | +0 | +100% | +
benefits/eligibility/verify.py | +15 | +0 | +0 | +6 | +0 | +100% | +
benefits/eligibility/views.py | +104 | +2 | +0 | +58 | +3 | +97% | +
benefits/enrollment/__init__.py | +0 | +0 | +0 | +0 | +0 | +100% | +
benefits/enrollment/analytics.py | +14 | +2 | +0 | +4 | +2 | +78% | +
benefits/enrollment/api.py | +166 | +6 | +0 | +54 | +0 | +95% | +
benefits/enrollment/apps.py | +5 | +0 | +0 | +0 | +0 | +100% | +
benefits/enrollment/forms.py | +12 | +0 | +0 | +0 | +0 | +100% | +
benefits/enrollment/urls.py | +4 | +0 | +0 | +0 | +0 | +100% | +
benefits/enrollment/views.py | +69 | +0 | +0 | +26 | +0 | +100% | +
benefits/oauth/__init__.py | +0 | +0 | +0 | +0 | +0 | +100% | +
benefits/oauth/analytics.py | +33 | +11 | +0 | +2 | +0 | +69% | +
benefits/oauth/apps.py | +11 | +0 | +0 | +0 | +0 | +100% | +
benefits/oauth/client.py | +21 | +0 | +0 | +4 | +0 | +100% | +
benefits/oauth/middleware.py | +13 | +0 | +0 | +4 | +0 | +100% | +
benefits/oauth/redirects.py | +14 | +0 | +0 | +2 | +0 | +100% | +
benefits/oauth/urls.py | +4 | +0 | +0 | +0 | +0 | +100% | +
benefits/oauth/views.py | +73 | +0 | +0 | +26 | +0 | +100% | +
benefits/sentry.py | +56 | +12 | +0 | +14 | +1 | +79% | +
benefits/settings.py | +108 | +10 | +0 | +26 | +12 | +84% | +
benefits/urls.py | +18 | +6 | +0 | +4 | +2 | +64% | +
benefits/wsgi.py | +4 | +4 | +0 | +0 | +0 | +0% | +
Total | +1510 | +111 | +0 | +430 | +40 | +91% | +
+ No items found using the specified filter. +
+Feature and user interface tests are implemented with cypress
and can be found in the
+tests/cypress
directory in the repository.
See the cypress
Command Line guide for more information.
These are instructions for running cypress
locally on your machine, without the devcontainer. These steps
+will install cypress
and its dependencies on your machine. Make sure to run these commands in a Terminal.
node -v
+npm -v
+
If not, install Node.js locally.
+docker compose up -d client
+
cypress
directory:cd tests/cypress
+
cypress
. Verify cypress
installation succeeds:npm install
+
cypress
with test environment variables and configuration variables:CYPRESS_baseUrl=http://localhost:8000 npm run cypress:open
+
See tests/cypress/package.json
for more cypress scripts.
As of Cypress 12.5.1 with Firefox 109, there is a CSRF issue that prevents the tests from passing; unclear if this is a bug in Cypress or what. Use one of the other browser options.
+The tests done at a request/unit level are run via pytest-django.
+To run locally, start the Devcontainer and run:
+tests/pytest/run.sh
+
The helper script:
+pytest
coverage
coverage
report in HTML in the app’s static/
directoryThe report can be viewed by launching the app and navigating to http://localhost:$DJANGO_LOCAL_PORT/static/coverage/index.html
The report files include a local .gitignore
file, so the entire directory is hidden from source control.
We also make the latest (from dev
) coverage report available online here: Coverage report
This use case describes a feature in the Cal-ITP Benefits app that allows US veterans who use public transit to verify their veteran status and receive reduced fares when paying by contactless debit or credit card at participating transit providers in California.
+Actor: A US veteran who uses public transit in California. For benefit eligibility, a veteran is defined as “a person who served in the active military, naval, or air service, and was discharged or released therefrom under conditions other than dishonorable.” (source)
+Goal: To verify a transit rider’s veteran status and enable the rider to receive reduced fares when paying by contactless debit or credit card.
+Precondition: The California transit provider delivering fixed route service has installed and tested validator hardware necessary to collect fares using contactless payment on bus or rail lines, and the provider has a policy to offer a transit discount for US veterans.
+sequenceDiagram
+%% Veteran Enrollment Pathway
+ actor Transit Rider
+ participant Benefits as Benefits app
+ participant IdG as Identity Gateway
+ participant Login.gov
+ participant VA.gov
+ participant Littlepay
+Transit Rider->>Benefits: visits benefits.calitp.org
+ activate Benefits
+Benefits-->>IdG: eligibility verification
+ activate IdG
+Transit Rider->>Login.gov: account authentication
+ activate Login.gov
+IdG-->>Login.gov: requests required PII
+ activate Login.gov
+ Note right of Login.gov: transit rider first name<br>transit rider last name<br>home address<br>date of birth
+Login.gov-->>IdG: returns required PII
+ deactivate Login.gov
+IdG-->>VA.gov: check veteran status
+ activate VA.gov
+VA.gov-->>IdG: return veteran status
+ deactivate VA.gov
+IdG-->>Benefits: eligibility response
+ deactivate IdG
+ deactivate Login.gov
+Benefits-->>Littlepay: payment enrollment start
+ activate Littlepay
+Transit Rider->>Littlepay: provides debit or credit card details
+Littlepay-->>Benefits: payment method enrollment confirmation
+ deactivate Littlepay
+ deactivate Benefits
+The transit rider receives a fare reduction each time they use the debit or credit card they registered to pay for transit rides. The number of times they can use the card to pay for transit is unlimited and the benefit never expires.
+A veteran in California uses public transit regularly. They don’t have a car and depend on buses to get to appointments and do errands that take too long to use their bicycle. They receive a 50% fare reduction for being a US veteran but have to pay for transit rides using the closed loop card provided by the agency to receive the reduced fare. It’s frustrating and inconvenient to reload this agency card in $10 payments every week, especially because they sometimes need the money tied up on the card to pay for groceries and medication.
+The transit provider serving their part of California implements contactless payments on fixed bus routes throughout the service area. This rider uses benefits.calitp.org to confirm their veteran status and register their debit card for reduced fares. They tap to pay when boarding buses in their area and are automatically charged the reduced fare. They no longer need to carry one card to pay for transit and another for other purchases. Best of all, they have complete access to all funds in their weekly budget. If food and medication costs are higher one week, they can allocate additional funds to those areas and ride transit less.
+ + + + + + + + +Agency Cards is a generic term for reduced fare programs offered by Transit Providers, such as the +Courtesy Card program from Monterey-Salinas Transit (MST).
+Agency cards are different from our other use cases in that eligibility verification happens on the agency side (offline) rather +than through the Benefits app, and the Benefits app then checks for a valid Agency Card via an Eligibility API call.
+In order to support an Agency Cards deployment, the Transit Provider produces a list of eligible users +(CSV format) that is loaded into an instance of Eligibility Server running in the Transit Provider’s cloud.
+Cal-ITP makes the hashfields
tool to facilitate masking user data before it leaves Transit Provider on-premises systems.
The complete system architecture looks like:
+flowchart LR
+ rider((User's browser))
+ api[Eligibility Server]
+ data[Hashed Agency Card data]
+ cardsystem[Data source]
+
+ rider --> Benefits
+
+ subgraph CDT Azure
+ Benefits
+ end
+
+ Benefits --> api
+
+ subgraph Transit Provider cloud
+ api --> data
+ end
+
+ subgraph Transit Provider on-prem
+ cardsystem --> hashfields
+ end
+
+ hashfields --> data
+Notes:
+Data Source
is Velocity, the system MST uses to manage and print Courtesy CardssequenceDiagram
+ actor Rider
+ participant Benefits as Benefits app
+ participant elig_server as Eligibility Server
+ participant cc_data as Hashed data
+ participant Data Source
+ participant Littlepay
+
+ Data Source-->>cc_data: exports nightly
+ cc_data-->>elig_server: gets loaded on Server start
+
+ Rider->>Benefits: visits site
+ Benefits-->>elig_server: passes entered Agency Card details
+ elig_server-->>Benefits: confirms eligibility
+
+ Benefits-->>Littlepay: enrollment start
+ Rider->>Littlepay: enters payment card details
+ Littlepay-->>Benefits: enrollment complete
+
+
+
+
+
+
+
+
+ We have another potential transit discount use case, which is for students/faculty/staff from the Monterey-Salinas Transit (MST) area. We will be taking the existing program where students from certain schools ride free, expanding it to faculty and staff in some cases, and allowing those riders to enroll their contactless bank (credit/debit) cards for half-price (50%) discounts during fall and winter breaks.
+Here’s a clickable prototype showing the planned flow, having users enroll via their college’s single sign-on (SSO) system:
+ + +Here’s what will happen behind the scenes in a success flow:
+sequenceDiagram
+ actor rider
+ participant Benefits as Benefits app
+ participant IdG as Identity Gateway
+ participant SSO
+ participant Littlepay
+
+ rider->>Benefits: visits site
+ Benefits-->>IdG: redirected to sign in
+ IdG-->>SSO: redirected to sign in
+ rider->>SSO: enters credentials
+ SSO-->>IdG: user attributes
+ IdG-->>Benefits: user attributes
+ Benefits-->>Littlepay: enrollment start
+ rider->>Littlepay: enters payment card details
+ Littlepay-->>Benefits: enrollment complete
+The plan is to determine whether the rider is eligible via SAML attributes and/or membership in a group on the college side.
+ + + + + + + + +This section describes in more detail some of the use cases with current or planned support in the Benefits application.
+We do sprint planning and track day-to-day work on our Project Board.
+See our Milestones for current work tracked against specific features and use cases.
+See our Product Roadmap for more information on planned feature development and prioritization.
+ + + + + + + + + +One Benefits application use case is for riders age 65 years and older. The Benefits application verifies the person’s age to confirm eligibility and allows those eligible to enroll their contactless payment card for their transit benefit.
+Currently, the app uses Login.gov’s Identity Assurance Level 2 (IAL2) to confirm age, which requires a person to have a Social Security number, a valid state-issued ID card and a phone number with a phone plan associated with the person’s name. Adding ways to confirm eligibility for people without a Social Security number, for people who are part of a transit agency benefit program are on the roadmap.
+Here’s a GIF showing what the flow looks like, having seniors confirm eligibility via Login.gov and enroll via LittlePay:
+ +sequenceDiagram
+ actor Rider
+ participant Benefits as Benefits app
+ participant IdG as Identity Gateway
+ participant Login.gov
+ participant Littlepay
+
+ Rider->>Benefits: visits site
+ Benefits-->>IdG: identity proofing
+ IdG-->>Login.gov: identity proofing
+ Rider->>Login.gov: enters SSN and ID
+ Login.gov-->>IdG: eligibility verification
+ IdG-->>Benefits: eligibility verification
+ Benefits-->>Littlepay: enrollment start
+ Rider->>Littlepay: enters payment card details
+ Littlepay-->>Benefits: enrollment complete
+
+
+
+
+
+
+
+
+