diff --git a/.devcontainer/postAttach.sh b/.devcontainer/postAttach.sh index 46a243aa6..29495649d 100755 --- a/.devcontainer/postAttach.sh +++ b/.devcontainer/postAttach.sh @@ -2,4 +2,6 @@ set -eu # initialize pre-commit + +git config --global --add safe.directory /home/calitp/app pre-commit install --overwrite diff --git a/.github/workflows/add-to-project-dependabot.yml b/.github/workflows/add-to-project-dependabot.yml index be934daec..ae42b5f6c 100644 --- a/.github/workflows/add-to-project-dependabot.yml +++ b/.github/workflows/add-to-project-dependabot.yml @@ -14,3 +14,11 @@ jobs: with: project-url: https://github.com/orgs/cal-itp/projects/${{ secrets.GH_PROJECT }} github-token: ${{ secrets.GH_PROJECTS_TOKEN }} + + - uses: EndBug/project-fields@v2 + with: + operation: set + fields: Effort + values: 1 + project_url: https://github.com/orgs/cal-itp/projects/${{ secrets.GH_PROJECT }} + github_token: ${{ secrets.GH_PROJECTS_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 82d93655f..d3365e533 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -15,9 +15,28 @@ on: - cron: "24 9 * * 6" jobs: + setup: + name: Set up CodeQL analysis + runs-on: ubuntu-latest + # Required permissions + permissions: + pull-requests: read + outputs: + # changes is a JSON array with names of all filters matching any of the changed files + languages: ${{ steps.filter.outputs.changes }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + javascript: "**.js" + python: "**.py" + codeql: name: CodeQL Analyze runs-on: ubuntu-latest + needs: setup permissions: actions: read contents: read @@ -26,7 +45,9 @@ jobs: strategy: fail-fast: false matrix: - language: ["javascript", "python"] + # Parse JSON array containing names of all filters matching any of changed files + # e.g. ['javascript', 'python'] if both file types had changes + language: ${{ fromJSON(needs.setup.outputs.languages) }} steps: - name: Checkout repository @@ -36,10 +57,10 @@ jobs: uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main + # https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning#specifying-configuration-details-using-the-config-input + config: | + paths-ignore: + - tests - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml index f08636847..226870d5d 100644 --- a/.github/workflows/mkdocs.yml +++ b/.github/workflows/mkdocs.yml @@ -29,6 +29,8 @@ jobs: runs-on: ubuntu-latest # only pull requests should generate a preview if: github.event.pull_request + permissions: + pull-requests: write steps: - name: Checkout uses: actions/checkout@v4 @@ -48,16 +50,15 @@ jobs: mkdocs build - name: Install Netlify CLI - run: npm install --location=global netlify-cli + run: npm install --location=global netlify-cli@17.x.x - name: Deploy Preview to Netlify run: | netlify deploy \ + --alias="${GITHUB_REPOSITORY#*/}-${{ github.event.number }}" \ + --auth=${{ secrets.NETLIFY_AUTH_TOKEN }} \ --dir="site" \ - --alias="${GITHUB_REPOSITORY#*/}-${{ github.event.number }}" - env: - NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} - NETLIFY_SITE_ID: ${{ secrets.NETLIFY_PREVIEW_APP_SITE_ID }} + --site=${{ secrets.NETLIFY_PREVIEW_APP_SITE_ID }} - name: Find existing comment uses: peter-evans/find-comment@v3 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 23b79b33b..7b4940767 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,13 +15,13 @@ default_stages: repos: - repo: https://github.com/compilerla/conventional-pre-commit - rev: v3.1.0 + rev: v3.2.0 hooks: - id: conventional-pre-commit stages: [commit-msg] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: trailing-whitespace - id: mixed-line-ending @@ -34,7 +34,7 @@ repos: args: ["--maxkb=1500"] - repo: https://github.com/psf/black - rev: 24.2.0 + rev: 24.4.2 hooks: - id: black types: diff --git a/benefits/core/admin.py b/benefits/core/admin.py index cb468b629..d026f27c8 100644 --- a/benefits/core/admin.py +++ b/benefits/core/admin.py @@ -5,6 +5,7 @@ import logging import requests +from adminsortable2.admin import SortableAdminMixin from django.conf import settings from django.contrib import admin from . import models @@ -16,8 +17,8 @@ for model in [ + models.AuthProvider, models.EligibilityType, - models.EligibilityVerifier, models.PaymentProcessor, models.PemData, models.TransitAgency, @@ -26,6 +27,11 @@ admin.site.register(model) +@admin.register(models.EligibilityVerifier) +class SortableEligibilityVerifierAdmin(SortableAdminMixin, admin.ModelAdmin): + pass + + def pre_login_user(user, request): logger.debug(f"Running pre-login callback for user: {user.username}") token = request.session.get("google_sso_access_token") diff --git a/benefits/core/analytics.py b/benefits/core/analytics.py index d998571a4..51dc4ae09 100644 --- a/benefits/core/analytics.py +++ b/benefits/core/analytics.py @@ -92,6 +92,18 @@ class ViewedPageEvent(Event): def __init__(self, request): super().__init__(request, "viewed page") + # Add UTM codes + utm_campaign = request.GET.get("utm_campaign") + utm_source = request.GET.get("utm_source") + utm_medium = request.GET.get("utm_medium") + utm_content = request.GET.get("utm_content") + utm_id = request.GET.get("utm_id") + self.update_event_properties( + utm_campaign=utm_campaign, utm_source=utm_source, utm_medium=utm_medium, utm_content=utm_content, utm_id=utm_id + ) + self.update_user_properties( + utm_campaign=utm_campaign, utm_source=utm_source, utm_medium=utm_medium, utm_content=utm_content, utm_id=utm_id + ) class ChangedLanguageEvent(Event): diff --git a/benefits/core/context_processors.py b/benefits/core/context_processors.py index 958ac6d57..d010ab284 100644 --- a/benefits/core/context_processors.py +++ b/benefits/core/context_processors.py @@ -7,10 +7,15 @@ from . import models, session +def unique_values(original_list): + # dict.fromkeys gets the unique values and preserves order + return list(dict.fromkeys(original_list)) + + def _agency_context(agency): return { "eligibility_index_url": agency.eligibility_index_url, - "help_template": agency.help_template, + "help_templates": unique_values([v.help_template for v in agency.active_verifiers if v.help_template is not None]), "info_url": agency.info_url, "long_name": agency.long_name, "phone": agency.phone, @@ -64,6 +69,21 @@ def debug(request): return {"debug": session.context_dict(request)} +def enrollment(request): + """Context processor adds enrollment information to request context.""" + eligibility = session.eligibility(request) + expiry = session.enrollment_expiry(request) + reenrollment = session.enrollment_reenrollment(request) + + data = { + "expires": expiry, + "reenrollment": reenrollment, + "supports_expiration": eligibility.supports_expiration if eligibility else False, + } + + return {"enrollment": data} + + def origin(request): """Context processor adds session.origin to request context.""" origin = session.origin(request) diff --git a/benefits/core/migrations/0003_eligibilitytype_expiration.py b/benefits/core/migrations/0003_eligibilitytype_expiration.py new file mode 100644 index 000000000..2cf1a88e4 --- /dev/null +++ b/benefits/core/migrations/0003_eligibilitytype_expiration.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.2 on 2024-03-14 20:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0002_paymentprocessor_backoffice_api"), + ] + + operations = [ + migrations.AddField( + model_name="eligibilitytype", + name="expiration_days", + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="eligibilitytype", + name="expiration_reenrollment_days", + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name="eligibilitytype", + name="supports_expiration", + field=models.BooleanField(default=False), + ), + ] diff --git a/benefits/core/migrations/0004_alter_eligibilityverifier_display_order.py b/benefits/core/migrations/0004_alter_eligibilityverifier_display_order.py new file mode 100644 index 000000000..f455e8e24 --- /dev/null +++ b/benefits/core/migrations/0004_alter_eligibilityverifier_display_order.py @@ -0,0 +1,30 @@ +# Generated by Django 5.0.3 on 2024-03-19 20:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0003_eligibilitytype_expiration"), + ] + + # see https://django-admin-sortable2.readthedocs.io/en/latest/usage.html#initial-data + def set_initial_display_order(apps, schema_editor): + EligibilityVerifier = apps.get_model("core", "EligibilityVerifier") + for order, item in enumerate(EligibilityVerifier.objects.all(), 1): + item.display_order = order + item.save(update_fields=["display_order"]) + + operations = [ + migrations.AlterModelOptions( + name="eligibilityverifier", + options={"ordering": ["display_order"]}, + ), + migrations.AddField( + model_name="eligibilityverifier", + name="display_order", + field=models.PositiveSmallIntegerField(default=0), + ), + migrations.RunPython(set_initial_display_order, reverse_code=migrations.RunPython.noop), + ] diff --git a/benefits/core/migrations/0005_move_help_template_to_eligibilityverifier.py b/benefits/core/migrations/0005_move_help_template_to_eligibilityverifier.py new file mode 100644 index 000000000..8f26ac8ea --- /dev/null +++ b/benefits/core/migrations/0005_move_help_template_to_eligibilityverifier.py @@ -0,0 +1,22 @@ +# Generated by Django 5.0.3 on 2024-03-21 00:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0004_alter_eligibilityverifier_display_order"), + ] + + operations = [ + migrations.RemoveField( + model_name="transitagency", + name="help_template", + ), + migrations.AddField( + model_name="eligibilityverifier", + name="help_template", + field=models.TextField(null=True), + ), + ] diff --git a/benefits/core/migrations/0006_alter_allow_blank.py b/benefits/core/migrations/0006_alter_allow_blank.py new file mode 100644 index 000000000..e5745672c --- /dev/null +++ b/benefits/core/migrations/0006_alter_allow_blank.py @@ -0,0 +1,93 @@ +# Generated by Django 5.0.3 on 2024-03-25 19:48 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0005_move_help_template_to_eligibilityverifier"), + ] + + operations = [ + migrations.AlterField( + model_name="authprovider", + name="claim", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="authprovider", + name="scope", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="authprovider", + name="sign_out_button_template", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="authprovider", + name="sign_out_link_template", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="eligibilityverifier", + name="api_auth_header", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="eligibilityverifier", + name="api_url", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="eligibilityverifier", + name="auth_provider", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to="core.authprovider" + ), + ), + migrations.AlterField( + model_name="eligibilityverifier", + name="form_class", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="eligibilityverifier", + name="help_template", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="eligibilityverifier", + name="jwe_cek_enc", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="eligibilityverifier", + name="jwe_encryption_alg", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="eligibilityverifier", + name="jws_signing_alg", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="eligibilityverifier", + name="public_key", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name="+", to="core.pemdata" + ), + ), + migrations.AlterField( + model_name="eligibilityverifier", + name="start_template", + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name="pemdata", + name="remote_url", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/benefits/core/migrations/0007_eligibilitytype_enrollment_index_template.py b/benefits/core/migrations/0007_eligibilitytype_enrollment_index_template.py new file mode 100644 index 000000000..aa252003b --- /dev/null +++ b/benefits/core/migrations/0007_eligibilitytype_enrollment_index_template.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-03-25 22:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0006_alter_allow_blank"), + ] + + operations = [ + migrations.AddField( + model_name="eligibilitytype", + name="enrollment_index_template", + field=models.TextField(default="enrollment/index.html"), + ), + ] diff --git a/benefits/core/migrations/0008_eligibilityverifier_unverified_template.py b/benefits/core/migrations/0008_eligibilityverifier_unverified_template.py new file mode 100644 index 000000000..f8ac08403 --- /dev/null +++ b/benefits/core/migrations/0008_eligibilityverifier_unverified_template.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-04-24 21:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0007_eligibilitytype_enrollment_index_template"), + ] + + operations = [ + migrations.AddField( + model_name="eligibilityverifier", + name="unverified_template", + field=models.TextField(default="eligibility/unverified.html"), + ), + ] diff --git a/benefits/core/migrations/0009_eligibilitytype_reenrollment_error_template.py b/benefits/core/migrations/0009_eligibilitytype_reenrollment_error_template.py new file mode 100644 index 000000000..d0abd30cf --- /dev/null +++ b/benefits/core/migrations/0009_eligibilitytype_reenrollment_error_template.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.3 on 2024-05-01 19:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0008_eligibilityverifier_unverified_template"), + ] + + operations = [ + migrations.AddField( + model_name="eligibilitytype", + name="reenrollment_error_template", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/benefits/core/migrations/0010_alter_secret_name_field_blank.py b/benefits/core/migrations/0010_alter_secret_name_field_blank.py new file mode 100644 index 000000000..df79de52c --- /dev/null +++ b/benefits/core/migrations/0010_alter_secret_name_field_blank.py @@ -0,0 +1,29 @@ +# Generated by Django 5.0.3 on 2024-05-13 19:57 + +import benefits.core.models +import benefits.secrets +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0009_eligibilitytype_reenrollment_error_template"), + ] + + operations = [ + migrations.AlterField( + model_name="eligibilityverifier", + name="api_auth_key_secret_name", + field=benefits.core.models.SecretNameField( + blank=True, max_length=127, null=True, validators=[benefits.secrets.SecretNameValidator()] + ), + ), + migrations.AlterField( + model_name="pemdata", + name="text_secret_name", + field=benefits.core.models.SecretNameField( + blank=True, max_length=127, null=True, validators=[benefits.secrets.SecretNameValidator()] + ), + ), + ] diff --git a/benefits/core/migrations/local_fixtures.json b/benefits/core/migrations/local_fixtures.json index 1e51dad7a..05abb266e 100644 --- a/benefits/core/migrations/local_fixtures.json +++ b/benefits/core/migrations/local_fixtures.json @@ -87,7 +87,8 @@ "fields": { "name": "courtesy_card", "label": "(MST) Courtesy Card Discount", - "group_id": "group123" + "group_id": "group123", + "enrollment_index_template": "enrollment/index--agency-card.html" } }, { @@ -114,6 +115,16 @@ "fields": { "name": "mobility_pass", "label": "(SBMTD) Mobility Pass Discount", + "group_id": "group123", + "enrollment_index_template": "enrollment/index--agency-card.html" + } + }, + { + "model": "core.eligibilitytype", + "pk": 7, + "fields": { + "name": "calfresh", + "label": "CalFresh", "group_id": "group123" } }, @@ -122,6 +133,7 @@ "pk": 1, "fields": { "name": "(MST) oauth claims via Login.gov", + "display_order": 1, "active": true, "api_url": null, "api_auth_header": null, @@ -142,6 +154,7 @@ "pk": 2, "fields": { "name": "(MST) VA.gov - veteran", + "display_order": 3, "active": true, "api_url": null, "api_auth_header": null, @@ -162,6 +175,7 @@ "pk": 3, "fields": { "name": "(MST) eligibility server verifier", + "display_order": 4, "active": true, "api_url": "http://server:8000/verify", "api_auth_header": "X-Server-API-Key", @@ -174,7 +188,9 @@ "auth_provider": null, "selection_label_template": "eligibility/includes/selection-label--mst-courtesy-card.html", "start_template": "eligibility/start--mst-courtesy-card.html", - "form_class": "benefits.eligibility.forms.MSTCourtesyCard" + "form_class": "benefits.eligibility.forms.MSTCourtesyCard", + "unverified_template": "eligibility/unverified--mst-courtesy-card.html", + "help_template": "core/includes/help--mst-courtesy-card.html" } }, { @@ -182,6 +198,7 @@ "pk": 4, "fields": { "name": "(SacRT) oauth claims via Login.gov", + "display_order": 5, "active": false, "api_url": null, "api_auth_header": null, @@ -202,6 +219,7 @@ "pk": 5, "fields": { "name": "(SBMTD) oauth claims via Login.gov", + "display_order": 6, "active": false, "api_url": null, "api_auth_header": null, @@ -222,6 +240,7 @@ "pk": 6, "fields": { "name": "(SBMTD) eligibility server verifier", + "display_order": 7, "active": true, "api_url": "http://server:8000/verify", "api_auth_header": "X-Server-API-Key", @@ -234,7 +253,31 @@ "auth_provider": null, "selection_label_template": "eligibility/includes/selection-label--sbmtd-mobility-pass.html", "start_template": "eligibility/start--sbmtd-mobility-pass.html", - "form_class": "benefits.eligibility.forms.SBMTDMobilityPass" + "form_class": "benefits.eligibility.forms.SBMTDMobilityPass", + "unverified_template": "eligibility/unverified--sbmtd-mobility-pass.html", + "help_template": "core/includes/help--sbmtd-mobility-pass.html" + } + }, + { + "model": "core.eligibilityverifier", + "pk": 7, + "fields": { + "name": "(MST) CalFresh oauth claims via Login.gov", + "display_order": 2, + "active": true, + "api_url": null, + "api_auth_header": null, + "api_auth_key_secret_name": null, + "eligibility_type": 7, + "public_key": null, + "jwe_cek_enc": null, + "jwe_encryption_alg": null, + "jws_signing_alg": null, + "auth_provider": 1, + "selection_label_template": "eligibility/includes/selection-label--calfresh.html", + "start_template": "eligibility/start--calfresh.html", + "form_class": null, + "help_template": "core/includes/help--calfresh.html" } }, { @@ -298,9 +341,8 @@ "index_template": "core/index--mst.html", "eligibility_index_template": "eligibility/index--mst.html", "enrollment_success_template": "enrollment/success--mst.html", - "help_template": "core/includes/help--mst.html", - "eligibility_types": [1, 2, 3], - "eligibility_verifiers": [1, 2, 3] + "eligibility_types": [1, 7, 2, 3], + "eligibility_verifiers": [1, 7, 2, 3] } }, { @@ -322,7 +364,6 @@ "index_template": "core/index--sacrt.html", "eligibility_index_template": "eligibility/index--sacrt.html", "enrollment_success_template": "enrollment/success--sacrt.html", - "help_template": null, "eligibility_types": [4], "eligibility_verifiers": [4] } @@ -346,7 +387,6 @@ "index_template": "core/index--sbmtd.html", "eligibility_index_template": "eligibility/index--sbmtd.html", "enrollment_success_template": "enrollment/success--sbmtd.html", - "help_template": "core/includes/help--sbmtd.html", "eligibility_types": [5, 6], "eligibility_verifiers": [5, 6] } diff --git a/benefits/core/models.py b/benefits/core/models.py index 72561c93a..22abac492 100644 --- a/benefits/core/models.py +++ b/benefits/core/models.py @@ -7,6 +7,7 @@ import logging from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse @@ -36,8 +37,6 @@ def __init__(self, *args, **kwargs): # although the validator also checks for a max length of 127 # this setting enforces the length at the database column level as well kwargs["max_length"] = 127 - # similar to max_length, enforce at the field (form) validation level to not allow blanks - kwargs["blank"] = False # the default is False, but this is more explicit kwargs["allow_unicode"] = False super().__init__(*args, **kwargs) @@ -50,9 +49,9 @@ class PemData(models.Model): # Human description of the PEM data label = models.TextField() # The name of a secret with data in utf-8 encoded PEM text format - text_secret_name = SecretNameField(null=True) + text_secret_name = SecretNameField(null=True, blank=True) # Public URL hosting the utf-8 encoded PEM text - remote_url = models.TextField(null=True) + remote_url = models.TextField(null=True, blank=True) def __str__(self): return self.label @@ -80,13 +79,13 @@ class AuthProvider(models.Model): """An entity that provides authentication for eligibility verifiers.""" id = models.AutoField(primary_key=True) - sign_out_button_template = models.TextField(null=True) - sign_out_link_template = models.TextField(null=True) + sign_out_button_template = models.TextField(null=True, blank=True) + sign_out_link_template = models.TextField(null=True, blank=True) client_name = models.TextField() client_id_secret_name = SecretNameField() authority = models.TextField() - scope = models.TextField(null=True) - claim = models.TextField(null=True) + scope = models.TextField(null=True, blank=True) + claim = models.TextField(null=True, blank=True) scheme = models.TextField() @property @@ -101,6 +100,9 @@ def supports_sign_out(self): def client_id(self): return get_secret_by_name(self.client_id_secret_name) + def __str__(self) -> str: + return self.client_name + class EligibilityType(models.Model): """A single conditional eligibility type.""" @@ -109,6 +111,11 @@ class EligibilityType(models.Model): name = models.TextField() label = models.TextField() group_id = models.TextField() + supports_expiration = models.BooleanField(default=False) + expiration_days = models.PositiveSmallIntegerField(null=True, blank=True) + expiration_reenrollment_days = models.PositiveSmallIntegerField(null=True, blank=True) + enrollment_index_template = models.TextField(default="enrollment/index.html") + reenrollment_error_template = models.TextField(null=True, blank=True) def __str__(self): return self.label @@ -132,30 +139,55 @@ def get_names(eligibility_types): eligibility_types = [eligibility_types] return [t.name for t in eligibility_types if isinstance(t, EligibilityType)] + def clean(self): + supports_expiration = self.supports_expiration + expiration_days = self.expiration_days + expiration_reenrollment_days = self.expiration_reenrollment_days + reenrollment_error_template = self.reenrollment_error_template + + if supports_expiration: + errors = {} + message = "When support_expiration is True, this value must be greater than 0." + if expiration_days is None or expiration_days <= 0: + errors.update(expiration_days=ValidationError(message)) + if expiration_reenrollment_days is None or expiration_reenrollment_days <= 0: + errors.update(expiration_reenrollment_days=ValidationError(message)) + if reenrollment_error_template is None: + errors.update(reenrollment_error_template=ValidationError("Required when supports expiration is True.")) + + if errors: + raise ValidationError(errors) + class EligibilityVerifier(models.Model): """An entity that verifies eligibility.""" id = models.AutoField(primary_key=True) name = models.TextField() + display_order = models.PositiveSmallIntegerField(default=0, blank=False, null=False) active = models.BooleanField(default=False) - api_url = models.TextField(null=True) - api_auth_header = models.TextField(null=True) - api_auth_key_secret_name = SecretNameField(null=True) + api_url = models.TextField(null=True, blank=True) + api_auth_header = models.TextField(null=True, blank=True) + api_auth_key_secret_name = SecretNameField(null=True, blank=True) eligibility_type = models.ForeignKey(EligibilityType, on_delete=models.PROTECT) # public key is used to encrypt requests targeted at this Verifier and to verify signed responses from this verifier - public_key = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT, null=True) + public_key = models.ForeignKey(PemData, related_name="+", on_delete=models.PROTECT, null=True, blank=True) # The JWE-compatible Content Encryption Key (CEK) key-length and mode - jwe_cek_enc = models.TextField(null=True) + jwe_cek_enc = models.TextField(null=True, blank=True) # The JWE-compatible encryption algorithm - jwe_encryption_alg = models.TextField(null=True) + jwe_encryption_alg = models.TextField(null=True, blank=True) # The JWS-compatible signing algorithm - jws_signing_alg = models.TextField(null=True) - auth_provider = models.ForeignKey(AuthProvider, on_delete=models.PROTECT, null=True) + jws_signing_alg = models.TextField(null=True, blank=True) + auth_provider = models.ForeignKey(AuthProvider, on_delete=models.PROTECT, null=True, blank=True) selection_label_template = models.TextField() - start_template = models.TextField(null=True) + start_template = models.TextField(null=True, blank=True) # reference to a form class used by this Verifier, e.g. benefits.app.forms.FormClass - form_class = models.TextField(null=True) + form_class = models.TextField(null=True, blank=True) + unverified_template = models.TextField(default="eligibility/unverified.html") + help_template = models.TextField(null=True, blank=True) + + class Meta: + ordering = ["display_order"] def __str__(self): return self.name @@ -245,7 +277,6 @@ class TransitAgency(models.Model): index_template = models.TextField() eligibility_index_template = models.TextField() enrollment_success_template = models.TextField() - help_template = models.TextField(null=True) def __str__(self): return self.long_name @@ -299,6 +330,11 @@ def public_key_data(self): """This Agency's public key as a string.""" return self.public_key.data + @property + def active_verifiers(self): + """This Agency's eligibility verifiers that are active.""" + return self.eligibility_verifiers.filter(active=True) + @staticmethod def by_id(id): """Get a TransitAgency instance by its ID.""" diff --git a/benefits/core/session.py b/benefits/core/session.py index 5582e5a29..6d791bfb0 100644 --- a/benefits/core/session.py +++ b/benefits/core/session.py @@ -2,6 +2,7 @@ The core application: helpers to work with request sessions. """ +from datetime import datetime, timedelta, timezone import hashlib import logging import time @@ -20,7 +21,8 @@ _DID = "did" _ELIGIBILITY = "eligibility" _ENROLLMENT_TOKEN = "enrollment_token" -_ENROLLMENT_TOKEN_EXP = "enrollment_token_exp" +_ENROLLMENT_TOKEN_EXP = "enrollment_token_expiry" +_ENROLLMENT_EXP = "enrollment_expiry" _LANG = "lang" _OAUTH_CLAIM = "oauth_claim" _OAUTH_TOKEN = "oauth_token" @@ -32,29 +34,26 @@ def agency(request): """Get the agency from the request's session, or None""" - logger.debug("Get session agency") try: return models.TransitAgency.by_id(request.session[_AGENCY]) except (KeyError, models.TransitAgency.DoesNotExist): - logger.debug("Can't get agency from session") return None def active_agency(request): """True if the request's session is configured with an active agency. False otherwise.""" - logger.debug("Get session active agency flag") a = agency(request) return a and a.active def context_dict(request): """The request's session context as a dict.""" - logger.debug("Get session context dict") return { _AGENCY: agency(request).slug if active_agency(request) else None, _DEBUG: debug(request), _DID: did(request), _ELIGIBILITY: eligibility(request), + _ENROLLMENT_EXP: enrollment_expiry(request), _ENROLLMENT_TOKEN: enrollment_token(request), _ENROLLMENT_TOKEN_EXP: enrollment_token_expiry(request), _LANG: language(request), @@ -69,7 +68,6 @@ def context_dict(request): def debug(request): """Get the DEBUG flag from the request's session.""" - logger.debug("Get session debug flag") return bool(request.session.get(_DEBUG, False)) @@ -84,7 +82,6 @@ def did(request): See more: https://help.amplitude.com/hc/en-us/articles/115003135607-Track-unique-users-in-Amplitude """ - logger.debug("Get session did") d = request.session.get(_DID) if not d: reset(request) @@ -94,7 +91,6 @@ def did(request): def eligibility(request): """Get the confirmed models.EligibilityType from the request's session, or None""" - logger.debug("Get session confirmed eligibility") eligibility = request.session.get(_ELIGIBILITY) if eligibility: return models.EligibilityType.get(eligibility) @@ -104,41 +100,52 @@ def eligibility(request): def eligible(request): """True if the request's session is configured with an active agency and has confirmed eligibility. False otherwise.""" - logger.debug("Get session eligible flag") return active_agency(request) and agency(request).supports_type(eligibility(request)) +def enrollment_expiry(request): + """Get the expiry date for a user's enrollment from session, or None.""" + expiry = request.session.get(_ENROLLMENT_EXP) + if expiry: + return datetime.fromtimestamp(expiry, tz=timezone.utc) + else: + return None + + +def enrollment_reenrollment(request): + """Get the reenrollment date for a user's enrollment from session, or None.""" + expiry = enrollment_expiry(request) + elig = eligibility(request) + + if elig and elig.supports_expiration and expiry: + return expiry - timedelta(days=elig.expiration_reenrollment_days) + else: + return None + + def enrollment_token(request): """Get the enrollment token from the request's session, or None.""" - logger.debug("Get session enrollment token") return request.session.get(_ENROLLMENT_TOKEN) def enrollment_token_expiry(request): """Get the enrollment token's expiry time from the request's session, or None.""" - logger.debug("Get session enrollment token expiry") return request.session.get(_ENROLLMENT_TOKEN_EXP) def enrollment_token_valid(request): """True if the request's session is configured with a valid token. False otherwise.""" if bool(enrollment_token(request)): - logger.debug("Session contains an enrollment token") exp = enrollment_token_expiry(request) - # ensure token does not expire in the next 5 seconds valid = exp is None or exp > (time.time() + 5) - - logger.debug(f"Session enrollment token is {'valid' if valid else 'expired'}") return valid else: - logger.debug("Session does not contain a valid enrollment token") return False def language(request): """Get the language configured for the request.""" - logger.debug("Get session language") return request.LANGUAGE_CODE @@ -154,19 +161,16 @@ def logout(request): def oauth_token(request): """Get the oauth token from the request's session, or None""" - logger.debug("Get session oauth token") return request.session.get(_OAUTH_TOKEN) def oauth_claim(request): """Get the oauth claim from the request's session, or None""" - logger.debug("Get session oauth claim") return request.session.get(_OAUTH_CLAIM) def origin(request): """Get the origin for the request's session, or the default core:index.""" - logger.debug("Get session origin") return request.session.get(_ORIGIN, reverse("core:index")) @@ -176,6 +180,7 @@ def reset(request): request.session[_AGENCY] = None request.session[_ELIGIBILITY] = None request.session[_ORIGIN] = reverse("core:index") + request.session[_ENROLLMENT_EXP] = None request.session[_ENROLLMENT_TOKEN] = None request.session[_ENROLLMENT_TOKEN_EXP] = None request.session[_OAUTH_TOKEN] = None @@ -201,7 +206,6 @@ def start(request): See more: https://help.amplitude.com/hc/en-us/articles/115002323627-Tracking-Sessions """ - logger.debug("Get session time") s = request.session.get(_START) if not s: reset(request) @@ -223,7 +227,6 @@ def uid(request): here a value is set on anonymous users anyway, as the users never sign-in and become de-anonymized to this app / Amplitude. """ - logger.debug("Get session uid") u = request.session.get(_UID) if not u: reset(request) @@ -236,6 +239,7 @@ def update( agency=None, debug=None, eligibility_types=None, + enrollment_expiry=None, enrollment_token=None, enrollment_token_exp=None, oauth_token=None, @@ -245,13 +249,10 @@ def update( ): """Update the request's session with non-null values.""" if agency is not None and isinstance(agency, models.TransitAgency): - logger.debug(f"Update session {_AGENCY}") request.session[_AGENCY] = agency.id if debug is not None: - logger.debug(f"Update session {_DEBUG}") request.session[_DEBUG] = debug if eligibility_types is not None and isinstance(eligibility_types, list): - logger.debug(f"Update session {_ELIGIBILITY}") if len(eligibility_types) > 1: raise NotImplementedError("Multiple eligibilities are not supported at this time.") elif len(eligibility_types) == 1: @@ -262,29 +263,31 @@ def update( else: # empty list, clear session eligibility request.session[_ELIGIBILITY] = None + if isinstance(enrollment_expiry, datetime): + if enrollment_expiry.tzinfo is None or enrollment_expiry.tzinfo.utcoffset(enrollment_expiry) is None: + # this is a naive datetime instance, update tzinfo for UTC + # see notes under https://docs.python.org/3/library/datetime.html#datetime.datetime.timestamp + # > There is no method to obtain the POSIX timestamp directly from a naive datetime instance representing UTC time. + # > If your application uses this convention and your system timezone is not set to UTC, you can obtain the POSIX + # > timestamp by supplying tzinfo=timezone.utc + enrollment_expiry = enrollment_expiry.replace(tzinfo=timezone.utc) + request.session[_ENROLLMENT_EXP] = enrollment_expiry.timestamp() if enrollment_token is not None: - logger.debug(f"Update session {_ENROLLMENT_TOKEN}") request.session[_ENROLLMENT_TOKEN] = enrollment_token request.session[_ENROLLMENT_TOKEN_EXP] = enrollment_token_exp if oauth_token is not None: - logger.debug(f"Update session {_OAUTH_TOKEN}") request.session[_OAUTH_TOKEN] = oauth_token if oauth_claim is not None: - logger.debug(f"Update session {_OAUTH_CLAIM}") request.session[_OAUTH_CLAIM] = oauth_claim if origin is not None: - logger.debug(f"Update session {_ORIGIN}") request.session[_ORIGIN] = origin if verifier is not None and isinstance(verifier, models.EligibilityVerifier): - logger.debug(f"Update session {_VERIFIER}") request.session[_VERIFIER] = verifier.id def verifier(request): """Get the verifier from the request's session, or None""" - logger.debug("Get session verifier") try: return models.EligibilityVerifier.by_id(request.session[_VERIFIER]) except (KeyError, models.EligibilityVerifier.DoesNotExist): - logger.debug("Can't get verifier from session") return None diff --git a/benefits/core/templates/core/base.html b/benefits/core/templates/core/base.html index 31373e292..282014196 100644 --- a/benefits/core/templates/core/base.html +++ b/benefits/core/templates/core/base.html @@ -44,7 +44,9 @@ {% endif %}