diff --git a/.ds.baseline b/.ds.baseline index 2cdf590d7..91907a154 100644 --- a/.ds.baseline +++ b/.ds.baseline @@ -633,5 +633,5 @@ } ] }, - "generated_at": "2025-03-19T03:23:37Z" + "generated_at": "2025-05-01T13:44:35Z" } diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 2034321a6..17de8332f 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -166,7 +166,7 @@ jobs: run: make run-flask & env: NOTIFY_ENVIRONMENT: scanning - FEATURE_ABOUT_PAGE_ENABLED: true + FEATURE_SOCKET_ENABLED: true - name: Run OWASP Baseline Scan uses: zaproxy/action-baseline@v0.14.0 with: diff --git a/.gitignore b/.gitignore index 62f46925c..09973207f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,11 @@ *.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 +!app/assets/pdf/standing-up-your-own-notify.pdf !app/assets/pdf/tcpa_overview.pdf !app/assets/pdf/investing-notifications-tts-public-benefits-memo.pdf !app/assets/pdf/out-of-pilot-announcement.pdf diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb3c48cae..579650135 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,6 +8,7 @@ repos: - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files + exclude: ^app/assets/pdf/.*\.pdf$ - id: debug-statements - id: check-merge-conflict - id: check-toml diff --git a/Makefile b/Makefile index 8fa5364a0..bd62ff14c 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ APP_VERSION_FILE = app/version.py GIT_BRANCH ?= $(shell git symbolic-ref --short HEAD 2> /dev/null || echo "detached") GIT_COMMIT ?= $(shell git rev-parse HEAD 2> /dev/null || echo "") +GIT_HOOKS_PATH ?= $(shell git config --global core.hooksPath || echo "") VIRTUALENV_ROOT := $(shell [ -z $$VIRTUAL_ENV ] && echo $$(pwd)/venv || echo $$VIRTUAL_ENV) @@ -14,7 +15,8 @@ NVMSH := $(shell [ -f "$(HOME)/.nvm/nvm.sh" ] && echo "$(HOME)/.nvm/nvm.sh" || e ## DEVELOPMENT .PHONY: bootstrap -bootstrap: generate-version-file ## Set up everything to run the app +bootstrap: ## Set up everything to run the app + make generate-version-file poetry self add poetry-dotenv-plugin poetry lock --no-update poetry install --sync --no-root @@ -24,6 +26,20 @@ bootstrap: generate-version-file ## Set up everything to run the app source $(NVMSH) && npm ci --no-audit source $(NVMSH) && npm run build +.PHONY: bootstrap-with-git-hooks +bootstrap-with-git-hooks: ## Sets everything up and accounts for pre-existing git hooks + make generate-version-file + poetry self add poetry-dotenv-plugin + poetry lock --no-update + poetry install --sync --no-root + poetry run playwright install --with-deps + git config --global --unset-all core.hooksPath + poetry run pre-commit install + git config --global core.hookspath "${GIT_HOOKS_PATH}" + source $(NVMSH) --no-use && nvm install && npm install + source $(NVMSH) && npm ci --no-audit + source $(NVMSH) && npm run build + .PHONY: watch-frontend watch-frontend: ## Build frontend and watch for changes source $(NVMSH) && npm run watch @@ -53,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 @@ -83,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 be7f08146..441ff5dd4 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 ( @@ -140,11 +141,14 @@ navigation = { def _csp(config): asset_domain = config["ASSET_DOMAIN"] logo_domain = config["LOGO_CDN_DOMAIN"] - return { + api_host_name = config["API_HOST_NAME"] + + csp = { "default-src": ["'self'", asset_domain], "frame-src": [ "https://www.youtube.com", "https://www.youtube-nocookie.com", + "https://www.googletagmanager.com", ], "frame-ancestors": "'none'", "form-action": "'self'", @@ -157,6 +161,7 @@ def _csp(config): "https://www.googletagmanager.com", "https://www.google-analytics.com", "https://dap.digitalgov.gov", + "https://cdn.socket.io", ], "connect-src": [ "'self'", @@ -167,17 +172,39 @@ def _csp(config): "img-src": ["'self'", asset_domain, logo_domain], } + if api_host_name: + csp["connect-src"].append(api_host_name) + # this is for web socket + if api_host_name.startswith("http://"): + ws_url = api_host_name.replace("http://", "ws://") + csp["connect-src"].append(ws_url) + elif api_host_name.startswith("https://"): + ws_url = api_host_name.replace("https://", "wss://") + csp["connect-src"].append(ws_url) + return csp + 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';" + ) + return response + @application.context_processor def inject_feature_flags(): - feature_about_page_enabled = application.config.get( - "FEATURE_ABOUT_PAGE_ENABLED", False - ) + # 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_ABOUT_PAGE_ENABLED=feature_about_page_enabled, + 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(): ttl = 24 * 60 * 60 @@ -682,4 +709,4 @@ def slugify(text): """ Converts text to lowercase, replaces spaces with hyphens, and removes invalid characters. """ - return re.sub(r'[^a-z0-9-]', '', re.sub(r'\s+', '-', text.lower())) + return re.sub(r"[^a-z0-9-]", "", re.sub(r"\s+", "-", text.lower())) 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/activityChart.js b/app/assets/javascripts/activityChart.js index a9e75debd..e2add6117 100644 --- a/app/assets/javascripts/activityChart.js +++ b/app/assets/javascripts/activityChart.js @@ -220,7 +220,7 @@ var url = type === 'service' ? `/services/${currentServiceId}/daily-stats.json?timezone=${encodeURIComponent(userTimezone)}` - : `/services/${currentServiceId}/daily-stats-by-user.json`; + : `/services/${currentServiceId}/daily-stats-by-user.json?timezone=${encodeURIComponent(userTimezone)}`; return fetch(url) diff --git a/app/assets/javascripts/colourPreview.js b/app/assets/javascripts/colourPreview.js deleted file mode 100644 index 05d82e64b..000000000 --- a/app/assets/javascripts/colourPreview.js +++ /dev/null @@ -1,29 +0,0 @@ -(function(Modules) { - "use strict"; - - let isSixDigitHex = value => value.match(/^#[0-9A-F]{6}$/i); - let colourOrWhite = value => isSixDigitHex(value) ? value : '#FFFFFF'; - - Modules.ColourPreview = function() { - - this.start = component => { - - this.$input = $(component); - - this.$input.closest('.usa-form-group').append( - this.$preview = $('') - ); - - this.$input - .on('input', this.update) - .trigger('input'); - - }; - - this.update = () => this.$preview.css( - 'background', colourOrWhite(this.$input.val()) - ); - - }; - -})(window.GOVUK.Modules); 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/preventDuplicateFormSubmissions.js b/app/assets/javascripts/preventDuplicateFormSubmissions.js index 61e2e6890..e3221d9d2 100644 --- a/app/assets/javascripts/preventDuplicateFormSubmissions.js +++ b/app/assets/javascripts/preventDuplicateFormSubmissions.js @@ -13,16 +13,23 @@ } else { $submitButton.data('clicked', 'true'); - setTimeout(renableSubmitButton($submitButton), 1500); + if ($submitButton.is('[name="Send"], [name="Schedule"]')) { + $submitButton.prop('disabled', true); + + setTimeout(() => { + renableSubmitButton($submitButton); + }, 10000); + } else { + setTimeout(renableSubmitButton($submitButton), 1500); + } } - }; let renableSubmitButton = $submitButton => () => { $submitButton.data('clicked', ''); - + $submitButton.prop('disabled', false); }; $('form').on('submit', disableSubmitButtons); 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/javascripts/validation.js b/app/assets/javascripts/validation.js index bd556f0c4..ade16c0e3 100644 --- a/app/assets/javascripts/validation.js +++ b/app/assets/javascripts/validation.js @@ -7,13 +7,17 @@ function showError(input, errorElement, message) { errorElement.textContent = message; }, 10); - input.classList.add("usa-input--error"); + if (input.type !== "radio" && input.type !== "checkbox") { + input.classList.add("usa-input--error"); + } input.setAttribute("aria-describedby", errorElement.id); } function hideError(input, errorElement) { errorElement.style.display = "none"; - input.classList.remove("usa-input--error"); + if (input.type !== "radio" && input.type !== "checkbox") { + input.classList.remove("usa-input--error"); + } input.removeAttribute("aria-describedby"); } @@ -24,16 +28,18 @@ function getFieldLabel(input) { // Attach validation logic to forms function attachValidation() { - const forms = document.querySelectorAll("form.send-one-off-form"); + const forms = document.querySelectorAll('form[data-force-focus="True"]'); forms.forEach((form) => { const inputs = form.querySelectorAll("input, textarea, select"); form.addEventListener("submit", function (event) { let isValid = true; let firstInvalidInput = null; + const validatedRadioNames = new Set(); inputs.forEach((input) => { - const errorId = input.id ? `${input.id}-error` : `${input.name}-error`; + if (input.type === "hidden") return; + const errorId = input.type === "radio" ? `${input.name}-error` : `${input.id}-error`; let errorElement = document.getElementById(errorId); if (!errorElement) { @@ -41,16 +47,24 @@ function attachValidation() { errorElement.id = errorId; errorElement.classList.add("usa-error-message"); errorElement.setAttribute("aria-live", "polite"); - input.insertAdjacentElement("afterend", errorElement); + errorElement.style.display = "none"; + if (input.type === "radio") { + const group = form.querySelectorAll(`input[name="${input.name}"]`); + const lastRadio = group[group.length - 1]; + lastRadio.parentElement.insertAdjacentElement("afterend", errorElement); + } else { + input.insertAdjacentElement("afterend", errorElement); + } } if (input.type === "radio") { - // Find all radio buttons with the same name - const radioGroup = document.querySelectorAll(`input[name="${input.name}"]`); + if (validatedRadioNames.has(input.name)) return; + validatedRadioNames.add(input.name); + const radioGroup = form.querySelectorAll(`input[name="${input.name}"]`); const isChecked = Array.from(radioGroup).some(radio => radio.checked); if (!isChecked) { - showError(input, errorElement, `Error: ${getFieldLabel(input)} must be selected.`); + showError(input, errorElement, `Error: A selection must be made.`); isValid = false; if (!firstInvalidInput) { firstInvalidInput = input; @@ -72,12 +86,22 @@ function attachValidation() { }); inputs.forEach((input) => { + if (input.type === "hidden") return; input.addEventListener("input", function () { - const errorElement = document.getElementById(`${input.id}-error`); - if (input.value.trim() !== "" && errorElement) { + const errorId = input.type === "radio" ? `${input.name}-error` : `${input.id}-error`; + const errorElement = document.getElementById(errorId); + if (errorElement && input.value.trim() !== "") { hideError(input, errorElement); } }); + if (input.type === "radio") { + input.addEventListener("change", function () { + const errorElement = document.getElementById(`${input.name}-error`); + if (errorElement) { + hideError(input, errorElement); + } + }); + } }); }); } diff --git a/app/assets/pdf/best-practices-for-texting-the-public.pdf b/app/assets/pdf/best-practices-for-texting-the-public.pdf new file mode 100644 index 000000000..04e9cc99c Binary files /dev/null and b/app/assets/pdf/best-practices-for-texting-the-public.pdf differ diff --git a/app/assets/pdf/best-practices-section-outline.pdf b/app/assets/pdf/best-practices-section-outline.pdf new file mode 100644 index 000000000..4c19613b0 Binary files /dev/null and b/app/assets/pdf/best-practices-section-outline.pdf differ diff --git a/app/assets/pdf/investing-in-notifications-tts-public-benefits-studio-decision-memo.pdf b/app/assets/pdf/investing-in-notifications-tts-public-benefits-studio-decision-memo.pdf new file mode 100644 index 000000000..727d6a122 Binary files /dev/null and b/app/assets/pdf/investing-in-notifications-tts-public-benefits-studio-decision-memo.pdf differ diff --git a/app/assets/pdf/standing-up-your-own-notify.pdf b/app/assets/pdf/standing-up-your-own-notify.pdf new file mode 100644 index 000000000..f76c9d094 Binary files /dev/null and b/app/assets/pdf/standing-up-your-own-notify.pdf differ 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 95% rename from app/assets/sass/uswds/_legacy-styles.scss rename to app/assets/sass/uswds/_main.scss index 4af2cc6f6..983a72c8c 100644 --- a/app/assets/sass/uswds/_legacy-styles.scss +++ b/app/assets/sass/uswds/_main.scss @@ -49,12 +49,12 @@ } .sms-message-sender, .sms-message-file-name, .sms-message-scheduler, .sms-message-template, .sms-message-sender { - margin:0.25rem 0 0; + margin: units(0.5) 0 0; } .sms-message-recipient { color: color('gray-cool-90'); - margin: units(1) 0 units(1); + margin: units(0.5) 0 units(2); } .sms-message-status { @@ -344,3 +344,17 @@ h2.recipient-list { } } } + +// 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 83ad4ece4..31cbeb3fa 100644 --- a/app/assets/sass/uswds/_uswds-theme-custom-styles.scss +++ b/app/assets/sass/uswds/_uswds-theme-custom-styles.scss @@ -139,11 +139,6 @@ td.table-empty-message { font-family: family('sans'); } -.usa-dark-background .pill-item__label { - color: white; - text-decoration: none !important; -} - .pill-item.usa-link { text-decoration: none !important; } @@ -198,9 +193,6 @@ td.table-empty-message { word-wrap: break-word; } - // border: 1px solid color('gray-cool-10'); - // padding: units(2); - .tick-cross-list-permissions { margin: units(1) 0; padding-left: units(2); @@ -257,6 +249,11 @@ td.table-empty-message { } } +.usa-checkbox.template-list-item.template-list-item-with-checkbox.template-list-item-without-ancestors { + display: flex; + flex-direction: column; +} + .usa-checkbox__label-description { margin: units(2px) 0 units(1) units(4); } @@ -292,6 +289,7 @@ td.table-empty-message { margin-top: units(1); width: 100%; border: 1px solid color('gray-60'); + height: 40px; } } @@ -594,19 +592,31 @@ td.table-empty-message { .big-number-smallest { font-size: units(3); } + .pill-item__label { + color: white; + } &:not(.pill-item--selected):hover { background: color('blue-warm-70v'); + + .pill-item__label { + color: white; + } } - &.pill-item--selected:hover { - color: color('blue-60v'); + &.pill-item--selected { + .pill-item__label { + color: color('blue-60v'); + } + &:hover { + .pill-item__label { + color: color('blue-60v'); + } + } } } } } } -// Etc - .email-brand, .browse-list { padding: 0; 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 a82c9a1ab..20381e499 100644 --- a/app/config.py +++ b/app/config.py @@ -88,7 +88,7 @@ class Config(object): ], } - FEATURE_ABOUT_PAGE_ENABLED = getenv("FEATURE_ABOUT_PAGE_ENABLED", "false") == "true" + FEATURE_SOCKET_ENABLED = getenv("FEATURE_SOCKET_ENABLED", "false") == "true" def _s3_credentials_from_env(bucket_prefix): diff --git a/app/formatters.py b/app/formatters.py index c427c2a9a..a739fe437 100644 --- a/app/formatters.py +++ b/app/formatters.py @@ -344,7 +344,7 @@ def nl2br(value): html="escape", ) ).then(utils_nl2br) - ) + ) # nosec return "" diff --git a/app/main/forms.py b/app/main/forms.py index 90a4409fc..2695fcdc4 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -257,7 +257,7 @@ def govuk_text_input_field_widget( return Markup( render_template("components/components/input/template.njk", params=params) - ) + ) # nosec class GovukTextInputField(StringField): @@ -690,7 +690,9 @@ def govuk_checkbox_field_widget(self, field, param_extensions=None, **kwargs): if param_extensions: merge_jsonlike(params, param_extensions) - return Markup(render_template("forms/fields/checkboxes/macro.njk", params=params)) + return Markup( + render_template("forms/fields/checkboxes/macro.njk", params=params) + ) # nosec def govuk_checkboxes_field_widget( @@ -704,7 +706,7 @@ def govuk_checkboxes_field_widget( f' data-field-label="{field_label}">' f" {checkboxes_string}" f"" - ) + ) # nosec return result @@ -757,12 +759,14 @@ def govuk_checkboxes_field_widget( return _wrap_in_collapsible( self.field_label, - Markup(render_template("forms/fields/checkboxes/macro.njk", params=params)), + Markup( + render_template("forms/fields/checkboxes/macro.njk", params=params) + ), # nosec ) else: return Markup( render_template("forms/fields/checkboxes/macro.njk", params=params) - ) + ) # nosec def govuk_radios_field_widget(self, field, param_extensions=None, **kwargs): @@ -805,7 +809,7 @@ def govuk_radios_field_widget(self, field, param_extensions=None, **kwargs): return Markup( render_template("components/components/radios/template.njk", params=params) - ) + ) # nosec class GovukCheckboxField(BooleanField): diff --git a/app/main/validators.py b/app/main/validators.py index e1292b959..f9b2de0cc 100644 --- a/app/main/validators.py +++ b/app/main/validators.py @@ -61,7 +61,7 @@ class ValidEmail: class NoCommasInPlaceHolders: - def __init__(self, message="You cannot put commas between double brackets"): + def __init__(self, message="You cannot put commas between double parenthesis"): self.message = message def __call__(self, form, field): @@ -108,33 +108,17 @@ class OnlySMSCharacters: ) if non_sms_characters: raise ValidationError( - "You cannot use {} in {}. {} will not show up properly on everyone’s phones.".format( + "Please remove the unaccepted character {} in your message, then save again".format( formatted_list( non_sms_characters, - conjunction="or", + conjunction="and", before_each="", after_each="", ), - { - "sms": "text messages", - }.get(self._template_type), - ("It" if len(non_sms_characters) == 1 else "They"), ) ) -# class NoPlaceholders: - -# def __init__(self, message=None): -# self.message = message or ( -# 'You can’t use ((double brackets)) to personalize this message' -# ) - -# def __call__(self, form, field): -# if Field(field.data).placeholders: -# raise ValidationError(self.message) - - class LettersNumbersSingleQuotesFullStopsAndUnderscoresOnly: regex = re.compile(r"^[a-zA-Z0-9\s\._']+$") diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index 7790d101f..2e18be074 100644 --- a/app/main/views/dashboard.py +++ b/app/main/views/dashboard.py @@ -132,13 +132,18 @@ def get_local_daily_stats_for_last_x_days(stats_utc, user_timezone, days): @user_has_permissions() def get_daily_stats_by_user(service_id): date_range = get_stats_date_range() - stats = service_api_client.get_user_service_notification_statistics_by_day( + days = date_range["days"] + user_timezone = request.args.get("timezone", "UTC") + + stats_utc = service_api_client.get_user_service_notification_statistics_by_day( service_id, user_id=current_user.id, start_date=date_range["start_date"], - days=date_range["days"], + days=days, ) - return jsonify(stats) + + local_stats = get_local_daily_stats_for_last_x_days(stats_utc, user_timezone, days) + return jsonify(local_stats) @main.route("/services/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. +
+