diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 2d1b24fa28..20279ddac1 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/vscode/devcontainers/python:1-3.10@sha256:c5b379b09a94ac1ccb437e000dd54c96164a8322d0c53d2bcb25f225e27924e6 +FROM mcr.microsoft.com/vscode/devcontainers/python:3.12 ENV POETRY_VERSION="1.7.1" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2f45fbc1f7..c419dcea02 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -33,12 +33,11 @@ "vsliveshare.vsliveshare" ], "settings": { + "ruff.lint.ignore": ["F401"], "ruff.lint.run": "onSave", - "ruff.configurationPreference": "filesystemFirst", - "editor.defaultFormatter": "charliermarsh.ruff", - "editor.formatOnSave": true, - "editor.codeActionsOnSave": { - "source.fixAll": "explicit" + "ruff.organizeImports": false, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff" } } } diff --git a/.devcontainer/scripts/installations.sh b/.devcontainer/scripts/installations.sh index 7151480947..5a8ee3aa36 100644 --- a/.devcontainer/scripts/installations.sh +++ b/.devcontainer/scripts/installations.sh @@ -38,6 +38,9 @@ poetry completions zsh > ~/.zfunc/_poetry cd /workspace poetry install +# Install pre-commit hooks +poetry run pre-commit install + # Poe the Poet plugin tab completions touch ~/.zfunc/_poe poetry run poe _zsh_completion > ~/.zfunc/_poe diff --git a/.github/workflows/cypress-staging.yaml b/.github/workflows/cypress-staging.yaml index 361009c393..9ecc28d1a2 100644 --- a/.github/workflows/cypress-staging.yaml +++ b/.github/workflows/cypress-staging.yaml @@ -3,6 +3,7 @@ name: Cypress staging a11y tests on: schedule: - cron: 0 */3 * * * + workflow_dispatch: defaults: run: shell: bash diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 1031535048..904b8a97c9 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -8,8 +8,7 @@ env: AWS_REGION: ca-central-1 DOCKER_ORG: public.ecr.aws/v6b8u5o6 DOCKER_SLUG: public.ecr.aws/v6b8u5o6/notify-admin - WORKFLOW_PAT: ${{ secrets.WORKFLOW_GITHUB_PAT }} - OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} + OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN_STAGING }} permissions: id-token: write # This is required for requesting the OIDC JWT @@ -57,8 +56,8 @@ jobs: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@5fd3084fc36e372ff1fff382a39b10d03659f355 # v2.2.0 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-access-key-id: ${{ secrets.STAGING_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.STAGING_AWS_SECRET_ACCESS_KEY }} aws-region: ca-central-1 - name: Install OpenVPN @@ -87,7 +86,6 @@ jobs: uses: "kota65535/github-openvpn-connect-action@cd2ed8a90cc7b060dc4e001143e811b5f7ea0af5" with: config_file: /var/tmp/staging.ovpn - client_key: ${{ secrets.STAGING_OVPN_CLIENT_KEY }} echo_config: false - name: Configure kubeconfig @@ -97,7 +95,7 @@ jobs: - name: Update images in staging run: | DOCKER_TAG=${GITHUB_SHA::7} - kubectl set image deployment.apps/admin admin=$DOCKER_SLUG:$DOCKER_TAG -n=notification-canada-ca --kubeconfig=$HOME/.kube/config + kubectl set image deployment.apps/notify-admin notify-admin=$DOCKER_SLUG:$DOCKER_TAG -n=notification-canada-ca --kubeconfig=$HOME/.kube/config - name: my-app-install token id: notify-pr-bot diff --git a/.github/workflows/test-admin-delete-unused.yaml b/.github/workflows/test-admin-delete-unused.yaml index 5beaa3611b..620526f00f 100644 --- a/.github/workflows/test-admin-delete-unused.yaml +++ b/.github/workflows/test-admin-delete-unused.yaml @@ -19,8 +19,8 @@ jobs: id: aws-creds uses: aws-actions/configure-aws-credentials@5fd3084fc36e372ff1fff382a39b10d03659f355 # v2.2.0 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-access-key-id: ${{ secrets.STAGING_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.STAGING_AWS_SECRET_ACCESS_KEY }} aws-region: ca-central-1 - name: Delete old PR review environments diff --git a/.github/workflows/test-admin-deploy.yaml b/.github/workflows/test-admin-deploy.yaml index c216e28f30..4428177b02 100644 --- a/.github/workflows/test-admin-deploy.yaml +++ b/.github/workflows/test-admin-deploy.yaml @@ -32,8 +32,8 @@ jobs: id: aws-creds uses: aws-actions/configure-aws-credentials@5fd3084fc36e372ff1fff382a39b10d03659f355 # v2.2.0 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-access-key-id: ${{ secrets.STAGING_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.STAGING_AWS_SECRET_ACCESS_KEY }} aws-region: ca-central-1 - name: Login to ECR @@ -79,8 +79,8 @@ jobs: id: aws-creds uses: aws-actions/configure-aws-credentials@5fd3084fc36e372ff1fff382a39b10d03659f355 # v2.2.0 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-access-key-id: ${{ secrets.STAGING_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.STAGING_AWS_SECRET_ACCESS_KEY }} aws-region: ca-central-1 - name: Create/Update lambda function diff --git a/.github/workflows/test-admin-remove.yaml b/.github/workflows/test-admin-remove.yaml index 5c3f0f2b28..c7381b3b0e 100644 --- a/.github/workflows/test-admin-remove.yaml +++ b/.github/workflows/test-admin-remove.yaml @@ -26,8 +26,8 @@ jobs: id: aws-creds uses: aws-actions/configure-aws-credentials@5fd3084fc36e372ff1fff382a39b10d03659f355 # v2.2.0 with: - aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-access-key-id: ${{ secrets.STAGING_AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.STAGING_AWS_SECRET_ACCESS_KEY }} aws-region: ca-central-1 - name: Delete lambda function resources diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fbd6772dd8..f2e6f1ba01 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - uses: actions/setup-python@b64ffcaf5b410884ad320a9cfac8866006a109aa # v4.8.0 with: - python-version: '3.10' + python-version: '3.12' - uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2 with: node-version: '16.x' diff --git a/.github/workflows/test_endpoints.yaml b/.github/workflows/test_endpoints.yaml index 419e570342..4fc17401fe 100644 --- a/.github/workflows/test_endpoints.yaml +++ b/.github/workflows/test_endpoints.yaml @@ -8,10 +8,10 @@ jobs: steps: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - name: Set up Python 3.10 + - name: Set up Python 3.12 uses: actions/setup-python@b64ffcaf5b410884ad320a9cfac8866006a109aa # v4.8.0 with: - python-version: '3.10' + python-version: '3.12' - name: Upgrade pip run: python -m pip install --upgrade pip @@ -45,7 +45,7 @@ jobs: working-directory: ${{ github.workspace }} shell: bash run: | - mkdir -p "${{ github.workspace }}/env/" && cp -fR $(poetry env list | poetry env info -p)/lib/python3.10/site-packages "${{ github.workspace }}/env/" + mkdir -p "${{ github.workspace }}/env/" && cp -fR $(poetry env list | poetry env info -p)/lib/python3.12/site-packages "${{ github.workspace }}/env/" - name: Install development .env file working-directory: ${{ github.workspace }} @@ -58,7 +58,7 @@ jobs: echo "PYTHONPATH=/github/workspace/env/site-packages:${{ env.PYTHONPATH}}" >> $GITHUB_ENV - name: Checks for new endpoints against AWS WAF rules - uses: cds-snc/notification-utils/.github/actions/waffles@52.2.2 + uses: cds-snc/notification-utils/.github/actions/waffles@53.0.1 with: app-loc: '/github/workspace' app-libs: '/github/workspace/env/site-packages' diff --git a/.github/workflows/test_prod_config.yaml b/.github/workflows/test_prod_config.yaml index d8c0fdc6dc..8e7ba34d00 100644 --- a/.github/workflows/test_prod_config.yaml +++ b/.github/workflows/test_prod_config.yaml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - uses: actions/setup-python@b64ffcaf5b410884ad320a9cfac8866006a109aa # v4.8.0 with: - python-version: '3.10' + python-version: '3.12' - uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3.8.2 with: node-version: '16.x' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000..2f775686d5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +repos: +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.5 + hooks: + # Run the linter + - id: ruff + args: [ --fix ] + # Run the formatter + - id: ruff-format \ No newline at end of file diff --git a/Makefile b/Makefile index 8833fd26e4..2e622e27b6 100644 --- a/Makefile +++ b/Makefile @@ -55,9 +55,13 @@ coverage: venv ## Create coverage report run-dev: poetry run flask run -p 6012 --host=localhost +.PHONY: run-gunicorn +run-gunicorn: + PORT=6012 poetry run gunicorn -c gunicorn_config.py application + .PHONY: format format: - ruff check --select I --fix . + ruff check --fix . ruff check ruff format . mypy ./ diff --git a/README.md b/README.md index 77f29917a0..b679a8ff53 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ https://github.com/alphagov/notifications-admin ``` Languages needed -- Python 3.10 +- Python 3.12 - [Node](https://nodejs.org/) 10.15.3 or greater - [npm](https://www.npmjs.com/) 6.4.1 or greater @@ -58,15 +58,15 @@ On OS X: `brew install pyenv` -2. Install Python 3.10.8 or whatever is the latest +2. Install Python 3.12.7 or whatever is the latest -`pyenv install 3.10.8` +`pyenv install 3.12.7` -3. If you expect no conflicts, set `3.10.8` as you default +3. If you expect no conflicts, set `3.12.7` as you default -`pyenv global 3.10.8` +`pyenv global 3.12.7` -4. Ensure that version `3.10.8` is now the default by running +4. Ensure that version `3.12.7` is now the default by running `python --version` @@ -77,7 +77,7 @@ eval "$(pyenv init -)" ``` and open a new terminal. -If you are still not running Python 3.10.8 take a look here: https://github.com/pyenv/pyenv/issues/660 +If you are still not running Python 3.12.7 take a look here: https://github.com/pyenv/pyenv/issues/660 5. Install `poetry`: @@ -85,7 +85,7 @@ If you are still not running Python 3.10.8 take a look here: https://github.com/ 6. Restart your terminal and make your virtual environtment: -`mkvirtualenv -p ~/.pyenv/versions/3.10.8/bin/python notifications-admin` +`mkvirtualenv -p ~/.pyenv/versions/3.12.7/bin/python notifications-admin` 7. You can now return to your environment any time by entering @@ -278,7 +278,7 @@ https://github.com/alphagov/notifications-admin ``` Langages nécessaires -- Python 3.10 +- Python 3.12 - [Node](https://nodejs.org/) 10.15.3 ou supérieur - [npm](https://www.npmjs.com/) 6.4.1 ou plus ```shell @@ -302,15 +302,15 @@ Sur macOS : `brew install pyenv` -2. Installez Python 3.10.8 ou la dernière version +1. Installez Python 3.12.7 ou la dernière version -`pyenv install 3.10.8` +`pyenv install 3.12.7` -3. Si vous n'attendez aucun conflit, mettez `3.10.8` comme valeur par défaut +3. Si vous n'attendez aucun conflit, mettez `3.12.7` comme valeur par défaut -`pyenv global 3.10.8` +`pyenv global 3.12.7` -4. Assurez-vous que la version 3.10.8 est maintenant la version par défaut en exécutant +4. Assurez-vous que la version 3.12.7 est maintenant la version par défaut en exécutant `python --version` @@ -320,7 +320,7 @@ eval "$(pyenv init --path)" eval "$(pyenv init -)" ``` et ouvrez un nouveau terminal. -Si vous n’utilisez toujours pas Python 3.10.8, jetez un coup d’œil ici : https://github.com/pyenv/pyenv/issues/660 +Si vous n’utilisez toujours pas Python 3.12.7, jetez un coup d’œil ici : https://github.com/pyenv/pyenv/issues/660 5. Installez `virtualenv` : @@ -331,12 +331,12 @@ Si vous n’utilisez toujours pas Python 3.10.8, jetez un coup d’œil ici : ht ``` export WORKON_HOME=$HOME/.virtualenvs export PROJECT_HOME=$HOME/Devel -source ~/.pyenv/versions/3.10.8/bin/virtualenvwrapper.sh +source ~/.pyenv/versions/3.12.7/bin/virtualenvwrapper.sh ``` 7. Redémarrez votre terminal et créez votre environnement virtuel : -`mkvirtualenv -p ~/.pyenv/versions/3.10.8/bin/python notifications-admin` +`mkvirtualenv -p ~/.pyenv/versions/3.12.7/bin/python notifications-admin` 8. Vous pouvez maintenant retourner dans votre environnement à tout moment en entrant diff --git a/app/config.py b/app/config.py index 9b37a55798..99dab44c5b 100644 --- a/app/config.py +++ b/app/config.py @@ -188,7 +188,6 @@ class Development(Config): SESSION_PROTECTION = None SYSTEM_STATUS_URL = "https://localhost:3000" NO_BRANDING_ID = "0af93cf1-2c49-485f-878f-f3e662e651ef" - FF_ANNUAL_LIMIT = env.bool("FF_ANNUAL_LIMIT", True) class Test(Development): @@ -252,7 +251,6 @@ class Production(Config): NOTIFY_LOG_LEVEL = "INFO" SYSTEM_STATUS_URL = "https://status.notification.canada.ca" NO_BRANDING_ID = "760c802a-7762-4f71-b19e-f93c66c92f1a" - FF_ANNUAL_LIMIT = False class Staging(Production): @@ -260,7 +258,6 @@ class Staging(Production): NOTIFY_LOG_LEVEL = "INFO" SYSTEM_STATUS_URL = "https://status.staging.notification.cdssandbox.xyz" NO_BRANDING_ID = "0af93cf1-2c49-485f-878f-f3e662e651ef" - FF_ANNUAL_LIMIT = True class Scratch(Production): diff --git a/app/main/views/dashboard.py b/app/main/views/dashboard.py index 5a3414a260..2c40d1a2a1 100644 --- a/app/main/views/dashboard.py +++ b/app/main/views/dashboard.py @@ -273,27 +273,34 @@ def aggregate_by_type(notification_data): return counts year, current_financial_year = requested_and_current_financial_year(request) - monthly_data = service_api_client.get_monthly_notification_stats(service_id, year) - annual_data = aggregate_by_type(monthly_data) - todays_data = annual_limit_client.get_all_notification_counts(current_service.id) + # if FF_ANNUAL is on + if current_app.config["FF_ANNUAL_LIMIT"]: + monthly_data = service_api_client.get_monthly_notification_stats(service_id, year) + annual_data = aggregate_by_type(monthly_data) - # if redis is empty, query the db - if todays_data is None: - todays_data = service_api_client.get_service_statistics(service_id, limit_days=1, today_only=False) - annual_data_aggregate = combine_daily_to_annual(todays_data, annual_data, "db") + todays_data = annual_limit_client.get_all_notification_counts(current_service.id) - months = (format_monthly_stats_to_list(monthly_data["data"]),) - monthly_data_aggregate = combine_daily_to_monthly(todays_data, months[0], "db") - else: - # aggregate daily + annual - current_app.logger.info("todays data" + str(todays_data)) - annual_data_aggregate = combine_daily_to_annual(todays_data, annual_data, "redis") + # if redis is empty, query the db + if all(value == 0 for value in todays_data.values()): + todays_data = service_api_client.get_service_statistics(service_id, limit_days=1, today_only=False) + annual_data_aggregate = combine_daily_to_annual(todays_data, annual_data, "db") - months = (format_monthly_stats_to_list(monthly_data["data"]),) - monthly_data_aggregate = combine_daily_to_monthly(todays_data, months[0], "redis") + months = (format_monthly_stats_to_list(monthly_data["data"]),) + monthly_data_aggregate = combine_daily_to_monthly(todays_data, months[0], "db") + else: + # aggregate daily + annual + current_app.logger.info("todays data" + str(todays_data)) + annual_data_aggregate = combine_daily_to_annual(todays_data, annual_data, "redis") - # add today's data to monthly data + months = (format_monthly_stats_to_list(monthly_data["data"]),) + monthly_data_aggregate = combine_daily_to_monthly(todays_data, months[0], "redis") + else: + monthly_data_aggregate = ( + format_monthly_stats_to_list(service_api_client.get_monthly_notification_stats(service_id, year)["data"]), + ) + monthly_data_aggregate = monthly_data_aggregate[0] + annual_data_aggregate = None return render_template( "views/dashboard/monthly.html", diff --git a/app/main/views/send.py b/app/main/views/send.py index 2a5720e38b..f0e4e86406 100644 --- a/app/main/views/send.py +++ b/app/main/views/send.py @@ -53,6 +53,7 @@ ) from app.main.views.dashboard import aggregate_notifications_stats from app.models.user import Users +from app.notify_client.notification_counts_client import notification_counts_client from app.s3_client.s3_csv_client import ( copy_bulk_send_file_to_uploads, list_bulk_send_uploads, @@ -649,8 +650,8 @@ def _check_messages(service_id, template_id, upload_id, preview_row, letters_as_ sms_fragments_sent_today = daily_sms_fragment_count(service_id) emails_sent_today = daily_email_count(service_id) - remaining_sms_message_fragments = current_service.sms_daily_limit - sms_fragments_sent_today - remaining_email_messages = current_service.message_limit - emails_sent_today + remaining_sms_message_fragments_today = current_service.sms_daily_limit - sms_fragments_sent_today + remaining_email_messages_today = current_service.message_limit - emails_sent_today contents = s3download(service_id, upload_id) @@ -659,7 +660,7 @@ def _check_messages(service_id, template_id, upload_id, preview_row, letters_as_ email_reply_to = None sms_sender = None recipients_remaining_messages = ( - remaining_email_messages if db_template["template_type"] == "email" else remaining_sms_message_fragments + remaining_email_messages_today if db_template["template_type"] == "email" else remaining_sms_message_fragments_today ) if db_template["template_type"] == "email": @@ -743,8 +744,8 @@ def _check_messages(service_id, template_id, upload_id, preview_row, letters_as_ original_file_name=request.args.get("original_file_name", ""), upload_id=upload_id, form=CsvUploadForm(), - remaining_messages=remaining_email_messages, - remaining_sms_message_fragments=remaining_sms_message_fragments, + remaining_messages=remaining_email_messages_today, + remaining_sms_message_fragments=remaining_sms_message_fragments_today, sms_parts_to_send=sms_parts_to_send, is_sms_parts_estimated=is_sms_parts_estimated, choose_time_form=choose_time_form, @@ -783,7 +784,24 @@ def check_messages(service_id, template_id, upload_id, row_index=2): data["original_file_name"] = SanitiseASCII.encode(data.get("original_file_name", "")) data["sms_parts_requested"] = data["stats_daily"]["sms"]["requested"] data["sms_parts_remaining"] = current_service.sms_daily_limit - daily_sms_fragment_count(service_id) - data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"] + + if current_app.config["FF_ANNUAL_LIMIT"]: + data["send_exceeds_annual_limit"] = False + data["send_exceeds_daily_limit"] = False + # determine the remaining sends for daily + annual + limit_stats = notification_counts_client.get_limit_stats(current_service) + remaining_annual = limit_stats[data["template"].template_type]["annual"]["remaining"] + + if remaining_annual < data["count_of_recipients"]: + data["recipients_remaining_messages"] = remaining_annual + data["send_exceeds_annual_limit"] = True + else: + # if they arent over their limit, and its sms, check if they are over their daily limit + if data["template"].template_type == "sms": + data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"] + + else: + data["send_exceeds_daily_limit"] = data["recipients"].sms_fragment_count > data["sms_parts_remaining"] if ( data["recipients"].too_many_rows @@ -804,6 +822,10 @@ def check_messages(service_id, template_id, upload_id, row_index=2): if data["send_exceeds_daily_limit"]: return render_template("views/check/column-errors.html", **data) + if current_app.config["FF_ANNUAL_LIMIT"]: + if data["send_exceeds_annual_limit"]: + return render_template("views/check/column-errors.html", **data) + metadata_kwargs = { "notification_count": data["count_of_recipients"], "template_id": str(template_id), @@ -866,8 +888,15 @@ def check_notification_preview(service_id, template_id, filetype): @main.route("/services//start-job/", methods=["POST"]) @user_has_permissions("send_messages", restrict_admin_usage=True) def start_job(service_id, upload_id): - job_api_client.create_job(upload_id, service_id, scheduled_for=request.form.get("scheduled_for", "")) - + try: + job_api_client.create_job(upload_id, service_id, scheduled_for=request.form.get("scheduled_for", "")) + except HTTPError as exception: + return render_template( + "views/notifications/check.html", + time_to_reset=get_limit_reset_time_et(), + **(get_template_error_dict(exception) if exception else {}), + template=None, + ) session.pop("sender_id", None) return redirect( @@ -1085,7 +1114,12 @@ def get_template_error_dict(exception): error = "too-many-sms-messages" elif "Content for template has a character count greater than the limit of" in exception.message: error = "message-too-long" + elif "Exceeded annual email sending" in exception.message: + error = "too-many-email-annual" + elif "Exceeded annual SMS sending" in exception.message: + error = "too-many-sms-annual" else: + current_app.logger.error("Unhandled exception from API: {}".format(exception)) raise exception return { diff --git a/app/main/views/service_settings.py b/app/main/views/service_settings.py index d5065969d9..5a582eafc2 100644 --- a/app/main/views/service_settings.py +++ b/app/main/views/service_settings.py @@ -16,7 +16,6 @@ from flask_babel import lazy_gettext as _l from flask_login import current_user from notifications_python_client.errors import HTTPError -from notifications_utils.decorators import requires_feature from app import ( billing_api_client, @@ -1134,7 +1133,6 @@ def set_sms_message_limit(service_id): @main.route("/service//service_settings/set-sms-annual-limit", methods=["GET", "POST"]) @user_is_platform_admin -@requires_feature("FF_ANNUAL_LIMIT") # TODO: FF_ANNUAL_LIMIT removal def set_sms_annual_limit(service_id): form = SMSAnnualMessageLimit(message_limit=current_service.sms_annual_limit) @@ -1153,7 +1151,6 @@ def set_sms_annual_limit(service_id): @main.route("/service//service_settings/set-email-annual.html", methods=["GET", "POST"]) @user_is_platform_admin -@requires_feature("FF_ANNUAL_LIMIT") # TODO: FF_ANNUAL_LIMIT removal def set_email_annual_limit(service_id): form = EmailAnnualMessageLimit(message_limit=current_service.email_annual_limit) diff --git a/app/main/views/templates.py b/app/main/views/templates.py index 6c7f8a9dce..28079cca77 100644 --- a/app/main/views/templates.py +++ b/app/main/views/templates.py @@ -57,6 +57,7 @@ TemplateList, TemplateLists, ) +from app.notify_client.notification_counts_client import notification_counts_client from app.template_previews import TemplatePreview, get_page_count_for_letter from app.utils import ( email_or_sms_not_enabled, @@ -125,6 +126,31 @@ def get_char_limit_error_msg(): return _("Too many characters") +def get_limit_stats(notification_type): + # get the limit stats for the current service + limit_stats = notification_counts_client.get_limit_stats(current_service) + + # transform the stats into a format that can be used in the template + limit_stats = { + "dailyLimit": limit_stats[notification_type]["daily"]["limit"], + "dailyUsed": limit_stats[notification_type]["daily"]["sent"], + "dailyRemaining": limit_stats[notification_type]["daily"]["remaining"], + "yearlyLimit": limit_stats[notification_type]["annual"]["limit"], + "yearlyUsed": limit_stats[notification_type]["annual"]["sent"], + "yearlyRemaining": limit_stats[notification_type]["annual"]["remaining"], + "notification_type": notification_type, + "heading": _("Ready to send?"), + } + + # determine ready to send heading + if limit_stats["yearlyRemaining"] == 0: + limit_stats["heading"] = _("Sending paused until annual limit resets") + elif limit_stats["dailyRemaining"] == 0: + limit_stats["heading"] = _("Sending paused until 7pm ET. You can schedule more messages to send later.") + + return limit_stats + + @main.route("/services//templates/") @user_has_permissions() def view_template(service_id, template_id): @@ -142,6 +168,7 @@ def view_template(service_id, template_id): template=get_email_preview_template(template, template_id, service_id), template_postage=template["postage"], user_has_template_permission=user_has_template_permission, + **get_limit_stats(template["template_type"]), ) @@ -1072,6 +1099,7 @@ def delete_service_template(service_id, template_id): "views/templates/template.html", template=get_email_preview_template(template, template["id"], service_id), user_has_template_permission=True, + **get_limit_stats(template["template_type"]), ) @@ -1085,6 +1113,7 @@ def confirm_redact_template(service_id, template_id): template=get_email_preview_template(template, template["id"], service_id), user_has_template_permission=True, show_redaction_message=True, + **get_limit_stats(template["template_type"]), ) diff --git a/app/notify_client/notification_counts_client.py b/app/notify_client/notification_counts_client.py new file mode 100644 index 0000000000..d97511efd0 --- /dev/null +++ b/app/notify_client/notification_counts_client.py @@ -0,0 +1,149 @@ +from datetime import datetime + +from notifications_utils.clients.redis import ( + email_daily_count_cache_key, + sms_daily_count_cache_key, +) + +from app import redis_client, service_api_client, template_statistics_client +from app.models.service import Service + + +class NotificationCounts: + def get_all_notification_counts_for_today(self, service_id): + # try to get today's stats from redis + todays_sms = redis_client.get(sms_daily_count_cache_key(service_id)) + todays_sms = int(todays_sms) if todays_sms is not None else None + + todays_email = redis_client.get(email_daily_count_cache_key(service_id)) + todays_email = int(todays_email) if todays_email is not None else None + + if todays_sms is not None and todays_email is not None: + return {"sms": todays_sms, "email": todays_email} + # fallback to the API if the stats are not in redis + else: + stats = template_statistics_client.get_template_statistics_for_service(service_id, limit_days=1) + transformed_stats = _aggregate_notifications_stats(stats) + + return transformed_stats + + def get_all_notification_counts_for_year(self, service_id, year): + """ + Get total number of notifications by type for the current service for the current year + + Return value: + { + 'sms': int, + 'email': int + } + + """ + stats_today = self.get_all_notification_counts_for_today(service_id) + stats_this_year = service_api_client.get_monthly_notification_stats(service_id, year)["data"] + stats_this_year = _aggregate_stats_from_service_api(stats_this_year) + # aggregate stats_today and stats_this_year + for template_type in ["sms", "email"]: + stats_this_year[template_type] += stats_today[template_type] + + return stats_this_year + + def get_limit_stats(self, service: Service): + """ + Get the limit stats for the current service, by notification type, including: + - how many notifications were sent today and this year + - the monthy and daily limits + - the number of notifications remaining today and this year + Returns: + dict: A dictionary containing the limit stats for email and SMS notifications. The structure is as follows: + { + "email": { + "annual": { + "limit": int, # The annual limit for email notifications + "sent": int, # The number of email notifications sent this year + "remaining": int, # The number of email notifications remaining this year + }, + "daily": { + "limit": int, # The daily limit for email notifications + "sent": int, # The number of email notifications sent today + "remaining": int, # The number of email notifications remaining today + }, + }, + "sms": { + "annual": { + "limit": int, # The annual limit for SMS notifications + "sent": int, # The number of SMS notifications sent this year + "remaining": int, # The number of SMS notifications remaining this year + }, + "daily": { + "limit": int, # The daily limit for SMS notifications + "sent": int, # The number of SMS notifications sent today + "remaining": int, # The number of SMS notifications remaining today + }, + } + } + """ + + sent_today = self.get_all_notification_counts_for_today(service.id) + sent_thisyear = self.get_all_notification_counts_for_year(service.id, datetime.now().year) + + limit_stats = { + "email": { + "annual": { + "limit": service.email_annual_limit, + "sent": sent_thisyear["email"], + "remaining": service.email_annual_limit - sent_thisyear["email"], + }, + "daily": { + "limit": service.message_limit, + "sent": sent_today["email"], + "remaining": service.message_limit - sent_today["email"], + }, + }, + "sms": { + "annual": { + "limit": service.sms_annual_limit, + "sent": sent_thisyear["sms"], + "remaining": service.sms_annual_limit - sent_thisyear["sms"], + }, + "daily": { + "limit": service.sms_daily_limit, + "sent": sent_today["sms"], + "remaining": service.sms_daily_limit - sent_today["sms"], + }, + }, + } + + return limit_stats + + +# TODO: consolidate this function and other functions that transform the results of template_statistics_client calls +def _aggregate_notifications_stats(template_statistics): + template_statistics = _filter_out_cancelled_stats(template_statistics) + notifications = {"sms": 0, "email": 0} + for stat in template_statistics: + notifications[stat["template_type"]] += stat["count"] + + return notifications + + +def _filter_out_cancelled_stats(template_statistics): + return [s for s in template_statistics if s["status"] != "cancelled"] + + +def _aggregate_stats_from_service_api(stats): + """Aggregate monthly notification stats excluding cancelled""" + total_stats = {"sms": {}, "email": {}} + + for month_data in stats.values(): + for msg_type in ["sms", "email"]: + if msg_type in month_data: + for status, count in month_data[msg_type].items(): + if status != "cancelled": + if status not in total_stats[msg_type]: + total_stats[msg_type][status] = 0 + total_stats[msg_type][status] += count + + return {msg_type: sum(counts.values()) for msg_type, counts in total_stats.items()} + + +notification_counts_client = NotificationCounts() diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index b1274337c7..4cb0f56494 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -3,7 +3,6 @@ from flask import current_app from flask_login import current_user -from notifications_utils.decorators import requires_feature from app.extensions import redis_client from app.notify_client import NotifyAdminAPIClient, _attach_current_user, cache @@ -168,7 +167,6 @@ def update_sms_message_limit(self, service_id, sms_daily_limit): sms_daily_limit=sms_daily_limit, ) - @requires_feature("FF_ANNUAL_LIMIT") # TODO: FF_ANNUAL_LIMIT removal @cache.delete("service-{service_id}") def update_sms_annual_limit(self, service_id, sms_annual_limit): return self.update_service( @@ -176,7 +174,6 @@ def update_sms_annual_limit(self, service_id, sms_annual_limit): sms_annual_limit=sms_annual_limit, ) - @requires_feature("FF_ANNUAL_LIMIT") # TODO: FF_ANNUAL_LIMIT removal @cache.delete("service-{service_id}") def update_email_annual_limit(self, service_id, email_annual_limit): return self.update_service( diff --git a/app/templates/components/message-count-label.html b/app/templates/components/message-count-label.html index 81b6764baf..444f58b1ab 100644 --- a/app/templates/components/message-count-label.html +++ b/app/templates/components/message-count-label.html @@ -104,9 +104,9 @@ {%- if session["userlang"] == "fr" -%} {%- if count <= 1 -%} - {{ _('addresse courriel problématique') }} + addresse courriel problématique {%- else -%} - {{ _('addresses courriel problématiques') }} + addresses courriel problématiques {%- endif %} {{" "}} {%- endif %} diff --git a/app/templates/components/remaining-messages-summary.html b/app/templates/components/remaining-messages-summary.html index 42175d47fd..b685e03aff 100644 --- a/app/templates/components/remaining-messages-summary.html +++ b/app/templates/components/remaining-messages-summary.html @@ -1,4 +1,4 @@ -{% macro remaining_messages_summary(dailyLimit, dailyUsed, yearlyLimit, yearlyUsed, notification_type, textOnly=None) %} +{% macro remaining_messages_summary(dailyLimit, dailyUsed, yearlyLimit, yearlyUsed, notification_type, headingMode=False, textOnly=None) %} {% set textOnly_allowed_values = ['text', 'emoji'] %} {% if textOnly not in textOnly_allowed_values %} @@ -95,14 +95,14 @@ {% endif %} - {% if sections[0].skip %} + {% if not headingMode and sections[0].skip %}

- Sending paused until annual limit resets + {{ _('Sending paused until annual limit resets') }}

- {% elif sections[0].remaining == "0" %} + {% elif not headingMode and sections[0].remaining == "0" %}

- Sending paused until 7pm ET. You can schedule more messages to send later. + {{ _('Sending paused until 7pm ET. You can schedule more messages to send later.') }}

{% endif %} -{% endmacro %} +{% endmacro %} \ No newline at end of file diff --git a/app/templates/partials/check/too-many-email-messages.html b/app/templates/partials/check/too-many-email-messages.html index 987846c472..9f66acd9c3 100644 --- a/app/templates/partials/check/too-many-email-messages.html +++ b/app/templates/partials/check/too-many-email-messages.html @@ -1,6 +1,6 @@ {% from "components/links.html" import content_link %} -

+

{%- if current_service.trial_mode %} {{ _("Your service is in trial mode. To send more messages, request to go live").format(url_for('main.request_to_go_live', service_id=current_service.id)) }} {% else %} diff --git a/app/templates/partials/check/too-many-messages-annual.html b/app/templates/partials/check/too-many-messages-annual.html new file mode 100644 index 0000000000..86e4cb3ae9 --- /dev/null +++ b/app/templates/partials/check/too-many-messages-annual.html @@ -0,0 +1,24 @@ +{% from "components/links.html" import content_link %} + +{% if template.template_type == 'email' %} + {% set units = _('email messages') %} +{% else %} + {% set units = _('text messages') %} +{% endif %} + +

+ {%- if current_service.trial_mode %} + {{ _("Your service is in trial mode. To send more messages, request to go live").format(url_for('main.request_to_go_live', service_id=current_service.id)) }} + {% else %} + {% if recipients_remaining_messages > 0 %} +

{{ _('{} can only send {} more {} until annual limit resets'.format(current_service.name, recipients_remaining_messages, units)) }}

+

+ {{ _('To send some of these messages now, edit the spreadsheet to {} recipients maximum. '.format(recipients_remaining_messages)) }} + {{ _('To send to recipients you removed, wait until April 1, {} or contact them some other way.'.format(now().year)) }} +

+ {% else %} +

{{ _('{} cannot send any more {} until April 1, {}'.format(current_service.name, units, now().year)) }}

+

{{ _('For more information, visit the usage report for {}.'.format(url_for('.monthly', service_id=current_service.id), current_service.name)) }}

+ {% endif %} + {%- endif -%} +

\ No newline at end of file diff --git a/app/templates/partials/check/too-many-sms-message-parts.html b/app/templates/partials/check/too-many-sms-message-parts.html index 1000fe1ac5..71cc5693a0 100644 --- a/app/templates/partials/check/too-many-sms-message-parts.html +++ b/app/templates/partials/check/too-many-sms-message-parts.html @@ -1,9 +1,9 @@ {% from "components/links.html" import content_link %} -

+

{%- if current_service.trial_mode %} {{ _("Your service is in trial mode. To send more messages, request to go live").format(url_for('main.request_to_go_live', service_id=current_service.id)) }} {% else %} {{ _("To request a daily limit above {} text messages, {}").format(current_service.sms_daily_limit, content_link(_("contact us"), url_for('main.contact'), is_external_link=true)) }} {%- endif -%} -

\ No newline at end of file +

diff --git a/app/templates/views/api/callbacks/delivery-status-callback.html b/app/templates/views/api/callbacks/delivery-status-callback.html index 92c5b0e118..e561c16236 100644 --- a/app/templates/views/api/callbacks/delivery-status-callback.html +++ b/app/templates/views/api/callbacks/delivery-status-callback.html @@ -41,13 +41,13 @@

autocomplete='new-password' ) }} {% set test_response_txt = _('Test response time') if has_callback_config else None %} - {% set test_response_value = _('test_response_time') if has_callback_config else None %} + {% set test_response_value = 'test_response_time' if has_callback_config else None %} {% set display_footer = is_deleting if is_deleting else False %} {% set delete_link = url_for('.delete_delivery_status_callback', service_id=current_service.id) if has_callback_config else None%} {% if not display_footer %} {{ sticky_page_footer_two_submit_buttons_and_delete_link( button1_text=_('Save'), - button1_value=_('save'), + button1_value='save', button2_text=test_response_txt, button2_value=test_response_value, delete_link=delete_link, diff --git a/app/templates/views/api/callbacks/received-text-messages-callback.html b/app/templates/views/api/callbacks/received-text-messages-callback.html index 7056ffa71d..e43e2b55e7 100644 --- a/app/templates/views/api/callbacks/received-text-messages-callback.html +++ b/app/templates/views/api/callbacks/received-text-messages-callback.html @@ -36,14 +36,14 @@

autocomplete='new-password' ) }} {% set test_response_txt = _('Test response time') if has_callback_config else None %} - {% set test_response_value = _('test_response_time') if has_callback_config else None %} + {% set test_response_value = 'test_response_time' if has_callback_config else None %} {% set display_footer = is_deleting if is_deleting else False %} {% set delete_link = url_for('.delete_received_text_messages_callback', service_id=current_service.id) if has_callback_config else None%} {% set delete_link_text = _('Delete') if has_callback_config else None %} {% if not display_footer %} {{ sticky_page_footer_two_submit_buttons_and_delete_link( button1_text=_('Save'), - button1_value=_('save'), + button1_value='save', button2_text=test_response_txt, button2_value=test_response_value, delete_link=delete_link, diff --git a/app/templates/views/check/column-errors.html b/app/templates/views/check/column-errors.html index c3ef2200f8..1e46c28333 100644 --- a/app/templates/views/check/column-errors.html +++ b/app/templates/views/check/column-errors.html @@ -10,7 +10,9 @@ {% set prefix_txt = _('a column called') %} {% set prefix_plural_txt = _('columns called') %} -{% if send_exceeds_daily_limit and (sms_parts_remaining <= 0) %} +{% if send_exceeds_annual_limit %} + {% set page_title = _('These messages exceed the annual limit') %} +{% elif send_exceeds_daily_limit and (sms_parts_remaining <= 0) %} {% set page_title = _('These messages exceed your daily limit') %} {% elif send_exceeds_daily_limit or recipients.more_rows_than_can_send %} {% set page_title = _('These messages exceed your daily limit') %} @@ -161,25 +163,23 @@

- {{ _("You can try sending these messages after {} Eastern Time. Check {}.").format(time_to_reset[current_lang], content_link(_("your current local time"), 'https://nrc.canada.ca/en/web-clock/', is_external_link=true))}} + {{ _("You can try sending these messages after {} Eastern Time. Check {}.").format(time_to_reset[current_lang], + content_link(_("your current local time"), _('https://nrc.canada.ca/en/web-clock/'), is_external_link=true))}}

- {% elif recipients.more_rows_than_can_send and false %} + {% elif send_exceeds_annual_limit %} {% call banner_wrapper(type='dangerous') %} - {% include "partials/check/too-many-email-messages.html" %} + {% include "partials/check/too-many-messages-annual.html" %} {% endcall %} {% elif recipients.more_rows_than_can_send %} - {% call banner_wrapper(type='dangerous') %} - {% include "partials/check/too-many-email-messages.html" %} - {% endcall %} -

{{ _('You cannot send all these email messages today') }}

-

- {{ _("You can try sending these messages after {} Eastern Time. Check {}.").format(time_to_reset[current_lang], content_link(_("your current local time"), 'https://nrc.canada.ca/en/web-clock/', is_external_link=true))}} -

- - + {% call banner_wrapper(type='dangerous') %} + {% include "partials/check/too-many-email-messages.html" %} + {% endcall %} +

{{ _('You cannot send all these email messages today') }}

+

+ {{ _("You can try sending these messages after {} Eastern Time. Check {}.").format(time_to_reset[current_lang], + content_link(_("your current local time"), _('https://nrc.canada.ca/en/web-clock/'), is_external_link=true))}} +

{% endif %} - - {% if not send_exceeds_daily_limit %} diff --git a/app/templates/views/email-branding/branding-goc.html b/app/templates/views/email-branding/branding-goc.html index d9fc13c351..e059b08f82 100644 --- a/app/templates/views/email-branding/branding-goc.html +++ b/app/templates/views/email-branding/branding-goc.html @@ -24,7 +24,8 @@

{{ _('Change the default langu {% call form_wrapper() %} {{ radios(form.goc_branding, hide_legend=True, testid="goc_branding") }} - {{ _('{}').format(url_for('main.review_branding_pool', service_id=current_service.id), _('Select alternate logo')) }} + {{ _('Select alternate logo') }}
{{ page_footer( diff --git a/app/templates/views/email-branding/branding-pool.html b/app/templates/views/email-branding/branding-pool.html index daa3366124..1d293017c7 100644 --- a/app/templates/views/email-branding/branding-pool.html +++ b/app/templates/views/email-branding/branding-pool.html @@ -29,7 +29,8 @@
- {{ _('{}').format(url_for('main.create_branding_request', service_id=current_service.id), _('Request a new logo')) }} + {{ + _('Request a new logo') }}
{{ page_footer(_('Preview'), testid="preview") }} diff --git a/app/templates/views/email-branding/branding-request-submitted.html b/app/templates/views/email-branding/branding-request-submitted.html index 968dad1359..70ab504f0c 100644 --- a/app/templates/views/email-branding/branding-request-submitted.html +++ b/app/templates/views/email-branding/branding-request-submitted.html @@ -31,7 +31,7 @@ {{ task_shortcut( description=_("Explore other settings"), link_url=url_for('main.service_settings', service_id=current_service.id), - link_text=_("Go to your Settings"), + link_text=_("Go to your settings"), icon="arrow-right" )}}
diff --git a/app/templates/views/notifications/check.html b/app/templates/views/notifications/check.html index 044eb29281..e9d61b7f94 100644 --- a/app/templates/views/notifications/check.html +++ b/app/templates/views/notifications/check.html @@ -43,7 +43,8 @@ {% endcall %}

{{_('You cannot send this text message today') }}

- {{ _("You can try sending this message after {} Eastern Time. Check {}.").format(time_to_reset[current_lang], content_link(_("your current local time"), 'https://nrc.canada.ca/en/web-clock/', is_external_link=true))}} + {{ _("You can try sending this message after {} Eastern Time. Check {}.").format(time_to_reset[current_lang], + content_link(_("your current local time"), _('https://nrc.canada.ca/en/web-clock/'), is_external_link=true))}}

{% elif error == 'too-many-messages' %} @@ -61,9 +62,17 @@

{{_('You cannot send this text message today') }}

{{_('You cannot send this email message today') }}

- {{ _("You can try sending this message after {} Eastern Time. Check {}.").format(time_to_reset[current_lang], content_link(_("your current local time"), 'https://nrc.canada.ca/en/web-clock/', is_external_link=true))}} + {{ _("You can try sending this message after {} Eastern Time. Check {}.").format(time_to_reset[current_lang], + content_link(_("your current local time"), _('https://nrc.canada.ca/en/web-clock/'), is_external_link=true))}}

+ {% elif error == 'too-many-email-annual' or error == 'too-many-sms-annual' %} + {{ page_header(_('These messages exceed the annual limit'), back_link=back_link) }} +
+ {% call banner_wrapper(type='dangerous') %} + {% set recipients_remaining_messages = 0 %} + {% include "partials/check/too-many-messages-annual.html" %} + {% endcall %} {% elif error == 'message-too-long' %} {# the only row_errors we can get when sending one off messages is that the message is too long #} {{ govuk_back_link(back_link) }} @@ -79,7 +88,9 @@

{{_('You cannot send this email message today') }}
{{ _('Your service is in trial mode') }}

{% call settings_row(if_has_permission='email') %} {% set txt = _('Annual maximum
(April 1 to March 31)') %} {{ text_field(txt) }} - {# TODO: FF_ANNUAL_LIMIT removal #} - {% if config["FF_ANNUAL_LIMIT"] %} - {% set annual_limit = _('{} emails').format((current_service.email_annual_limit) | format_number) %} - {% else %} - {% set annual_limit = _('{} million emails').format((limits.free_yearly_email//1000000) | format_number) %} - {% endif%} + {% set annual_limit = _('{} emails').format((current_service.email_annual_limit) | format_number) %} {{ text_field(annual_limit, attributes="data-testid=email-annual-limit") }} {{ text_field('')}} {% endcall %} @@ -271,12 +266,7 @@

{{ _('Your service is in trial mode') }}

{% call settings_row(if_has_permission='sms') %} {% set txt = _('Annual maximum
(April 1 to March 31)') %} {{ text_field(txt) }} - {# TODO: FF_ANNUAL_LIMIT removal #} - {% if config["FF_ANNUAL_LIMIT"] %} - {% set annual_sms_limit = _('{} text messages').format((current_service.sms_annual_limit) | format_number) %} - {% else %} - {% set annual_sms_limit = _('{} text messages').format((limits.free_yearly_sms) | format_number) %} - {% endif%} + {% set annual_sms_limit = _('{} text messages').format((current_service.sms_annual_limit) | format_number) %} {{ text_field(annual_sms_limit, attributes="data-testid=sms-annual-limit") }} {{ text_field('')}} {% endcall %} @@ -383,8 +373,6 @@

{{ _('Platform admin settings') }}

) }} {% endcall %} - {# TODO: FF_ANNUAL_LIMIT removal #} - {% if config["FF_ANNUAL_LIMIT"] %} {% call row() %} {% set txt = _('Annual email limit') %} {{ text_field(txt)}} @@ -408,7 +396,6 @@

{{ _('Platform admin settings') }}

for=txt ) }} {% endcall %} - {% endif %} {% call row() %} {% set txt = _('API rate limit per minute') %} diff --git a/app/templates/views/service-settings/email_from.html b/app/templates/views/service-settings/email_from.html index 321b6ee3f0..b9b9851efc 100644 --- a/app/templates/views/service-settings/email_from.html +++ b/app/templates/views/service-settings/email_from.html @@ -23,8 +23,7 @@ {% call form_wrapper() %} {% set save_txt = _('Save') %} - {% set hint_txt = _('Maximum 64 characters with no spaces. Characters can include letters, numbers, dots, dashes, and - underscores.') %} + {% set hint_txt = _('Maximum 64 characters with no spaces. Characters can include letters, numbers, dots, dashes, and underscores.') %} {{ textbox(form.email_from, hint=hint_txt) }}
{% call confirmation_preview() %} diff --git a/app/templates/views/service-settings/set-free-sms-allowance.html b/app/templates/views/service-settings/set-free-sms-allowance.html index c410b58d10..d06a4ccd00 100644 --- a/app/templates/views/service-settings/set-free-sms-allowance.html +++ b/app/templates/views/service-settings/set-free-sms-allowance.html @@ -12,7 +12,7 @@ {% call form_wrapper() %} {{ page_header( - _('Free text messages per year'), + _('Free text messages per fiscal year'), back_link=url_for('.service_settings', service_id=current_service.id) ) }} {{ textbox(form.free_sms_allowance) }} diff --git a/app/templates/views/storybook/remaining-messages-summary.html b/app/templates/views/storybook/remaining-messages-summary.html index 694ec40b4c..f5baec4cdd 100644 --- a/app/templates/views/storybook/remaining-messages-summary.html +++ b/app/templates/views/storybook/remaining-messages-summary.html @@ -53,33 +53,33 @@

Mixed

Text only

below limit
- {{ remaining_messages_summary(10000, 700, 10000, 750, "email", "text") }} + {{ remaining_messages_summary(10000, 700, 10000, 750, "email", False, "text") }}
near limit
- {{ remaining_messages_summary(1000, 800, 1000, 900, "email", "text") }} + {{ remaining_messages_summary(1000, 800, 1000, 900, "email", False, "text") }}
at limit
- {{ remaining_messages_summary(1000, 1000, 1000, 1000, "email", "text") }} + {{ remaining_messages_summary(1000, 1000, 1000, 1000, "email", False, "text") }}

Text only emoji

below limit
- {{ remaining_messages_summary(1000, 700, 1000, 750, "email", "emoji") }} + {{ remaining_messages_summary(1000, 700, 1000, 750, "email", False, "emoji") }}
near limit
- {{ remaining_messages_summary(1000, 800, 1000, 900, "email", "emoji") }} + {{ remaining_messages_summary(1000, 800, 1000, 900, "email", False, "emoji") }}
at limit
- {{ remaining_messages_summary(1000, 1000, 1000, 1000, "email", "emoji") }} + {{ remaining_messages_summary(1000, 1000, 1000, 1000, "email", False, "emoji") }}
diff --git a/app/templates/views/templates/_template.html b/app/templates/views/templates/_template.html index 86c19a127e..918f52efc9 100644 --- a/app/templates/views/templates/_template.html +++ b/app/templates/views/templates/_template.html @@ -1,4 +1,5 @@ {% from 'components/message-count-label.html' import message_count_label %} +{% from 'components/remaining-messages-summary.html' import remaining_messages_summary with context %}
{% if template._template.archived %} @@ -13,20 +14,23 @@

{% else %} {% if current_user.has_permissions('send_messages', restrict_admin_usage=True) %} -

{{ _('Ready to send?') }}

+

{{ heading }}

- + {% if config["FF_ANNUAL_LIMIT"] %} + {{ remaining_messages_summary(dailyLimit, dailyUsed, yearlyLimit, yearlyUsed, notification_type, yearlyRemaining == 0 or dailyRemaining == 0) }} + {% endif %} + {% if not config["FF_ANNUAL_LIMIT"] or (yearlyRemaining > 0 and dailyRemaining > 0) %} + + {% endif %} {% endif %} {% endif %}
-
+
{{ template|string|translate_preview_template }} -
- - +
\ No newline at end of file diff --git a/app/translations/csv/fr.csv b/app/translations/csv/fr.csv index 867db47213..8f520b7cff 100644 --- a/app/translations/csv/fr.csv +++ b/app/translations/csv/fr.csv @@ -1,6 +1,6 @@ "source","target" "\!/\!/ HELLO DEVS, READ THIS FOR DOCUMENTATION \!/\!/","Read more about how translations work in scripts/generate_en_translations.py" -":"," :" +": "," : " "English","Anglais" "French","Français" "Your account and language","Votre compte et langue" @@ -56,6 +56,7 @@ "Who runs this service?","Qui assure la gestion du service?" "What’s your key called?","Quel est le nom de votre clé?" "Numbers of text messages per fiscal year","Nombre de messages texte par exercice financier" +"Free text messages per fiscal year","Messages texte gratuits par exercice financier" "Enter password","Entrez votre mot de passe" "Invalid password","Mot de passe non valide" "Template name","Nom du gabarit" @@ -127,6 +128,7 @@ "Text message","Message texte" "Choose a folder","Choisissez un dossier" "Create template","Créer un gabarit" +"Create category","Créer une catégorie" "Will you send the message by email or text?","Enverrez-vous le message par courriel ou par texte?" "What’s their name?","Quel est son nom?" "What’s their email address?","Quelle est son adresse courriel?" @@ -653,6 +655,7 @@ "Count in list of live services","Compte dans la liste de services activés" "Organisation","Organisation" "Free text message allowance","Nombre de messages texte gratuits" +"Free text messages per year","Nombre de messages texte gratuits par année" "text messages per fiscal year","Allocation de messages texte par exercice financier" "Letter branding","Image de marque de la lettre" "Data retention","Rétention des données" @@ -1080,6 +1083,7 @@ "Daily text message limit","Limite quotidienne de message texte" "Last edited","Dernière modification : " "See previous versions","Voir les versions précédentes" +"Version {}","Version {}" "Delete this template","Supprimer ce gabarit" "Redact personalised variable content after sending","Masquer le contenu variable personnalisé après l’envoi" "Personalised variable content redacted after sending","Le contenu variable personnalisé masqué après l’envoi" @@ -1426,6 +1430,7 @@ "Filter by year","Filtrer par année" "Filter by status","Filtrer par état de livraison" "Filter by template type","Filtrer par type de gabarit" +"Filter by template type and category","Filtrer par type et catégorie de gabarit" "Top of page","Haut de page" "Your service is in trial mode. Trial mode limits your service to sending notifications to yourself and to your team members within GC Notify.","Votre service est en mode d’essai. Le mode d’essai limite l’envoi de notifications à vous-même et aux autres membres de votre équipe sur Notification GC." "Complete the following steps to go live and send notifications to more people.","Complétez les étapes suivantes pour activer votre service et envoyer des messages à d’autres personnes." @@ -1648,6 +1653,8 @@ "Back to template {}","Retour au gabarit {}" "Previewing template {}","Aperçu du gabarit {}" "You need a new password","Vous devez créer un nouveau mot de passe" +"You need to create a new password","Vous devez créer un nouveau mot de passe" +"GC Notify needs you to create a new password for this account.","Notification GC vous demande de créer un nouveau mot de passe pour ce compte." "As a security precaution, all users of GC Notify must change their password.","Par mesure de sécurité, tou·te·s les utilisateur·rice·s de Notification GC doivent changer leur mot de passe." "Check your email. If you did not receive the link,","Nous vous avons envoyé un courriel contenant un lien de réinitialisation. Si vous ne l’avez pas reçu," "contact support.","veuillez contacter notre équipe de soutien." @@ -1661,7 +1668,7 @@ "The link in the email we sent you has expired","Le lien que nous vous avons envoyé par courriel a expiré." "Check your email. We sent you a password reset link.","Vérifiez votre courriel. Nous vous avons envoyé un lien pour réinitialiser votre mot de passe." "Why we are asking you to create a new password","Pourquoi nous vous demandons de créer un nouveau mot de passe." -"GC Notify has expired all user passwords out of an abundance of caution following the discovery of a potential security risk on March 29, 2022.", Notification GC a expiré le mot de passe de tous les utilisateurs par excès de prudence, suivant la découverte d’une faille potentielle le 29 mars 2022." +"GC Notify has expired all user passwords out of an abundance of caution following the discovery of a potential security risk on March 29, 2022.","Notification GC a expiré le mot de passe de tous les utilisateurs par excès de prudence, suivant la découverte d’une faille potentielle le 29 mars 2022." "If you don't receive a password reset link in your inbox,","Si vous ne recevez pas de courriel pour réinitialiser votre mot de passe," "please contact our support team.","contactez notre équipe de soutien technique." "A password that is hard to guess contains:","Un mot de passe difficile à deviner contient les caractéristiques suivantes:" @@ -1716,7 +1723,8 @@ "This message exceeds your daily email limit","Ce message dépasse votre limite quotidienne d'envoi de courriels" "You’ve sent too many text messages.","Vous avez envoyé trop de messages texte." "You can send more text messages after {} Eastern Time. To raise your daily limit, {contact_us}.","Vous pourrez envoyer d’autres messages texte après {} heures, heure de l’Est. Pour augmenter votre limite d’envoi quotidienne, {contact_us}." -"You can try sending these messages after {} Eastern Time. Check your current local time.","Vous pourrez envoyer ces messages après {} heures, heure de l’Est. Comparez les heures officielles au Canada." +"You can try sending these messages after {} Eastern Time. Check {}.","Vous pourrez envoyer ces messages après {} heures, heure de l’Est. Comparez {}." +"https://nrc.canada.ca/en/web-clock/","https://nrc.canada.ca/fr/horloge-web/" "Attachment has virus","La pièce jointe contient un virus" "Review reports","Examiner les rapports" "Enter name of your group","Saisissez le nom de votre groupe" @@ -1727,8 +1735,9 @@ "For example: Treasury Board of Canada Secretariat","Par exemple : Secrétariat du Conseil du Trésor du Canada" "For example: Canadian Digital Service","Par exemple : Service numérique canadien" "Not on list?","Vous ne trouvez pas?" +"Not on the list? Add your organization","Vous ne trouvez pas? Ajoutez votre organisation" "Choose name from drop-down menu","Choisissez un nom dans le menu déroulant" -"Tech issue","Problème technique", +"Tech issue","Problème technique" "Content or inbox issue","Problème de contenu ou de boîte de réception" "In transit","Envoi en cours" "Exceeds Protected A","Niveau supérieur à Protégé A" @@ -1777,8 +1786,7 @@ "Annual maximum
(April 1 to March 31)","Maximum par exercice financier" "To request a daily limit above {} emails, {}","Si vous désirez obtenir une limite quotidienne supérieure à {} courriels, veuillez {}" "To request a daily limit above {} text messages, {}","Si vous désirez obtenir une limite quotidienne supérieure à {} messages texte, veuillez {}" -"You can try sending these messages after {} Eastern Time. Check your {}.","Vous pourrez envoyer ces messages après {} heures, heure de l’Est. Comparez {}" -"You can try sending this message after {} Eastern Time. Check your {}.","Vous pourrez envoyer ce message après {} heures, heure de l’Est. Comparez {}" +"You can try sending this message after {} Eastern Time. Check {}.","Vous pourrez envoyer ce message après {} heures, heure de l’Est. Comparez {}." "your current local time","les heures officielles au Canada" "You cannot send this email message today","Vous ne pouvez pas envoyer ce courriel aujourd’hui." "You cannot send this text message today","Vous ne pouvez pas envoyer ce message texte aujourd’hui." @@ -1787,12 +1795,16 @@ "of","de" "Sent since 7 pm Eastern Time","Envoyé depuis 19 h, heure de l'Est" "You are nearing the daily {} limit","Vous approchez de la limite quotidienne de {}" +"Below limit: ","Vous êtes en dessous de votre limite : " +"Near limit: ","Vous approchez de votre limite : " +"At limit: ","Vous avez atteint votre limite : " "Daily usage","Utilisation quotidienne" "Message limits reset each night at 7pm Eastern Time","Les limites d’envoi sont réinitialisées chaque soir à 19 h, heure de l’Est" "Maximum 612 characters. Some messages may be too long due to custom content.","612 caractères au maximum. Certains messages peuvent être trop longs en raison de leur contenu personnalisé." "Too many characters","Limite de caractère atteinte" "New features","Nouvelles fonctionnalités" "Your","Votre" +"your","votre" "You are browsing templates. Create and copy template or add new folder.","Vous explorez les gabarits. Créer et copier un gabarit ou créer un nouveau dossier." "Move templates to a new or existing folder","Déplacer les gabarits dans un dossier" "You are selecting templates. Move templates into a new or existing folder.","Vous sélectionnez les gabarits. Déplacer les gabarits dans un dossier." @@ -1929,7 +1941,7 @@ "Read and agree to the terms of use","Lisez et acceptez les conditions d’utilisation" "Read and agree to continue","Lisez et acceptez les conditions d’utilisation" "Agree follows terms of use","Accepter suite aux conditions d'utilisation" -"Priority","Envoi prioritaire", +"Priority","Envoi prioritaire" "Bulk","Envoi de masse" "Text message priority","Niveau de priorité des messages texte" "Hide category","Visibilité de la catégorie" @@ -2015,12 +2027,19 @@ "Annual text message limit","(FR) Limite maximale de messages texte par exercice financier" "Annual email message limit","(FR) Limite maximale de messages électroniques par exercice financier" "Annual email limit","(FR) Limite maximale de courriels par exercice financier" +"Test response time","Tester le temps de réponse" +"No records found.","Aucun enregistrement trouvé." " and ends March 31, "," et se termine le 31 mars " "Annual limit overview","Aperçu de la limite annuelle" +"Annual overview","Aperçu annuel" "Usage report","Rapport d’utilisation" "Fiscal year begins April 1, ","Réinitialisation le 1er avril " "resets on April 1, ","Réinitialisation le 1er avril " "Annual usage","Utilisation annuelle" "resets at 7pm Eastern Time","Réinitialisation à 19 h, heure de l’Est" "Visit usage report","Consulter le rapport d’utilisation" -"Month by month totals","Totaux mensuels" \ No newline at end of file +"Month by month totals","Totaux mensuels" +"email messages","courriels" +"Sending paused until 7pm ET. You can schedule more messages to send later.","FR: Sending paused until 7pm ET. You can schedule more messages to send later." +"Sending paused until annual limit resets","FR: Sending paused until annual limit resets" +"These messages exceed the annual limit","FR: These messages exceed the annual limit" \ No newline at end of file diff --git a/babel.cfg b/babel.cfg index 19d23b74a1..2c649d5cee 100644 --- a/babel.cfg +++ b/babel.cfg @@ -1 +1,2 @@ [python: app/**.py] +[jinja2: app/templates/**.html] \ No newline at end of file diff --git a/ci/Dockerfile b/ci/Dockerfile index 1520383fa5..f3addf1b18 100644 --- a/ci/Dockerfile +++ b/ci/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10-alpine3.18@sha256:d5ee9613c89c9bd4c4112465d2136512ea8629bce6ff15fa27144f3cc16b5c6b +FROM python:3.12.7-alpine3.20 ENV PYTHONDONTWRITEBYTECODE 1 ENV POETRY_VERSION="1.7.1" diff --git a/ci/Dockerfile.lambda b/ci/Dockerfile.lambda index 9d96e1bc5c..d4f39b59b3 100644 --- a/ci/Dockerfile.lambda +++ b/ci/Dockerfile.lambda @@ -5,7 +5,7 @@ ARG POETRY_VERSION="1.7.1" ARG POETRY_VIRTUALENVS_CREATE="false" # Build image -FROM python:3.10-alpine3.18@sha256:d5ee9613c89c9bd4c4112465d2136512ea8629bce6ff15fa27144f3cc16b5c6b as base +FROM python:3.12-alpine3.20@sha256:5049c050bdc68575a10bcb1885baa0689b6c15152d8a56a7e399fb49f783bf98 as base ARG APP_DIR ARG APP_VENV @@ -68,7 +68,7 @@ RUN . ${APP_VENV}/bin/activate \ && make generate-version-file # Final image -FROM python:3.10-alpine3.18@sha256:d5ee9613c89c9bd4c4112465d2136512ea8629bce6ff15fa27144f3cc16b5c6b as lambda +FROM python:3.12-alpine3.20@sha256:5049c050bdc68575a10bcb1885baa0689b6c15152d8a56a7e399fb49f783bf98 as lambda ARG APP_DIR ARG APP_VENV diff --git a/ci/Dockerfile.test b/ci/Dockerfile.test index 418f87782c..4f59865132 100644 --- a/ci/Dockerfile.test +++ b/ci/Dockerfile.test @@ -1,4 +1,4 @@ -FROM python:3.10-alpine3.16@sha256:afe68972cc00883d70b3760ee0ffbb7375cf09706c122dda7063ffe64c5be21b +FROM python:3.12-alpine3.20@sha256:5049c050bdc68575a10bcb1885baa0689b6c15152d8a56a7e399fb49f783bf98 ENV PYTHONDONTWRITEBYTECODE 1 diff --git a/gunicorn_config.py b/gunicorn_config.py index 63325ca386..cf6e5e7112 100644 --- a/gunicorn_config.py +++ b/gunicorn_config.py @@ -51,6 +51,7 @@ # Start timer for total running time start_time = time.time() + def on_starting(server): server.log.info("Starting Notifications Admin") diff --git a/mypy.ini b/mypy.ini index 2d3d5c255f..05a2cf8485 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,5 +1,5 @@ [mypy] -python_version = 3.10 +python_version = 3.12 [mypy-pytest.*] ignore_missing_imports = True diff --git a/poetry.lock b/poetry.lock index 005883ce93..0c6ff0d2c0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -652,20 +652,6 @@ files = [ {file = "et_xmlfile-1.1.0.tar.gz", hash = "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c"}, ] -[[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, -] - -[package.extras] -test = ["pytest (>=6)"] - [[package]] name = "execnet" version = "2.1.1" @@ -882,7 +868,7 @@ files = [ [package.dependencies] cffi = {version = ">=1.12.2", markers = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""} -greenlet = {version = ">=2.0.0", markers = "platform_python_implementation == \"CPython\" and python_version < \"3.11\""} +greenlet = {version = ">=3.0rc3", markers = "platform_python_implementation == \"CPython\" and python_version >= \"3.11\""} "zope.event" = "*" "zope.interface" = "*" @@ -895,79 +881,88 @@ test = ["cffi (>=1.12.2)", "coverage (>=5.0)", "dnspython (>=1.16.0,<2.0)", "idn [[package]] name = "greenlet" -version = "2.0.2" +version = "3.1.1" description = "Lightweight in-process concurrent programming" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*" -files = [ - {file = "greenlet-2.0.2-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:bdfea8c661e80d3c1c99ad7c3ff74e6e87184895bbaca6ee8cc61209f8b9b85d"}, - {file = "greenlet-2.0.2-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:9d14b83fab60d5e8abe587d51c75b252bcc21683f24699ada8fb275d7712f5a9"}, - {file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"}, - {file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"}, - {file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"}, - {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c"}, - {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"}, - {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"}, - {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"}, - {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470"}, - {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a51c9751078733d88e013587b108f1b7a1fb106d402fb390740f002b6f6551a"}, - {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"}, - {file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"}, - {file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"}, - {file = "greenlet-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47"}, - {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"}, - {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"}, - {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"}, - {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:eff4eb9b7eb3e4d0cae3d28c283dc16d9bed6b193c2e1ace3ed86ce48ea8df19"}, - {file = "greenlet-2.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5454276c07d27a740c5892f4907c86327b632127dd9abec42ee62e12427ff7e3"}, - {file = "greenlet-2.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5"}, - {file = "greenlet-2.0.2-cp35-cp35m-macosx_10_14_x86_64.whl", hash = "sha256:910841381caba4f744a44bf81bfd573c94e10b3045ee00de0cbf436fe50673a6"}, - {file = "greenlet-2.0.2-cp35-cp35m-manylinux2010_x86_64.whl", hash = "sha256:18a7f18b82b52ee85322d7a7874e676f34ab319b9f8cce5de06067384aa8ff43"}, - {file = "greenlet-2.0.2-cp35-cp35m-win32.whl", hash = "sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a"}, - {file = "greenlet-2.0.2-cp35-cp35m-win_amd64.whl", hash = "sha256:4b58adb399c4d61d912c4c331984d60eb66565175cdf4a34792cd9600f21b394"}, - {file = "greenlet-2.0.2-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:703f18f3fda276b9a916f0934d2fb6d989bf0b4fb5a64825260eb9bfd52d78f0"}, - {file = "greenlet-2.0.2-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:32e5b64b148966d9cccc2c8d35a671409e45f195864560829f395a54226408d3"}, - {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dd11f291565a81d71dab10b7033395b7a3a5456e637cf997a6f33ebdf06f8db"}, - {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0f72c9ddb8cd28532185f54cc1453f2c16fb417a08b53a855c4e6a418edd099"}, - {file = "greenlet-2.0.2-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd021c754b162c0fb55ad5d6b9d960db667faad0fa2ff25bb6e1301b0b6e6a75"}, - {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:3c9b12575734155d0c09d6c3e10dbd81665d5c18e1a7c6597df72fd05990c8cf"}, - {file = "greenlet-2.0.2-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:b9ec052b06a0524f0e35bd8790686a1da006bd911dd1ef7d50b77bfbad74e292"}, - {file = "greenlet-2.0.2-cp36-cp36m-win32.whl", hash = "sha256:dbfcfc0218093a19c252ca8eb9aee3d29cfdcb586df21049b9d777fd32c14fd9"}, - {file = "greenlet-2.0.2-cp36-cp36m-win_amd64.whl", hash = "sha256:9f35ec95538f50292f6d8f2c9c9f8a3c6540bbfec21c9e5b4b751e0a7c20864f"}, - {file = "greenlet-2.0.2-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:d5508f0b173e6aa47273bdc0a0b5ba055b59662ba7c7ee5119528f466585526b"}, - {file = "greenlet-2.0.2-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:f82d4d717d8ef19188687aa32b8363e96062911e63ba22a0cff7802a8e58e5f1"}, - {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c9c59a2120b55788e800d82dfa99b9e156ff8f2227f07c5e3012a45a399620b7"}, - {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2780572ec463d44c1d3ae850239508dbeb9fed38e294c68d19a24d925d9223ca"}, - {file = "greenlet-2.0.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937e9020b514ceedb9c830c55d5c9872abc90f4b5862f89c0887033ae33c6f73"}, - {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:36abbf031e1c0f79dd5d596bfaf8e921c41df2bdf54ee1eed921ce1f52999a86"}, - {file = "greenlet-2.0.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:18e98fb3de7dba1c0a852731c3070cf022d14f0d68b4c87a19cc1016f3bb8b33"}, - {file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"}, - {file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"}, - {file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"}, - {file = "greenlet-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1"}, - {file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"}, - {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"}, - {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"}, - {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acd2162a36d3de67ee896c43effcd5ee3de247eb00354db411feb025aa319857"}, - {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:0bf60faf0bc2468089bdc5edd10555bab6e85152191df713e2ab1fcc86382b5a"}, - {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"}, - {file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"}, - {file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"}, - {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417"}, - {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"}, - {file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"}, - {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"}, - {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be4ed120b52ae4d974aa40215fcdfde9194d63541c7ded40ee12eb4dda57b76b"}, - {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94c817e84245513926588caf1152e3b559ff794d505555211ca041f032abbb6b"}, - {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8"}, - {file = "greenlet-2.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9"}, - {file = "greenlet-2.0.2-cp39-cp39-win32.whl", hash = "sha256:ea9872c80c132f4663822dd2a08d404073a5a9b5ba6155bea72fb2a79d1093b5"}, - {file = "greenlet-2.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564"}, - {file = "greenlet-2.0.2.tar.gz", hash = "sha256:e7c8dc13af7db097bed64a051d2dd49e9f0af495c26995c00a9ee842690d34c0"}, +python-versions = ">=3.7" +files = [ + {file = "greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc"}, + {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617"}, + {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7"}, + {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6"}, + {file = "greenlet-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80"}, + {file = "greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383"}, + {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a"}, + {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511"}, + {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395"}, + {file = "greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39"}, + {file = "greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36"}, + {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9"}, + {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0"}, + {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942"}, + {file = "greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01"}, + {file = "greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4"}, + {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e"}, + {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1"}, + {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c"}, + {file = "greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b"}, + {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822"}, + {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01"}, + {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47da355d8687fd65240c364c90a31569a133b7b60de111c255ef5b606f2ae291"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98884ecf2ffb7d7fe6bd517e8eb99d31ff7855a840fa6d0d63cd07c037f6a981"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1d4aeb8891338e60d1ab6127af1fe45def5259def8094b9c7e34690c8858803"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db32b5348615a04b82240cc67983cb315309e88d444a288934ee6ceaebcad6cc"}, + {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dcc62f31eae24de7f8dce72134c8651c58000d3b1868e01392baea7c32c247de"}, + {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1d3755bcb2e02de341c55b4fca7a745a24a9e7212ac953f6b3a48d117d7257aa"}, + {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b8da394b34370874b4572676f36acabac172602abf054cbc4ac910219f3340af"}, + {file = "greenlet-3.1.1-cp37-cp37m-win32.whl", hash = "sha256:a0dfc6c143b519113354e780a50381508139b07d2177cb6ad6a08278ec655798"}, + {file = "greenlet-3.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:54558ea205654b50c438029505def3834e80f0869a70fb15b871c29b4575ddef"}, + {file = "greenlet-3.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:346bed03fe47414091be4ad44786d1bd8bef0c3fcad6ed3dee074a032ab408a9"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfc59d69fc48664bc693842bd57acfdd490acafda1ab52c7836e3fc75c90a111"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21e10da6ec19b457b82636209cbe2331ff4306b54d06fa04b7c138ba18c8a81"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37b9de5a96111fc15418819ab4c4432e4f3c2ede61e660b1e33971eba26ef9ba"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef9ea3f137e5711f0dbe5f9263e8c009b7069d8a1acea822bd5e9dae0ae49c8"}, + {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85f3ff71e2e60bd4b4932a043fbbe0f499e263c628390b285cb599154a3b03b1"}, + {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:95ffcf719966dd7c453f908e208e14cde192e09fde6c7186c8f1896ef778d8cd"}, + {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:03a088b9de532cbfe2ba2034b2b85e82df37874681e8c470d6fb2f8c04d7e4b7"}, + {file = "greenlet-3.1.1-cp38-cp38-win32.whl", hash = "sha256:8b8b36671f10ba80e159378df9c4f15c14098c4fd73a36b9ad715f057272fbef"}, + {file = "greenlet-3.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:7017b2be767b9d43cc31416aba48aab0d2309ee31b4dbf10a1d38fb7972bdf9d"}, + {file = "greenlet-3.1.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145"}, + {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c"}, + {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e"}, + {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e"}, + {file = "greenlet-3.1.1-cp39-cp39-win32.whl", hash = "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c"}, + {file = "greenlet-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22"}, + {file = "greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467"}, ] [package.extras] -docs = ["Sphinx", "docutils (<0.18)"] +docs = ["Sphinx", "furo"] test = ["objgraph", "psutil"] [[package]] @@ -1463,7 +1458,6 @@ files = [ [package.dependencies] mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} typing-extensions = ">=4.6.0" [package.extras] @@ -1552,10 +1546,10 @@ requests = ">=2.0.0" [[package]] name = "notifications-utils" -version = "52.3.9" +version = "53.0.1" description = "Shared python code for Notification - Provides logging utils etc." optional = false -python-versions = "~3.10.9" +python-versions = "~3.12.7" files = [] develop = false @@ -1580,7 +1574,7 @@ pypdf2 = "1.28.6" python-json-logger = "2.0.7" pytz = "2021.3" PyYAML = "6.0.1" -requests = "2.31.0" +requests = "2.32.2" smartypants = "2.0.1" statsd = "3.3.0" werkzeug = "3.0.4" @@ -1588,8 +1582,8 @@ werkzeug = "3.0.4" [package.source] type = "git" url = "https://github.com/cds-snc/notifier-utils.git" -reference = "52.3.9" -resolved_reference = "b344e5a74c79a8fa8ca4f722691850ac0d277959" +reference = "53.0.1" +resolved_reference = "8ee66e4ed598a694b778283573715d7d5b9eeb9c" [[package]] name = "openpyxl" @@ -1918,11 +1912,9 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] @@ -2142,13 +2134,13 @@ hiredis = ["hiredis (>=0.1.3)"] [[package]] name = "requests" -version = "2.31.0" +version = "2.32.2" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, - {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, + {file = "requests-2.32.2-py3-none-any.whl", hash = "sha256:fc06670dd0ed212426dfeb94fc1b983d917c4f9847c863f313c9dfaaffb7c23c"}, + {file = "requests-2.32.2.tar.gz", hash = "sha256:dd951ff5ecf3e3b3aa26b40703ba77495dab41da839ae72ef3c8e5d8e2433289"}, ] [package.dependencies] @@ -2194,29 +2186,29 @@ pyasn1 = ">=0.1.3" [[package]] name = "ruff" -version = "0.6.9" +version = "0.8.2" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.6.9-py3-none-linux_armv6l.whl", hash = "sha256:064df58d84ccc0ac0fcd63bc3090b251d90e2a372558c0f057c3f75ed73e1ccd"}, - {file = "ruff-0.6.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:140d4b5c9f5fc7a7b074908a78ab8d384dd7f6510402267bc76c37195c02a7ec"}, - {file = "ruff-0.6.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53fd8ca5e82bdee8da7f506d7b03a261f24cd43d090ea9db9a1dc59d9313914c"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:645d7d8761f915e48a00d4ecc3686969761df69fb561dd914a773c1a8266e14e"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eae02b700763e3847595b9d2891488989cac00214da7f845f4bcf2989007d577"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d5ccc9e58112441de8ad4b29dcb7a86dc25c5f770e3c06a9d57e0e5eba48829"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:417b81aa1c9b60b2f8edc463c58363075412866ae4e2b9ab0f690dc1e87ac1b5"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c866b631f5fbce896a74a6e4383407ba7507b815ccc52bcedabb6810fdb3ef7"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b118afbb3202f5911486ad52da86d1d52305b59e7ef2031cea3425142b97d6f"}, - {file = "ruff-0.6.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a67267654edc23c97335586774790cde402fb6bbdb3c2314f1fc087dee320bfa"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3ef0cc774b00fec123f635ce5c547dac263f6ee9fb9cc83437c5904183b55ceb"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:12edd2af0c60fa61ff31cefb90aef4288ac4d372b4962c2864aeea3a1a2460c0"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:55bb01caeaf3a60b2b2bba07308a02fca6ab56233302406ed5245180a05c5625"}, - {file = "ruff-0.6.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:925d26471fa24b0ce5a6cdfab1bb526fb4159952385f386bdcc643813d472039"}, - {file = "ruff-0.6.9-py3-none-win32.whl", hash = "sha256:eb61ec9bdb2506cffd492e05ac40e5bc6284873aceb605503d8494180d6fc84d"}, - {file = "ruff-0.6.9-py3-none-win_amd64.whl", hash = "sha256:785d31851c1ae91f45b3d8fe23b8ae4b5170089021fbb42402d811135f0b7117"}, - {file = "ruff-0.6.9-py3-none-win_arm64.whl", hash = "sha256:a9641e31476d601f83cd602608739a0840e348bda93fec9f1ee816f8b6798b93"}, - {file = "ruff-0.6.9.tar.gz", hash = "sha256:b076ef717a8e5bc819514ee1d602bbdca5b4420ae13a9cf61a0c0a4f53a2baa2"}, + {file = "ruff-0.8.2-py3-none-linux_armv6l.whl", hash = "sha256:c49ab4da37e7c457105aadfd2725e24305ff9bc908487a9bf8d548c6dad8bb3d"}, + {file = "ruff-0.8.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ec016beb69ac16be416c435828be702ee694c0d722505f9c1f35e1b9c0cc1bf5"}, + {file = "ruff-0.8.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f05cdf8d050b30e2ba55c9b09330b51f9f97d36d4673213679b965d25a785f3c"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60f578c11feb1d3d257b2fb043ddb47501ab4816e7e221fbb0077f0d5d4e7b6f"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cbd5cf9b0ae8f30eebc7b360171bd50f59ab29d39f06a670b3e4501a36ba5897"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b402ddee3d777683de60ff76da801fa7e5e8a71038f57ee53e903afbcefdaa58"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:705832cd7d85605cb7858d8a13d75993c8f3ef1397b0831289109e953d833d29"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:32096b41aaf7a5cc095fa45b4167b890e4c8d3fd217603f3634c92a541de7248"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e769083da9439508833cfc7c23e351e1809e67f47c50248250ce1ac52c21fb93"}, + {file = "ruff-0.8.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fe716592ae8a376c2673fdfc1f5c0c193a6d0411f90a496863c99cd9e2ae25d"}, + {file = "ruff-0.8.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:81c148825277e737493242b44c5388a300584d73d5774defa9245aaef55448b0"}, + {file = "ruff-0.8.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d261d7850c8367704874847d95febc698a950bf061c9475d4a8b7689adc4f7fa"}, + {file = "ruff-0.8.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1ca4e3a87496dc07d2427b7dd7ffa88a1e597c28dad65ae6433ecb9f2e4f022f"}, + {file = "ruff-0.8.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:729850feed82ef2440aa27946ab39c18cb4a8889c1128a6d589ffa028ddcfc22"}, + {file = "ruff-0.8.2-py3-none-win32.whl", hash = "sha256:ac42caaa0411d6a7d9594363294416e0e48fc1279e1b0e948391695db2b3d5b1"}, + {file = "ruff-0.8.2-py3-none-win_amd64.whl", hash = "sha256:2aae99ec70abf43372612a838d97bfe77d45146254568d94926e8ed5bbb409ea"}, + {file = "ruff-0.8.2-py3-none-win_arm64.whl", hash = "sha256:fb88e2a506b70cfbc2de6fae6681c4f944f7dd5f2fe87233a7233d888bad73e8"}, + {file = "ruff-0.8.2.tar.gz", hash = "sha256:b84f4f414dda8ac7f75075c1fa0b905ac0ff25361f42e6d5da681a465e0f78e5"}, ] [[package]] @@ -2757,5 +2749,5 @@ testing = ["coverage (>=5.0.3)", "zope.event", "zope.testing"] [metadata] lock-version = "2.0" -python-versions = "~3.10.9" -content-hash = "443df8a67497588c1801bfac747fde95ecaffda93675b6f038906750e891316b" +python-versions = "~3.12.7" +content-hash = "8dde551218693f0c7fbcf66efd7c34b508f5d3007d00a91f14d41058e23a09ab" diff --git a/pyproject.toml b/pyproject.toml index e44d4352e3..e102337b19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ requires = ["poetry-core>=1.7.1"] build-backend = "poetry.core.masonry.api" [tool.poetry.dependencies] -python = "~3.10.9" +python = "~3.12.7" timeago = "1.0.16" Flask = "2.3.3" Flask-WTF = "1.2.1" @@ -43,13 +43,13 @@ user-agents = "2.2.0" WTForms = "3.1.2" email-validator = "1.3.1" Werkzeug = "3.0.4" -greenlet = "2.0.2" +greenlet = "3.1.1" mixpanel = "4.10.1" unidecode = "^1.3.8" # PaaS awscli-cwlogs = "^1.4.6" -notifications-utils = { git = "https://github.com/cds-snc/notifier-utils.git", tag = "52.3.9" } +notifications-utils = { git = "https://github.com/cds-snc/notifier-utils.git", tag = "53.0.1"} # Pinned dependencies @@ -78,7 +78,8 @@ jinja2-cli = { version = "^0.8.2", extras = ["yaml"] } mypy = "1.11.2" monkeytype = "23.3.0" poethepoet = "^0.24.4" -ruff = "^0.6.9" +pre-commit = "^3.7.1" +ruff = "^0.8.2" # stubs libraries to keep mypy happy types-python-dateutil = "2.9.0.20241003" diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 97cca72678..9dac814985 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -31,6 +31,9 @@ display_result $? 1 "Code style check" ruff check --select I . display_result $? 1 "Import order check" +ruff format --check . +display_result $? 1 "Code format check" + mypy ./ display_result $? 1 "Type check" diff --git a/tests/app/articles/test_fallback_cache.py b/tests/app/articles/test_fallback_cache.py index 5baeb9b649..5d91554175 100644 --- a/tests/app/articles/test_fallback_cache.py +++ b/tests/app/articles/test_fallback_cache.py @@ -71,5 +71,5 @@ def test_retrieve_existing_from_fallback_cache_on_http_error(app_, mocker): get_content(endpoint, {"slug": "mypage", "lang": "en"}, cacheable=True) assert mock_redis_method.get.called - assert mock_redis_method.get.called_with(cache_key) + mock_redis_method.get.assert_called_with(cache_key) assert mock_redis_method.get(cache_key) == json.dumps(response_json) diff --git a/tests/app/articles/test_pages.py b/tests/app/articles/test_pages.py index bc10fba10a..7a62dbb443 100644 --- a/tests/app/articles/test_pages.py +++ b/tests/app/articles/test_pages.py @@ -40,7 +40,7 @@ def test_get_page_by_slug_with_cache_retrieve_from_cache(app_, mocker): assert mock_redis_method.get.called assert mock_redis_method.get.call_count == 1 - assert mock_redis_method.get.called_with(cache_key) + mock_redis_method.get.assert_called_with(cache_key) assert mock_redis_method.get(cache_key) is not None assert not request_mock.called diff --git a/tests/app/main/views/test_dashboard.py b/tests/app/main/views/test_dashboard.py index a00980bc6c..3be02e81d6 100644 --- a/tests/app/main/views/test_dashboard.py +++ b/tests/app/main/views/test_dashboard.py @@ -314,6 +314,7 @@ def test_should_show_monthly_breakdown_of_template_usage( def test_anyone_can_see_monthly_breakdown( client, api_user_active, service_one, mocker, mock_get_monthly_notification_stats, mock_get_service_statistics ): + mocker.patch("app.main.views.dashboard.annual_limit_client.get_all_notification_counts", return_value={"data": service_one}) validate_route_permission_with_client( mocker, client, @@ -327,8 +328,9 @@ def test_anyone_can_see_monthly_breakdown( def test_monthly_shows_letters_in_breakdown( - client_request, service_one, mock_get_monthly_notification_stats, mock_get_service_statistics + client_request, service_one, mocker, mock_get_monthly_notification_stats, mock_get_service_statistics ): + mocker.patch("app.main.views.dashboard.annual_limit_client.get_all_notification_counts", return_value={"data": service_one}) page = client_request.get("main.monthly", service_id=service_one["id"]) columns = page.select(".table-field-left-aligned .big-number-label") @@ -346,8 +348,15 @@ def test_monthly_shows_letters_in_breakdown( ) @freeze_time("2015-01-01 15:15:15.000000") def test_stats_pages_show_last_3_years( - client_request, endpoint, mock_get_monthly_notification_stats, mock_get_monthly_template_usage, mock_get_service_statistics + client_request, + endpoint, + service_one, + mocker, + mock_get_monthly_notification_stats, + mock_get_monthly_template_usage, + mock_get_service_statistics, ): + mocker.patch("app.main.views.dashboard.annual_limit_client.get_all_notification_counts", return_value={"data": service_one}) page = client_request.get( endpoint, service_id=SERVICE_ONE_ID, @@ -359,8 +368,9 @@ def test_stats_pages_show_last_3_years( def test_monthly_has_equal_length_tables( - client_request, service_one, mock_get_monthly_notification_stats, mock_get_service_statistics + client_request, service_one, mocker, mock_get_monthly_notification_stats, mock_get_service_statistics ): + mocker.patch("app.main.views.dashboard.annual_limit_client.get_all_notification_counts", return_value={"data": service_one}) page = client_request.get("main.monthly", service_id=service_one["id"]) assert page.select_one(".table-field-headings th")["style"] == "width: 33%" @@ -1572,7 +1582,7 @@ def test_usage_report_aggregates_calculated_properly_without_redis( # mock annual_limit_client.get_all_notification_counts mocker.patch( "app.main.views.dashboard.annual_limit_client.get_all_notification_counts", - return_value=None, + return_value={"sms_delivered": 0, "email_delivered": 0, "sms_failed": 0, "email_failed": 0}, ) mocker.patch( diff --git a/tests/app/main/views/test_notifications.py b/tests/app/main/views/test_notifications.py index a0b3eb690c..16110de5e2 100644 --- a/tests/app/main/views/test_notifications.py +++ b/tests/app/main/views/test_notifications.py @@ -814,6 +814,7 @@ def test_notification_page_has_expected_template_link_for_letter( assert link is None +@pytest.mark.skip(reason="feature not in use") def test_should_show_image_of_precompiled_letter_notification( logged_in_client, fake_uuid, diff --git a/tests/app/main/views/test_send.py b/tests/app/main/views/test_send.py index 78df4b10cc..706abb69ad 100644 --- a/tests/app/main/views/test_send.py +++ b/tests/app/main/views/test_send.py @@ -6,6 +6,7 @@ from io import BytesIO from itertools import repeat from os import path +from unittest.mock import patch from uuid import uuid4 from zipfile import BadZipFile @@ -41,6 +42,7 @@ mock_get_service_letter_template, mock_get_service_template, normalize_spaces, + set_config, ) template_types = ["email", "sms"] @@ -2543,6 +2545,7 @@ def test_check_messages_shows_too_many_sms_messages_errors( mock_get_jobs, mock_s3_download, mock_s3_set_metadata, + mock_get_limit_stats, fake_uuid, num_requested, expected_msg, @@ -2584,6 +2587,30 @@ def test_check_messages_shows_too_many_sms_messages_errors( assert details == expected_msg +@pytest.fixture +def mock_notification_counts_client(): + with patch("app.main.views.send.notification_counts_client") as mock: + yield mock + + +@pytest.fixture +def mock_daily_sms_fragment_count(): + with patch("app.main.views.send.daily_sms_fragment_count") as mock: + yield mock + + +@pytest.fixture +def mock_daily_email_count(): + with patch("app.main.views.send.daily_email_count") as mock: + yield mock + + +@pytest.fixture +def mock_get_service_template_annual_limits(): + with patch("app.service_api_client.get_service_template") as mock: + yield mock + + @pytest.mark.parametrize( "num_requested,expected_msg", [ @@ -2601,6 +2628,7 @@ def test_check_messages_shows_too_many_email_messages_errors( mock_get_template_statistics, mock_get_job_doesnt_exist, mock_get_jobs, + mock_get_limit_stats, fake_uuid, num_requested, expected_msg, @@ -2723,49 +2751,6 @@ def test_warns_if_file_sent_already( mock_get_jobs.assert_called_once_with(SERVICE_ONE_ID, limit_days=0) -def test_check_messages_column_error_doesnt_show_optional_columns( - mocker, - client_request, - mock_get_service_letter_template, - mock_has_permissions, - fake_uuid, - mock_get_users_by_service, - mock_get_service_statistics, - mock_get_template_statistics, - mock_get_job_doesnt_exist, - mock_get_jobs, -): - mocker.patch( - "app.main.views.send.s3download", - return_value="\n".join(["address_line_1,address_line_2,foo"] + ["First Lastname,1 Example Road,SW1 1AA"]), - ) - - mocker.patch( - "app.main.views.send.get_page_count_for_letter", - return_value=5, - ) - - with client_request.session_transaction() as session: - session["file_uploads"] = { - fake_uuid: { - "template_id": "", - "original_file_name": "", - } - } - - page = client_request.get( - "main.check_messages", - service_id=SERVICE_ONE_ID, - template_id=fake_uuid, - upload_id=fake_uuid, - _test_page_title=False, - ) - - assert normalize_spaces(page.select_one(".banner-dangerous").text) == ( - "Your spreadsheet is missing a column called ‘postcode’. " "Add the missing column." - ) - - def test_check_messages_adds_sender_id_in_session_to_metadata( client_request, mocker, @@ -3401,3 +3386,394 @@ class Object(object): multiple_choise_options = [x.text.strip() for x in options] assert multiple_choise_options == expected_filenames + + +class TestAnnualLimitsSend: + @pytest.mark.parametrize( + "num_being_sent, num_sent_today, num_sent_this_year, expect_to_see_annual_limit_msg, expect_to_see_daily_limit_msg", + [ + # annual limit for mock_get_live_service is 10,000email/10,000sms + # daily limit for mock_get_live_service is 1,000email/1,000sms + # 1000 have already been sent today, trying to send 100 more [over both limits] + (100, 1000, 10000, True, False), + # No sent yet today or this year, trying to send 1001 [over both limits] + (10001, 0, 0, True, False), + # 600 have already been sent this year, trying to send 500 more [over annual limit but not daily] + (500, 0, 9600, True, False), + # No sent yet today or this year, trying to send 1001 [over daily limit but not annual] + (1001, 0, 0, False, True), + # No sent yet today or this year, trying to send 100 [over neither limit] + (100, 0, 0, False, False), + ], + ids=[ + "email_over_both_limits", + "email_over_both_limits2", + "email_over_annual_but_not_daily", + "email_over_daily_but_not_annual", + "email_over_neither", + ], + ) + def test_email_send_fails_approrpiately_when_over_limits( + self, + mocker, + client_request, + mock_get_live_service, # set email_annual_limit and sms_annual_limit to 1000 + mock_get_users_by_service, + mock_get_service_email_template_without_placeholders, + mock_get_template_statistics, + mock_get_job_doesnt_exist, + mock_get_jobs, + mock_s3_set_metadata, + mock_notification_counts_client, + mock_daily_sms_fragment_count, + mock_daily_email_count, + fake_uuid, + num_being_sent, + num_sent_today, + num_sent_this_year, + expect_to_see_annual_limit_msg, + expect_to_see_daily_limit_msg, + app_, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): + mocker.patch( + "app.main.views.send.s3download", + return_value=",\n".join( + ["email address"] + ([mock_get_users_by_service(None)[0]["email_address"]] * num_being_sent) + ), + ) + + mock_notification_counts_client.get_limit_stats.return_value = { + "email": { + "annual": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": 10000 + - num_sent_this_year + - num_sent_today, # The number of email notifications remaining this year + }, + "daily": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": 1000 - num_sent_today, # The number of email notifications remaining today + }, + } + } + + # mock that we've already sent `emails_sent_today` emails today + mock_daily_email_count.return_value = num_sent_today + mock_daily_sms_fragment_count.return_value = 900 # not used in test but needs a value + + with client_request.session_transaction() as session: + session["file_uploads"] = { + fake_uuid: { + "template_id": fake_uuid, + "notification_count": 1, + "valid": True, + } + } + + page = client_request.get( + "main.check_messages", + service_id=SERVICE_ONE_ID, + template_id=fake_uuid, + upload_id=fake_uuid, + original_file_name="valid.csv", + _test_page_title=False, + ) + + if expect_to_see_annual_limit_msg: + assert page.find(attrs={"data-testid": "exceeds-annual"}) is not None + else: + assert page.find(attrs={"data-testid": "exceeds-annual"}) is None + + if expect_to_see_daily_limit_msg: + assert page.find(attrs={"data-testid": "exceeds-daily"}) is not None + else: + assert page.find(attrs={"data-testid": "exceeds-daily"}) is None + + @pytest.mark.parametrize( + "num_being_sent, num_sent_today, num_sent_this_year, expect_to_see_annual_limit_msg, expect_to_see_daily_limit_msg", + [ + # annual limit for mock_get_live_service is 10,000email/10,000sms + # daily limit for mock_get_live_service is 1,000email/1,000sms + # 1000 have already been sent today, trying to send 100 more [over both limits] + (100, 1000, 10000, True, False), + # No sent yet today or this year, trying to send 1001 [over both limits] + (10001, 0, 0, True, False), + # 600 have already been sent this year, trying to send 500 more [over annual limit but not daily] + (500, 0, 9600, True, False), + # No sent yet today or this year, trying to send 1001 [over daily limit but not annual] + (1001, 0, 0, False, True), + # No sent yet today or this year, trying to send 100 [over neither limit] + (100, 0, 0, False, False), + ], + ids=[ + "sms_over_both_limits", + "sms_over_both_limits2", + "sms_over_annual_but_not_daily", + "sms_over_daily_but_not_annual", + "sms_over_neither", + ], + ) + def test_sms_send_fails_approrpiately_when_over_limits( + self, + mocker, + client_request, + mock_get_live_service, # set email_annual_limit and sms_annual_limit to 1000 + mock_get_users_by_service, + mock_get_service_sms_template_without_placeholders, + mock_get_template_statistics, + mock_get_job_doesnt_exist, + mock_get_jobs, + mock_s3_set_metadata, + mock_notification_counts_client, + mock_daily_sms_fragment_count, + mock_daily_email_count, + fake_uuid, + num_being_sent, + num_sent_today, + num_sent_this_year, + expect_to_see_annual_limit_msg, + expect_to_see_daily_limit_msg, + app_, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): # REMOVE LINE WHEN FF REMOVED + mocker.patch( + "app.main.views.send.s3download", + return_value=",\n".join( + ["phone number"] + ([mock_get_users_by_service(None)[0]["mobile_number"]] * num_being_sent) + ), + ) + mock_notification_counts_client.get_limit_stats.return_value = { + "sms": { + "annual": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": 10000 + - num_sent_this_year + - num_sent_today, # The number of email notifications remaining this year + }, + "daily": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": 1000 - num_sent_today, # The number of email notifications remaining today + }, + } + } + # mock that we've already sent `num_sent_today` emails today + mock_daily_email_count.return_value = 900 # not used in test but needs a value + mock_daily_sms_fragment_count.return_value = num_sent_today + + with client_request.session_transaction() as session: + session["file_uploads"] = { + fake_uuid: { + "template_id": fake_uuid, + "notification_count": 1, + "valid": True, + } + } + + page = client_request.get( + "main.check_messages", + service_id=SERVICE_ONE_ID, + template_id=fake_uuid, + upload_id=fake_uuid, + original_file_name="valid.csv", + _test_page_title=False, + ) + + if expect_to_see_annual_limit_msg: + assert page.find(attrs={"data-testid": "exceeds-annual"}) is not None + else: + assert page.find(attrs={"data-testid": "exceeds-annual"}) is None + + if expect_to_see_daily_limit_msg: + assert page.find(attrs={"data-testid": "exceeds-daily"}) is not None + else: + assert page.find(attrs={"data-testid": "exceeds-daily"}) is None + + @pytest.mark.parametrize( + "num_to_send, remaining_daily, remaining_annual, error_shown", + [ + (2, 2, 2, "none"), + (5, 5, 4, "annual"), + (5, 4, 5, "daily"), + (5, 4, 4, "annual"), + ], + ) + def test_correct_error_displayed( + self, + mocker, + client_request, + mock_get_live_service, # set email_annual_limit and sms_annual_limit to 1000 + mock_get_users_by_service, + mock_get_service_email_template_without_placeholders, + mock_get_template_statistics, + mock_get_job_doesnt_exist, + mock_get_jobs, + mock_s3_set_metadata, + mock_daily_email_count, + mock_notification_counts_client, + fake_uuid, + num_to_send, + remaining_daily, + remaining_annual, + error_shown, + app_, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): # REMOVE LINE WHEN FF REMOVED + # mock that `num_sent_this_year` have already been sent this year + mock_notification_counts_client.get_limit_stats.return_value = { + "email": { + "annual": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": remaining_annual, # The number of email notifications remaining this year + }, + "daily": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": remaining_daily, # The number of email notifications remaining today + }, + } + } + + # only change this value when we're expecting an error + if error_shown != "none": + mock_daily_email_count.return_value = 1000 - ( + num_to_send - 1 + ) # svc limit is 1000 - exceeding the daily limit is calculated based off of this + else: + mock_daily_email_count.return_value = 0 # none sent + + mocker.patch( + "app.main.views.send.s3download", + return_value=",\n".join( + ["email address"] + ([mock_get_users_by_service(None)[0]["email_address"]] * num_to_send) + ), + ) + with client_request.session_transaction() as session: + session["file_uploads"] = { + fake_uuid: { + "template_id": fake_uuid, + "notification_count": 1, + "valid": True, + } + } + page = client_request.get( + "main.check_messages", + service_id=SERVICE_ONE_ID, + template_id=fake_uuid, + upload_id=fake_uuid, + original_file_name="valid.csv", + _test_page_title=False, + ) + + if error_shown == "annual": + assert page.find(attrs={"data-testid": "exceeds-annual"}) is not None + assert page.find(attrs={"data-testid": "exceeds-daily"}) is None + elif error_shown == "daily": + assert page.find(attrs={"data-testid": "exceeds-annual"}) is None + assert page.find(attrs={"data-testid": "exceeds-daily"}) is not None + elif error_shown == "none": + assert page.find(attrs={"data-testid": "exceeds-annual"}) is None + assert page.find(attrs={"data-testid": "exceeds-daily"}) is None + + @pytest.mark.parametrize( + "notification_type, exception_msg_api, expected_error_msg_admin", + [ + ("email", "Exceeded annual email sending", "These messages exceed the annual limit"), + ("sms", "Exceeded annual SMS sending", "These messages exceed the annual limit"), + ], + ) + def test_error_msgs_from_api_for_one_off( + self, + client_request, + service_one, + fake_uuid, + mocker, + mock_get_service_template_with_placeholders, + mock_get_template_statistics, + notification_type, + exception_msg_api, + expected_error_msg_admin, + ): + class MockHTTPError(HTTPError): + message = exception_msg_api + + mocker.patch( + "app.notification_api_client.send_notification", + side_effect=MockHTTPError(), + ) + + if notification_type == "sms": + with client_request.session_transaction() as session: + session["recipient"] = "6502532223" + session["placeholders"] = {"name": "a" * 600} + elif notification_type == "email": + with client_request.session_transaction() as session: + session["recipient"] = "test@example.com" + session["placeholders"] = {"name": "a" * 600} + + page = client_request.post( + "main.send_notification", + service_id=service_one["id"], + template_id=fake_uuid, + _expected_status=200, + ) + + assert normalize_spaces(page.select("h1")[0].text) == expected_error_msg_admin + + @pytest.mark.parametrize( + "exception_msg_api, expected_error_msg_admin", + [ + # ("email","Exceeded annual email sending", "These messages exceed the annual limit"), + ("Exceeded annual SMS sending", "These messages exceed the annual limit") + ], + ) + def test_error_msgs_from_api_for_bulk( + self, + client_request, + mock_create_job, + mock_get_job, + mock_get_notifications, + mock_get_service_template, + mock_get_service_data_retention, + mocker, + fake_uuid, + exception_msg_api, + expected_error_msg_admin, + ): + class MockHTTPError(HTTPError): + message = exception_msg_api + + data = mock_get_job(SERVICE_ONE_ID, fake_uuid)["data"] + job_id = data["id"] + original_file_name = data["original_file_name"] + template_id = data["template"] + notification_count = data["notification_count"] + with client_request.session_transaction() as session: + session["file_uploads"] = { + fake_uuid: { + "template_id": template_id, + "notification_count": notification_count, + "valid": True, + } + } + + mocker.patch( + "app.job_api_client.create_job", + side_effect=MockHTTPError(), + ) + page = client_request.post( + "main.start_job", + service_id=SERVICE_ONE_ID, + upload_id=job_id, + original_file_name=original_file_name, + _data={}, + _follow_redirects=True, + _expected_status=200, + ) + + assert normalize_spaces(page.select("h1")[0].text) == expected_error_msg_admin diff --git a/tests/app/main/views/test_templates.py b/tests/app/main/views/test_templates.py index b48234af1f..333dced2a8 100644 --- a/tests/app/main/views/test_templates.py +++ b/tests/app/main/views/test_templates.py @@ -1,6 +1,6 @@ from datetime import datetime from functools import partial -from unittest.mock import ANY, MagicMock, Mock +from unittest.mock import ANY, MagicMock, Mock, patch import pytest from flask import url_for @@ -49,11 +49,18 @@ fake_uuid, mock_get_service_template_with_process_type, normalize_spaces, + set_config, ) DEFAULT_PROCESS_TYPE = TemplateProcessTypes.BULK.value +@pytest.fixture +def mock_notification_counts_client(): + with patch("app.main.views.templates.notification_counts_client") as mock: + yield mock + + class TestRedisPreviewUtilities: def test_set_get(self, fake_uuid, mocker): mock_redis_obj = MockRedis() @@ -113,6 +120,7 @@ def test_create_email_template_cat_other_to_freshdesk( mock_get_service_template_when_no_template_exists, mock_get_template_categories, mock_send_other_category_to_freshdesk, + mock_get_limit_stats, active_user_with_permissions, fake_uuid, app_, @@ -147,6 +155,7 @@ def test_edit_email_template_cat_other_to_freshdesk( mock_get_template_categories, mock_update_service_template, mock_send_other_category_to_freshdesk, + mock_get_limit_stats, active_user_with_permissions, fake_uuid, app_, @@ -490,7 +499,13 @@ def test_should_show_page_for_one_template( def test_caseworker_redirected_to_one_off( - client_request, mock_get_service_templates, mock_get_service_template, mocker, fake_uuid, active_caseworking_user + client_request, + mock_get_service_templates, + mock_get_service_template, + mock_get_limit_stats, + mocker, + fake_uuid, + active_caseworking_user, ): client_request.login(active_caseworking_user) client_request.get( @@ -510,6 +525,7 @@ def test_user_with_only_send_and_view_redirected_to_one_off( client_request, mock_get_service_templates, mock_get_service_template, + mock_get_limit_stats, active_user_with_permissions, mocker, fake_uuid, @@ -532,40 +548,6 @@ def test_user_with_only_send_and_view_redirected_to_one_off( ) -@pytest.mark.parametrize( - "permissions", - ( - {"send_messages", "view_activity"}, - {"send_messages"}, - {"view_activity"}, - {}, - ), -) -def test_user_with_only_send_and_view_sees_letter_page( - client_request, - mock_get_service_templates, - mock_get_template_folders, - mock_get_service_letter_template, - single_letter_contact_block, - mock_has_jobs, - active_user_with_permissions, - mocker, - fake_uuid, - permissions, -): - mocker.patch("app.main.views.templates.get_page_count_for_letter", return_value=1) - active_user_with_permissions["permissions"][SERVICE_ONE_ID] = permissions - client_request.login(active_user_with_permissions) - page = client_request.get( - "main.view_template", - service_id=SERVICE_ONE_ID, - template_id=fake_uuid, - _test_page_title=False, - ) - assert normalize_spaces(page.select_one("h1").text) == ("Two week reminder") - assert normalize_spaces(page.select_one("title").text) == ("Two week reminder – Templates - service one – Notify") - - @pytest.mark.parametrize( "letter_branding, expected_link, expected_link_text", ( @@ -610,46 +592,11 @@ def test_letter_with_default_branding_has_add_logo_button( assert first_edit_link.text == expected_link_text -@pytest.mark.parametrize( - "template_postage,expected_result", - [ - ("first", "Postage: first class"), - ("second", "Postage: second class"), - ], -) -def test_view_letter_template_displays_postage( - client_request, - service_one, - mock_get_service_templates, - mock_get_template_folders, - single_letter_contact_block, - mock_has_jobs, - active_user_with_permissions, - mocker, - fake_uuid, - template_postage, - expected_result, -): - mocker.patch("app.main.views.templates.get_page_count_for_letter", return_value=1) - client_request.login(active_user_with_permissions) - - template = create_letter_template(postage=template_postage) - mocker.patch("app.service_api_client.get_service_template", return_value=template) - - page = client_request.get( - "main.view_template", - service_id=SERVICE_ONE_ID, - template_id=template["data"]["id"], - _test_page_title=False, - ) - - assert normalize_spaces(page.select_one(".letter-postage").text) == expected_result - - def test_view_non_letter_template_does_not_display_postage( client_request, mock_get_service_template, mock_get_template_folders, + mock_get_limit_stats, fake_uuid, ): page = client_request.get( @@ -740,6 +687,7 @@ def test_should_be_able_to_view_a_template_with_links( client_request, mock_get_service_template, mock_get_template_folders, + mock_get_limit_stats, active_user_with_permissions, single_letter_contact_block, fake_uuid, @@ -777,6 +725,7 @@ def test_should_show_template_id_on_template_page( mock_get_service_template, mock_get_template_folders, fake_uuid, + mock_get_limit_stats, ): page = client_request.get( ".view_template", @@ -792,6 +741,7 @@ def test_should_show_logos_on_template_page( fake_uuid, mocker, service_one, + mock_get_limit_stats, app_, ): mocker.patch( @@ -817,6 +767,7 @@ def test_should_not_show_send_buttons_on_template_page_for_user_without_permissi client_request, fake_uuid, mock_get_service_template, + mock_get_limit_stats, active_user_view_permissions, ): client_request.login(active_user_view_permissions) @@ -838,6 +789,7 @@ def test_should_show_sms_template_with_downgraded_unicode_characters( service_one, single_letter_contact_block, mock_get_template_folders, + mock_get_limit_stats, fake_uuid, ): msg = "here:\tare some “fancy quotes” and zero\u200bwidth\u200bspaces" @@ -1335,6 +1287,7 @@ def test_should_redirect_when_saving_a_template( client_request, mock_get_template_categories, mock_update_service_template, + mock_get_limit_stats, fake_uuid, app_, mocker, @@ -2032,6 +1985,7 @@ def test_should_show_delete_template_page_with_time_block( client_request, mock_get_service_template, mock_get_template_folders, + mock_get_limit_stats, mocker, fake_uuid, ): @@ -2060,11 +2014,7 @@ def test_should_show_delete_template_page_with_time_block( def test_should_show_delete_template_page_with_time_block_for_empty_notification( - client_request, - mock_get_service_template, - mock_get_template_folders, - mocker, - fake_uuid, + client_request, mock_get_service_template, mock_get_template_folders, mocker, fake_uuid, mock_get_limit_stats ): with freeze_time("2012-01-08 12:00:00"): template = template_json("1234", "1234", "Test template", "sms", "Something very interesting") @@ -2095,6 +2045,7 @@ def test_should_show_delete_template_page_with_never_used_block( client_request, mock_get_service_template, mock_get_template_folders, + mock_get_limit_stats, fake_uuid, mocker, ): @@ -2168,6 +2119,7 @@ def test_should_show_page_for_a_deleted_template( mock_get_user, mock_get_user_by_email, mock_has_permissions, + mock_notification_counts_client, fake_uuid, ): template_id = fake_uuid @@ -2214,6 +2166,7 @@ def test_route_permissions( mock_get_template_folders, mock_get_template_statistics_for_template, mock_get_template_categories, + mock_get_limit_stats, fake_uuid, ): validate_route_permission( @@ -2323,6 +2276,7 @@ def test_can_create_email_template_with_emoji( mock_get_template_folders, mock_get_service_template_when_no_template_exists, mock_get_template_categories, + mock_get_limit_stats, app_, ): page = client_request.post( @@ -2365,6 +2319,7 @@ def test_create_template_with_process_types( mock_get_template_folders, mock_get_service_template_when_no_template_exists, mock_get_template_categories, + mock_get_limit_stats, app_, mocker, platform_admin_user, @@ -2478,6 +2433,7 @@ def test_should_create_sms_template_without_downgrading_unicode_characters( def test_should_show_message_before_redacting_template( client_request, mock_get_service_template, + mock_get_limit_stats, service_one, fake_uuid, ): @@ -2501,6 +2457,7 @@ def test_should_show_redact_template( mock_get_service_template, mock_get_template_folders, mock_redact_template, + mock_get_limit_stats, single_letter_contact_block, service_one, fake_uuid, @@ -2524,6 +2481,7 @@ def test_should_show_hint_once_template_redacted( mocker, service_one, mock_get_template_folders, + mock_get_limit_stats, fake_uuid, ): template = create_template(redact_personalisation=True) @@ -2539,27 +2497,6 @@ def test_should_show_hint_once_template_redacted( assert page.select(".hint")[0].text.strip() == "Recipients' information will be redacted from system" -def test_should_not_show_redaction_stuff_for_letters( - client_request, - mocker, - fake_uuid, - mock_get_service_letter_template, - mock_get_template_folders, - single_letter_contact_block, -): - mocker.patch("app.main.views.templates.get_page_count_for_letter", return_value=1) - - page = client_request.get( - "main.view_template", - service_id=SERVICE_ONE_ID, - template_id=fake_uuid, - _test_page_title=False, - ) - - assert page.select(".hint") == [] - assert "personalisation" not in " ".join(link.text.lower() for link in page.select("a")) - - def test_set_template_sender( client_request, fake_uuid, @@ -2677,6 +2614,7 @@ def test_template_should_show_email_address_in_correct_language( client_request, mock_get_service_email_template, mock_get_template_folders, + mock_get_limit_stats, fake_uuid, ): # check english @@ -2705,6 +2643,7 @@ def test_template_should_show_phone_number_in_correct_language( client_request, mock_get_service_template, mock_get_template_folders, + mock_get_limit_stats, fake_uuid, ): # check english @@ -2742,3 +2681,66 @@ def test_should_hide_category_name_from_template_list_if_marked_hidden( # assert that "HIDDEN_CATEGORY" is not found anywhere in the page using beautifulsoup assert "HIDDEN_CATEGORY" not in page.text assert not page.find(text="HIDDEN_CATEGORY") + + +class TestAnnualLimits: + @pytest.mark.parametrize( + "remaining_daily, remaining_annual, buttons_shown", + [ + (10, 100, True), # Within both limits + (0, 100, False), # Exceeds daily limit + (10, 0, False), # Exceeds annual limit + (0, 0, False), # Exceeds both limits + (1, 1, True), # Exactly at both limits + ], + ) + def test_should_hide_send_buttons_when_appropriate( + self, + client_request, + mock_get_service_template, + mock_get_template_folders, + mock_notification_counts_client, + fake_uuid, + remaining_daily, + remaining_annual, + buttons_shown, + app_, + ): + with set_config(app_, "FF_ANNUAL_LIMIT", True): # REMOVE LINE WHEN FF REMOVED + mock_notification_counts_client.get_limit_stats.return_value = { + "email": { + "annual": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": remaining_annual, # The number of email notifications remaining this year + }, + "daily": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": remaining_daily, # The number of email notifications remaining today + }, + }, + "sms": { + "annual": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": remaining_annual, # The number of email notifications remaining this year + }, + "daily": { + "limit": 1, # doesn't matter for our test + "sent": 1, # doesn't matter for our test + "remaining": remaining_daily, # The number of email notifications remaining today + }, + }, + } + + page = client_request.get( + ".view_template", + service_id=SERVICE_ONE_ID, + template_id=fake_uuid, + _test_page_title=False, + ) + if buttons_shown: + assert page.find(attrs={"data-testid": "send-buttons"}) is not None + else: + assert page.find(attrs={"data-testid": "send-buttons"}) is None diff --git a/tests/app/notify_client/test_notification_counts_client.py b/tests/app/notify_client/test_notification_counts_client.py new file mode 100644 index 0000000000..3d4f510bca --- /dev/null +++ b/tests/app/notify_client/test_notification_counts_client.py @@ -0,0 +1,202 @@ +from datetime import datetime +from unittest.mock import Mock, patch + +import pytest + +from app.notify_client.notification_counts_client import NotificationCounts + + +@pytest.fixture +def mock_redis(): + with patch("app.notify_client.notification_counts_client.redis_client") as mock: + yield mock + + +@pytest.fixture +def mock_template_stats(): + with patch("app.notify_client.notification_counts_client.template_statistics_client") as mock: + yield mock + + +@pytest.fixture +def mock_service_api(): + with patch("app.notify_client.notification_counts_client.service_api_client") as mock: + yield mock + + +@pytest.fixture +def mock_get_all_notification_counts_for_today(): + with patch("app.notify_client.notification_counts_client.get_all_notification_counts_for_today") as mock: + yield mock + + +class TestNotificationCounts: + def test_get_all_notification_counts_for_today_redis_has_data(self, mock_redis): + # Setup + mock_redis.get.side_effect = [5, 10] # sms, email + wrapper = NotificationCounts() + + # Execute + result = wrapper.get_all_notification_counts_for_today("service-123") + + # Assert + assert result == {"sms": 5, "email": 10} + assert mock_redis.get.call_count == 2 + + @pytest.mark.parametrize( + "redis_side_effect, expected_result", + [ + ([None, None], {"sms": 10, "email": 10}), + ([None, 10], {"sms": 10, "email": 10}), # Falls back to API if either is None + ([10, None], {"sms": 10, "email": 10}), # Falls back to API if either is None + ([25, 25], {"sms": 25, "email": 25}), # Falls back to API if either is None + ], + ) + def test_get_all_notification_counts_for_today_redis_missing_data( + self, mock_redis, mock_template_stats, redis_side_effect, expected_result + ): + # Setup + mock_redis.get.side_effect = redis_side_effect + mock_template_stats.get_template_statistics_for_service.return_value = [ + {"template_id": "a1", "template_type": "sms", "count": 3, "status": "delivered"}, + {"template_id": "a2", "template_type": "email", "count": 7, "status": "temporary-failure"}, + {"template_id": "a3", "template_type": "email", "count": 3, "status": "delivered"}, + {"template_id": "a4", "template_type": "sms", "count": 7, "status": "delivered"}, + ] + + wrapper = NotificationCounts() + + # Execute + result = wrapper.get_all_notification_counts_for_today("service-123") + + # Assert + assert result == expected_result + + if None in redis_side_effect: + mock_template_stats.get_template_statistics_for_service.assert_called_once() + + def test_get_all_notification_counts_for_year(self, mock_service_api): + # Setup + mock_service_api.get_monthly_notification_stats.return_value = { + "data": { + "2024-01": { + "sms": {"sent": 1, "temporary-failure:": 22}, + "email": {"delivered": 1, "permanent-failure": 1, "sending": 12, "technical-failure": 1}, + }, + "2024-02": {"sms": {"sent": 1}, "email": {"delivered": 1}}, + } + } + wrapper = NotificationCounts() + + with patch.object(wrapper, "get_all_notification_counts_for_today") as mock_today: + mock_today.return_value = {"sms": 5, "email": 5} + + # Execute + result = wrapper.get_all_notification_counts_for_year("service-123", 2024) + + # Assert + assert result["sms"] == 29 # 1 + 22 + 1 + 5 + assert result["email"] == 21 # 1 + 1 + 12 + 1 + 1 + 5 + + def test_get_limit_stats(self, mocker): + # Setup + mock_service = Mock(id="service-1", email_annual_limit=1000, sms_annual_limit=500, message_limit=100, sms_daily_limit=50) + + mock_notification_client = NotificationCounts() + + # Mock the dependency methods + + mocker.patch.object( + mock_notification_client, "get_all_notification_counts_for_today", return_value={"email": 20, "sms": 10} + ) + mocker.patch.object( + mock_notification_client, "get_all_notification_counts_for_year", return_value={"email": 200, "sms": 100} + ) + + # Execute + result = mock_notification_client.get_limit_stats(mock_service) + + # Assert + assert result == { + "email": { + "annual": { + "limit": 1000, + "sent": 200, + "remaining": 800, + }, + "daily": { + "limit": 100, + "sent": 20, + "remaining": 80, + }, + }, + "sms": { + "annual": { + "limit": 500, + "sent": 100, + "remaining": 400, + }, + "daily": { + "limit": 50, + "sent": 10, + "remaining": 40, + }, + }, + } + + @pytest.mark.parametrize( + "today_counts,year_counts,expected_remaining", + [ + ( + {"email": 0, "sms": 0}, + {"email": 0, "sms": 0}, + {"email": {"annual": 1000, "daily": 100}, "sms": {"annual": 500, "daily": 50}}, + ), + ( + {"email": 100, "sms": 50}, + {"email": 1000, "sms": 500}, + {"email": {"annual": 0, "daily": 0}, "sms": {"annual": 0, "daily": 0}}, + ), + ( + {"email": 50, "sms": 25}, + {"email": 500, "sms": 250}, + {"email": {"annual": 500, "daily": 50}, "sms": {"annual": 250, "daily": 25}}, + ), + ], + ) + def test_get_limit_stats_remaining_calculations(self, mocker, today_counts, year_counts, expected_remaining): + # Setup + mock_service = Mock(id="service-1", email_annual_limit=1000, sms_annual_limit=500, message_limit=100, sms_daily_limit=50) + + mock_notification_client = NotificationCounts() + + mocker.patch.object(mock_notification_client, "get_all_notification_counts_for_today", return_value=today_counts) + mocker.patch.object(mock_notification_client, "get_all_notification_counts_for_year", return_value=year_counts) + + # Execute + result = mock_notification_client.get_limit_stats(mock_service) + + # Assert remaining counts + assert result["email"]["annual"]["remaining"] == expected_remaining["email"]["annual"] + assert result["email"]["daily"]["remaining"] == expected_remaining["email"]["daily"] + assert result["sms"]["annual"]["remaining"] == expected_remaining["sms"]["annual"] + assert result["sms"]["daily"]["remaining"] == expected_remaining["sms"]["daily"] + + def test_get_limit_stats_dependencies_called(self, mocker): + # Setup + mock_service = Mock(id="service-1", email_annual_limit=1000, sms_annual_limit=500, message_limit=100, sms_daily_limit=50) + mock_notification_client = NotificationCounts() + + mock_today = mocker.patch.object( + mock_notification_client, "get_all_notification_counts_for_today", return_value={"email": 0, "sms": 0} + ) + mock_year = mocker.patch.object( + mock_notification_client, "get_all_notification_counts_for_year", return_value={"email": 0, "sms": 0} + ) + + # Execute + mock_notification_client.get_limit_stats(mock_service) + + # Assert dependencies called + mock_today.assert_called_once_with(mock_service.id) + mock_year.assert_called_once_with(mock_service.id, datetime.now().year) diff --git a/tests/app/salesforce/test_salesforce_account.py b/tests/app/salesforce/test_salesforce_account.py index 5f2196ad9a..1348f14e49 100644 --- a/tests/app/salesforce/test_salesforce_account.py +++ b/tests/app/salesforce/test_salesforce_account.py @@ -1,3 +1,5 @@ +from unittest.mock import call + from app.salesforce import salesforce_account @@ -5,8 +7,8 @@ def test_get_accounts_requests_correct_url(mocker, app_): with app_.app_context(): mock_request = mocker.patch("app.salesforce.salesforce_account.requests.get") salesforce_account.get_accounts("www.test_url.ca", "secret_token", app_.logger) - assert mock_request.called_with("www.test_url.ca") - assert mock_request.called_with(headers={"Authorization": "token secret_token"}) + calls = [call("www.test_url.ca", headers={"Authorization": "token secret_token"})] + mock_request.assert_has_calls(calls) def test_get_accounts_sorts_alphabetically(mocker, app_): diff --git a/tests/conftest.py b/tests/conftest.py index 94ff0ca5be..a491717587 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -550,7 +550,14 @@ def fake_uuid(): @pytest.fixture(scope="function") def mock_get_service(mocker, api_user_active): def _get(service_id): - service = service_json(service_id, users=[api_user_active["id"]], message_limit=50, sms_daily_limit=20) + service = service_json( + service_id, + users=[api_user_active["id"]], + message_limit=50, + sms_daily_limit=20, + email_annual_limit=1000, + sms_annual_limit=1000, + ) return {"data": service} return mocker.patch("app.service_api_client.get_service", side_effect=_get) @@ -675,7 +682,9 @@ def mock_service_email_from_is_unique(mocker): @pytest.fixture(scope="function") def mock_get_live_service(mocker, api_user_active): def _get(service_id): - service = service_json(service_id, users=[api_user_active["id"]], restricted=False) + service = service_json( + service_id, users=[api_user_active["id"]], restricted=False, sms_annual_limit=10000, email_annual_limit=10000 + ) return {"data": service} return mocker.patch("app.service_api_client.get_service", side_effect=_get) @@ -971,6 +980,21 @@ def _get(service_id, template_id, version=None): return mocker.patch("app.service_api_client.get_service_template", side_effect=_get) +@pytest.fixture(scope="function") +def mock_get_service_sms_template_without_placeholders(mocker): + def _get(service_id, template_id, version=None): + template = template_json( + service_id, + template_id, + "Two week reminder", + "sms", + "Yo.", + ) + return {"data": template} + + return mocker.patch("app.service_api_client.get_service_template", side_effect=_get) + + @pytest.fixture(scope="function") def mock_get_service_letter_template(mocker, content=None, subject=None, postage="second"): def _get(service_id, template_id, version=None, postage=postage): @@ -1123,6 +1147,39 @@ def _update( return mocker.patch("app.service_api_client.update_service_template", side_effect=_update) +@pytest.fixture(scope="function") +def mock_get_limit_stats(mocker): + def _get_data(svc): + return { + "email": { + "annual": { + "limit": 1000, + "sent": 10, + "remaining": 990, + }, + "daily": { + "limit": 100, + "sent": 5, + "remaining": 95, + }, + }, + "sms": { + "annual": { + "limit": 1000, + "sent": 10, + "remaining": 990, + }, + "daily": { + "limit": 100, + "sent": 5, + "remaining": 95, + }, + }, + } + + return mocker.patch("app.main.views.templates.notification_counts_client.get_limit_stats", side_effect=_get_data) + + def create_template( service_id=SERVICE_ONE_ID, template_id=None,