diff --git a/.ds.baseline b/.ds.baseline index 3d1881f23..4dafc3f2d 100644 --- a/.ds.baseline +++ b/.ds.baseline @@ -151,7 +151,7 @@ "filename": "app/config.py", "hashed_secret": "577a4c667e4af8682ca431857214b3a920883efc", "is_verified": false, - "line_number": 118, + "line_number": 120, "is_secret": false } ], @@ -674,5 +674,5 @@ } ] }, - "generated_at": "2025-03-20T18:22:36Z" + "generated_at": "2025-04-10T19:38:31Z" } diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index d473eceae..7f4b18ff5 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -167,6 +167,7 @@ jobs: run: make run-flask & env: NOTIFY_ENVIRONMENT: scanning + FEATURE_SOCKET_ENABLED: true - name: Run OWASP Baseline Scan uses: zaproxy/action-baseline@v0.14.0 with: diff --git a/.gitignore b/.gitignore index 7e07fc102..09973207f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ *.XLSX ## Non user files allowed to be commited +!app/assets/pdf/wa_case_study.pdf !app/assets/pdf/best-practices-for-texting-the-public.pdf !app/assets/pdf/investing-in-notifications-tts-public-benefits-studio-decision-memo.pdf !app/assets/pdf/best-practices-section-outline.pdf diff --git a/Makefile b/Makefile index 30eed146d..bd62ff14c 100644 --- a/Makefile +++ b/Makefile @@ -69,7 +69,7 @@ generate-version-file: ## Generates the app version file @echo -e "__git_commit__ = \"${GIT_COMMIT}\"\n__time__ = \"${DATE}\"" > ${APP_VERSION_FILE} .PHONY: test -test: py-lint py-test js-lint js-test ## Run tests +test: py-lint py-test js-test ## Run tests .PHONY: py-lint py-lint: ## Run python linting scanners and black @@ -99,7 +99,7 @@ too-complex: py-test: export NEW_RELIC_ENVIRONMENT=test py-test: ## Run python unit tests poetry run coverage run -m pytest --maxfail=10 --ignore=tests/end_to_end tests/ - poetry run coverage report --fail-under=96 + poetry run coverage report --fail-under=93 poetry run coverage html -d .coverage_cache .PHONY: dead-code diff --git a/app/__init__.py b/app/__init__.py index f1c1d5fe4..48e122bd8 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -109,6 +109,7 @@ from app.notify_client.template_statistics_api_client import template_statistics from app.notify_client.upload_api_client import upload_api_client from app.notify_client.user_api_client import user_api_client from app.url_converters import SimpleDateTypeConverter, TemplateTypeConverter +from app.utils.api_health import is_api_down from app.utils.govuk_frontend_jinja.flask_ext import init_govuk_frontend from notifications_utils import logging, request_helper from notifications_utils.formatters import ( @@ -158,11 +159,14 @@ def _csp(config): "https://www.googletagmanager.com", "https://www.google-analytics.com", "https://dap.digitalgov.gov", + "https://cdn.socket.io", ], "connect-src": [ "'self'", "https://gov-bam.nr-data.net", "https://www.google-analytics.com", + "http://localhost:6011", + "ws://localhost:6011", ], "style-src": ["'self'", asset_domain], "img-src": ["'self'", asset_domain, logo_domain], @@ -173,17 +177,22 @@ def create_app(application): @application.after_request def add_csp_header(response): existing_csp = response.headers.get("Content-Security-Policy", "") - response.headers["Content-Security-Policy"] = existing_csp + "; form-action 'self';" + response.headers["Content-Security-Policy"] = ( + existing_csp + "; form-action 'self';" + ) return response - # @application.context_processor - # def inject_feature_flags(): - # this is where feature flags can be easily added as a dictionary within context - # feature_about_page_enabled = application.config.get( - # "FEATURE_ABOUT_PAGE_ENABLED", False - # ) - # return dict( - # FEATURE_ABOUT_PAGE_ENABLED=feature_about_page_enabled, - # ) + + @application.context_processor + def inject_feature_flags(): + # this is where feature flags can be easily added as a dictionary within context + feature_socket_enabled = application.config.get("FEATURE_SOCKET_ENABLED", False) + return dict( + FEATURE_SOCKET_ENABLED=feature_socket_enabled, + ) + + @application.context_processor + def inject_is_api_down(): + return {"is_api_down": is_api_down()} @application.context_processor def inject_initial_signin_url(): diff --git a/app/assets/images/api-error.svg b/app/assets/images/api-error.svg new file mode 100644 index 000000000..4a01f9b0c --- /dev/null +++ b/app/assets/images/api-error.svg @@ -0,0 +1 @@ + diff --git a/app/assets/javascripts/fileUpload.js b/app/assets/javascripts/fileUpload.js index ea6c7ac62..19c53b971 100644 --- a/app/assets/javascripts/fileUpload.js +++ b/app/assets/javascripts/fileUpload.js @@ -1,34 +1,69 @@ +function announceUploadStatusFromElement() { + const srRegion = document.getElementById('upload-status-live'); + const success = document.getElementById('upload-success'); + const error = document.getElementById('upload-error'); + + if (!srRegion) return; + + const message = error?.textContent || success?.textContent; + + if (message) { + srRegion.textContent = ''; + setTimeout(() => { + srRegion.textContent = message + '\u00A0'; // add a non-breaking space + srRegion.focus(); // Optional + }, 300); + } +} + + +// Exported for use in tests +function initUploadStatusAnnouncer() { + document.addEventListener('DOMContentLoaded', () => { + announceUploadStatusFromElement(); + }); +} + (function(Modules) { "use strict"; Modules.FileUpload = function() { - this.submit = () => this.$form.trigger('submit'); - this.showCancelButton = () => $('.file-upload-button', this.$form).replaceWith(` - Cancel upload - `); - - this.start = function(component) { - - this.$form = $(component); - - // The label gets styled like a button and is used to hide the native file upload control. This is so that - // users see a button that looks like the others on the site. - - this.$form.find('label.file-upload-button').addClass('usa-button margin-bottom-1').attr( {role: 'button', tabindex: '0'} ); - - // Clear the form if the user navigates back to the page - $(window).on("pageshow", () => this.$form[0].reset()); - - // Need to put the event on the container, not the input for it to work properly - this.$form.on( - 'change', '.file-upload-field', - () => this.submit() && this.showCancelButton() - ); - + this.showCancelButton = () => { + $('.file-upload-button', this.$form).replaceWith(` + + `); }; - }; + this.start = function(component) { + this.$form = $(component); + this.$form.on('click', '[data-module="upload-trigger"]', function () { + const inputId = $(this).data('file-input-id'); + const fileInput = document.getElementById(inputId); + if (fileInput) fileInput.click(); + }); + + $(window).on("pageshow", () => this.$form[0].reset()); + + this.$form.on('change', '.file-upload-field', () => { + this.submit(); + this.showCancelButton(); + }); + }; + }; })(window.GOVUK.Modules); + +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + announceUploadStatusFromElement, + initUploadStatusAnnouncer + }; +} + +if (typeof window !== 'undefined') { + initUploadStatusAnnouncer(); +} diff --git a/app/assets/javascripts/fullscreenTable.js b/app/assets/javascripts/fullscreenTable.js index cef792052..d4062e71c 100644 --- a/app/assets/javascripts/fullscreenTable.js +++ b/app/assets/javascripts/fullscreenTable.js @@ -22,7 +22,6 @@ this.$scrollableTable .on('scroll', this.toggleShadows) .on('scroll', this.maintainHeight) - .on('focus blur', () => this.$component.toggleClass('js-focus-style')); if ( window.GOVUK.stickAtBottomWhenScrolling && @@ -37,11 +36,11 @@ this.insertShims = () => { - const attributesForFocus = 'role aria-labelledby tabindex'; + const attributesForFocus = 'role aria-labelledby'; let captionId = this.$table.find('caption').text().toLowerCase().replace(/[^A-Za-z]+/g, ''); this.$table.find('caption').attr('id', captionId); - this.$table.wrap(`
`); + this.$table.wrap(``); this.$component .append( diff --git a/app/assets/javascripts/socketio.js b/app/assets/javascripts/socketio.js new file mode 100644 index 000000000..46d18c7d4 --- /dev/null +++ b/app/assets/javascripts/socketio.js @@ -0,0 +1,75 @@ +function debounce(func, wait) { + let timeout; + return function (...args) { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(this, args), wait); + }; +} + +document.addEventListener('DOMContentLoaded', function () { + const isJobPage = window.location.pathname.includes('/jobs/'); + if (!isJobPage) return; + + const jobEl = document.querySelector('[data-job-id]'); + const jobId = jobEl?.dataset?.jobId; + const featureEnabled = jobEl?.dataset?.feature === 'true'; + const apiHost = jobEl?.dataset?.host; + + if (!jobId) return; + + if (featureEnabled) { + const socket = io(apiHost); + + socket.on('connect', () => { + socket.emit('join', { room: `job-${jobId}` }); + }); + + window.addEventListener('beforeunload', () => { + socket.emit('leave', { room: `job-${jobId}` }); + }); + + const debouncedUpdate = debounce((data) => { + updateAllJobSections(); + }, 1000); + + socket.on('job_updated', (data) => { + if (data.job_id !== jobId) return; + debouncedUpdate(data); + }); + } + + function updateAllJobSections() { + const resourceEl = document.querySelector('[data-socket-update="status"]'); + const url = resourceEl?.dataset?.resource; + + if (!url) { + console.warn('No resource URL found for job updates'); + return; + } + + fetch(url) + .then((res) => res.json()) + .then(({ status, counts, notifications }) => { + const sections = { + status: document.querySelector('[data-socket-update="status"]'), + counts: document.querySelector('[data-socket-update="counts"]'), + notifications: document.querySelector( + '[data-socket-update="notifications"]' + ), + }; + + if (status && sections.status) { + sections.status.innerHTML = status; + } + if (counts && sections.counts) { + sections.counts.innerHTML = counts; + } + if (notifications && sections.notifications) { + sections.notifications.innerHTML = notifications; + } + }) + .catch((err) => { + console.error('Error fetching job update partials:', err); + }); + } +}); diff --git a/app/assets/pdf/wa_case_study.pdf b/app/assets/pdf/wa_case_study.pdf new file mode 100644 index 000000000..46181ce0a Binary files /dev/null and b/app/assets/pdf/wa_case_study.pdf differ diff --git a/app/assets/sass/uswds/_legacy-styles.scss b/app/assets/sass/uswds/_main.scss similarity index 96% rename from app/assets/sass/uswds/_legacy-styles.scss rename to app/assets/sass/uswds/_main.scss index 85752c0af..88e44042d 100644 --- a/app/assets/sass/uswds/_legacy-styles.scss +++ b/app/assets/sass/uswds/_main.scss @@ -350,3 +350,16 @@ h2.recipient-list { height: 40px; margin-top: units(1); } + +// Button ellipses loading +.dot-anim::after { + content: '.'; + animation: dotPulse 1.5s steps(3, end) infinite; +} + +@keyframes dotPulse { + 0% { content: ''; } + 33% { content: '.'; } + 66% { content: '..'; } + 100% { content: '...'; } +} diff --git a/app/assets/sass/uswds/_uswds-theme-custom-styles.scss b/app/assets/sass/uswds/_uswds-theme-custom-styles.scss index ef50b9394..31cbeb3fa 100644 --- a/app/assets/sass/uswds/_uswds-theme-custom-styles.scss +++ b/app/assets/sass/uswds/_uswds-theme-custom-styles.scss @@ -289,6 +289,7 @@ td.table-empty-message { margin-top: units(1); width: 100%; border: 1px solid color('gray-60'); + height: 40px; } } diff --git a/app/assets/sass/uswds/styles.scss b/app/assets/sass/uswds/styles.scss index 304e2f346..ce6adcbbb 100644 --- a/app/assets/sass/uswds/styles.scss +++ b/app/assets/sass/uswds/styles.scss @@ -1,5 +1,5 @@ @forward "uswds-theme"; @forward "uswds"; @forward "uswds-theme-custom-styles"; -@forward "legacy-styles"; +@forward "main"; @forward "data-visualization"; diff --git a/app/config.py b/app/config.py index dece2728d..20381e499 100644 --- a/app/config.py +++ b/app/config.py @@ -88,6 +88,8 @@ class Config(object): ], } + FEATURE_SOCKET_ENABLED = getenv("FEATURE_SOCKET_ENABLED", "false") == "true" + def _s3_credentials_from_env(bucket_prefix): return { diff --git a/app/main/views/index.py b/app/main/views/index.py index ab11ba8c2..ad324a8fc 100644 --- a/app/main/views/index.py +++ b/app/main/views/index.py @@ -1,3 +1,5 @@ +import logging + from flask import ( abort, current_app, @@ -19,6 +21,8 @@ from app.main.views.sub_navigation_dictionaries import ( ) from app.utils.user import user_is_logged_in +logger = logging.getLogger(__name__) + # Hook to check for feature flags @main.before_request @@ -33,7 +37,7 @@ def check_feature_flags(): @main.route("/test/feature-flags") def test_feature_flags(): return jsonify( - {"FEATURE_ABOUT_PAGE_ENABLED": current_app.config["FEATURE_ABOUT_PAGE_ENABLED"]} + {"FEATURE_SOCKET_ENABLED": current_app.config["FEATURE_SOCKET_ENABLED"]} ) @@ -45,7 +49,7 @@ def index(): return render_template( "views/signedout.html", sms_rate=CURRENT_SMS_RATE, - counts=status_api_client.get_count_of_live_services_and_organizations(), + counts=status_api_client.get_count_of_live_services_and_organizations() ) diff --git a/app/main/views/jobs.py b/app/main/views/jobs.py index 86ce0bce6..e2cd4c7ce 100644 --- a/app/main/views/jobs.py +++ b/app/main/views/jobs.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- - +import os from functools import partial from flask import ( Response, abort, + current_app, jsonify, redirect, render_template, @@ -57,8 +58,12 @@ def view_job(service_id, job_id): filter_args = parse_filter_args(request.args) filter_args["status"] = set_status_filters(filter_args) + api_host_name = os.environ.get('API_HOST_NAME') + return render_template( "views/jobs/job.html", + api_host_name=api_host_name, + FEATURE_SOCKET_ENABLED=current_app.config["FEATURE_SOCKET_ENABLED"], job=job, status=request.args.get("status", ""), updates_url=url_for( diff --git a/app/main/views/send.py b/app/main/views/send.py index e3b3ed0a9..418446f52 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -481,6 +481,7 @@ def send_one_off_step(service_id, template_id, step_index): ), template=template, form=form, + current_placeholder=current_placeholder, skip_link=get_skip_link(step_index, template), back_link=back_link, link_to_upload=( diff --git a/app/templates/base.html b/app/templates/base.html index f482d8f37..c75fae406 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -11,6 +11,12 @@ {% include "components/head.html" %} +Notify.gov Service Ending
-- GSA will no longer offer the Notify.gov service after June 8th, 2025. Visit - Notify.gov Service Ending for more information. -
+ {% if not is_api_down %} + {% if current_user.is_authenticated or current_service or current_user.platform_admin %} +Notify.gov Service Ending
++ GSA will no longer offer the Notify.gov service after June 8th, 2025. Visit + Notify.gov Service Ending for more information. +
+Notify.gov Service Ending
-- Notify.gov is no longer accepting new partners. -
+ + {% else %} +Notify.gov Service Ending
++ Notify.gov is no longer accepting new partners. +
+