Compare commits

..

77 Commits

Author SHA1 Message Date
Alex Janousek
26b94b82bc Merge pull request #1773 from GSA/dependabot/pip/faker-37.4.0
Bump faker from 37.3.0 to 37.4.0
2025-06-12 10:16:46 -04:00
dependabot[bot]
eda0612561 Bump faker from 37.3.0 to 37.4.0
Bumps [faker](https://github.com/joke2k/faker) from 37.3.0 to 37.4.0.
- [Release notes](https://github.com/joke2k/faker/releases)
- [Changelog](https://github.com/joke2k/faker/blob/master/CHANGELOG.md)
- [Commits](https://github.com/joke2k/faker/compare/v37.3.0...v37.4.0)

---
updated-dependencies:
- dependency-name: faker
  dependency-version: 37.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-11 21:36:15 +00:00
Kenneth Kehl
eb50ca7554 Merge pull request #1758 from GSA/check_csvs
check which services have csvs
2025-06-11 07:49:36 -07:00
Kenneth Kehl
0044a1580a ugh 2025-06-11 07:39:32 -07:00
Kenneth Kehl
fe1f333d4c Update app/aws/s3.py
Co-authored-by: ccostino <ccostino@users.noreply.github.com>
2025-06-11 07:27:51 -07:00
ccostino
5d9e612ecf Merge pull request #1767 from GSA/add-adr-0014
Add new ADR 0014: Localize the notifications-python-client library
2025-06-11 08:38:55 -04:00
Alex Janousek
ccf6f10169 Merge pull request #1770 from GSA/dependabot/pip/aiohttp-3.12.12
Bump aiohttp from 3.12.11 to 3.12.12
2025-06-10 20:36:18 -04:00
dependabot[bot]
79598ca498 Bump aiohttp from 3.12.11 to 3.12.12
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.12.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-11 00:29:49 +00:00
Alex Janousek
669470202f Merge pull request #1769 from GSA/dependabot/pip/cyclonedx-python-lib-10.2.0
Bump cyclonedx-python-lib from 10.1.0 to 10.2.0
2025-06-10 20:27:43 -04:00
dependabot[bot]
7c41012dce Bump cyclonedx-python-lib from 10.1.0 to 10.2.0
Bumps [cyclonedx-python-lib](https://github.com/CycloneDX/cyclonedx-python-lib) from 10.1.0 to 10.2.0.
- [Release notes](https://github.com/CycloneDX/cyclonedx-python-lib/releases)
- [Changelog](https://github.com/CycloneDX/cyclonedx-python-lib/blob/main/CHANGELOG.md)
- [Commits](https://github.com/CycloneDX/cyclonedx-python-lib/compare/v10.1.0...v10.2.0)

---
updated-dependencies:
- dependency-name: cyclonedx-python-lib
  dependency-version: 10.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-11 00:21:09 +00:00
Alex Janousek
aebf3ec92d Merge pull request #1771 from GSA/dependabot/pip/cryptography-45.0.4
Bump cryptography from 45.0.3 to 45.0.4
2025-06-10 20:18:53 -04:00
dependabot[bot]
f2f9bcce5c Bump cryptography from 45.0.3 to 45.0.4
Bumps [cryptography](https://github.com/pyca/cryptography) from 45.0.3 to 45.0.4.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/45.0.3...45.0.4)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 45.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-10 21:46:01 +00:00
Carlo Costino
a1bf2918dc Add new ADR 0014: Localize the notifications-python-client library
This changeset formalizes the new ADR (Architectural Decision Record) that proposed localizing the notifications-python-client into the API and Admin applications.

Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2025-06-10 14:35:48 -04:00
ccostino
419e70bfd7 Merge pull request #1678 from GSA/update-marshmallow-deps
Update Marshmallow and SQLAlchemy dependencies
2025-06-10 11:53:37 -04:00
Carlo Costino
ca6ed1dca5 Updated poetry.lock file to sync with recent updates
Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2025-06-10 10:59:00 -04:00
Carlo Costino
fb4d2624a0 Merge branch 'main' into update-marshmallow-deps 2025-06-10 10:58:16 -04:00
ccostino
2cc2022f83 Merge pull request #1764 from GSA/dependabot/pip/newrelic-10.13.0
Bump newrelic from 10.12.0 to 10.13.0
2025-06-10 10:56:32 -04:00
Kenneth Kehl
414298fd79 merge from main 2025-06-10 07:52:00 -07:00
ccostino
172293d33a Merge pull request #1762 from GSA/dependabot/pip/phonenumbers-9.0.7
Bump phonenumbers from 9.0.6 to 9.0.7
2025-06-10 10:48:52 -04:00
dependabot[bot]
11c5f77806 Bump newrelic from 10.12.0 to 10.13.0
Bumps [newrelic](https://github.com/newrelic/newrelic-python-agent) from 10.12.0 to 10.13.0.
- [Release notes](https://github.com/newrelic/newrelic-python-agent/releases)
- [Commits](https://github.com/newrelic/newrelic-python-agent/compare/v10.12.0...v10.13.0)

---
updated-dependencies:
- dependency-name: newrelic
  dependency-version: 10.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-10 14:31:00 +00:00
alexjanousekGSA
18be2fc179 Fixed merge conflict 2025-06-10 09:59:08 -04:00
dependabot[bot]
e04b3cf438 Bump phonenumbers from 9.0.6 to 9.0.7
Bumps [phonenumbers](https://github.com/daviddrysdale/python-phonenumbers) from 9.0.6 to 9.0.7.
- [Commits](https://github.com/daviddrysdale/python-phonenumbers/compare/v9.0.6...v9.0.7)

---
updated-dependencies:
- dependency-name: phonenumbers
  dependency-version: 9.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-10 13:50:31 +00:00
ccostino
df48988db1 Merge pull request #1763 from GSA/dependabot/pip/requests-2.32.4
Bump requests from 2.32.3 to 2.32.4
2025-06-10 09:48:20 -04:00
dependabot[bot]
27e73343b8 Bump requests from 2.32.3 to 2.32.4
Bumps [requests](https://github.com/psf/requests) from 2.32.3 to 2.32.4.
- [Release notes](https://github.com/psf/requests/releases)
- [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md)
- [Commits](https://github.com/psf/requests/compare/v2.32.3...v2.32.4)

---
updated-dependencies:
- dependency-name: requests
  dependency-version: 2.32.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-09 21:26:53 +00:00
alexjanousekGSA
1472755b51 Fixed unit test 2025-06-09 13:39:08 -04:00
alexjanousekGSA
82deb9633d Made conditional more specific to pass scan 2025-06-09 13:06:01 -04:00
alexjanousekGSA
8e7b3dead3 Fixing static scan 2025-06-09 13:01:22 -04:00
alexjanousekGSA
0582a775be Regenerated poetry lock again 2025-06-09 12:39:46 -04:00
alexjanousekGSA
bcbd98114c Regenerated poetry lock 2025-06-09 12:35:14 -04:00
ccostino
214ee24836 Merge pull request #1761 from GSA/python_manual_unwind
Python manual unwind
2025-06-09 12:28:09 -04:00
alexjanousekGSA
c4c3967def Fixed merge conflict 2025-06-09 12:10:35 -04:00
alexjanousekGSA
bc68f80958 Updated based on PR comments 2025-06-09 12:09:22 -04:00
Kenneth Kehl
e7406aaf60 add poetry.lock 2025-06-09 08:46:50 -07:00
Kenneth Kehl
97ea02e85a manually roll python back to 3.12.2 2025-06-09 08:46:07 -07:00
Kenneth Kehl
fbe733bc7e check which services have csvs 2025-06-09 07:12:05 -07:00
ccostino
8e0bb22634 Merge pull request #1756 from GSA/add-cert-env-var
Add one more cert environment variable
2025-06-05 17:51:07 -04:00
Carlo Costino
a6bc7b4985 Add one more cert environment variable
This changeset adds an additional environment variable to enforce usage of the correct CA certificate in case any libraries override it.

Please see https://cloud.gov/docs/management/container-to-container/#addressing-certificate-validation-errors for more details.

Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2025-06-05 17:41:03 -04:00
ccostino
dc71f03692 Merge pull request #1755 from GSA/better_debug
improve debug of external issues
2025-06-05 11:31:44 -04:00
Kenneth Kehl
2abcbc5560 cleanup 2025-06-05 08:17:30 -07:00
Kenneth Kehl
aa9cafb5f1 improve debug of external issues 2025-06-05 08:12:56 -07:00
ccostino
630641af1d Merge pull request #1754 from GSA/unpin-egress-proxy
Unpin egress proxy release
2025-06-04 15:30:56 -04:00
Carlo Costino
ed4cbbc05b Unpin egress proxy release
This changeset unpins the egress proxy release now that we have resolved the other issues surrounding the connectivity to S3.

Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2025-06-04 12:02:52 -04:00
ccostino
1547478bbf Merge pull request #1752 from GSA/get_rid_of_oscrypto
get rid of oscrypto
2025-06-04 11:44:05 -04:00
alexjanousekGSA
8c70b2b7bd Fixed merge conflict with main 2025-06-04 11:07:24 -04:00
Kenneth Kehl
57f98b1507 cleanup 2025-06-04 08:03:01 -07:00
Kenneth Kehl
5eade384c5 fix imports 2025-06-04 07:42:41 -07:00
Kenneth Kehl
9379ca0436 get rid of oscrypto 2025-06-04 07:38:53 -07:00
alexjanousekGSA
6a5c4de30f Updated a few dependencies 2025-06-02 13:46:58 -04:00
alexjanousekGSA
c8eae375f4 Fixed scan errors 2025-06-02 10:44:42 -04:00
Carlo Costino
07195ba145 Merge branch 'main' into update-marshmallow-deps 2025-06-02 10:19:17 -04:00
alexjanousekGSA
baa8cc4bfa All tests should pass now 2025-05-31 08:56:24 -04:00
alexjanousekGSA
a431ccfb73 Fixed more unit tests 2025-05-30 16:42:52 -04:00
alexjanousekGSA
aa445f5f40 Fixed more tests 2025-05-30 16:23:47 -04:00
alexjanousekGSA
7c1f3f2c3a More fixed tests 2025-05-28 14:01:55 -04:00
alexjanousekGSA
8feced8d7f Fixed more tests, 48 left 2025-05-28 13:47:35 -04:00
alexjanousekGSA
f0fefbef21 Fixed another 30 tests 2025-05-27 19:40:26 -04:00
alexjanousekGSA
898607b40a Removed dependency that is not needed, fixed more tests 2025-05-16 16:23:59 -04:00
alexjanousekGSA
c1507ec060 Fixed some linting errors 2025-05-16 14:14:50 -04:00
Carlo Costino
56bcf0dd68 Merge branch 'main' into update-marshmallow-deps 2025-05-16 09:33:27 -04:00
alexjanousekGSA
a100fa6eb8 Fixed test_get_notification.py tests 2025-05-14 15:01:01 -04:00
alexjanousekGSA
b7d87b6db8 Removed unused import 2025-05-14 13:42:12 -04:00
alexjanousekGSA
68aa63945a Fixed test_rest file 2025-05-14 13:41:23 -04:00
Carlo Costino
2824056a7d Fix use of data_key argument and add logging
Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2025-05-14 11:20:45 -04:00
Carlo Costino
1c54593e9e Merge branch 'main' into update-marshmallow-deps 2025-05-14 11:04:12 -04:00
alexjanousekGSA
0402f6dc71 Fixed a few unit tests 2025-05-09 15:14:07 -04:00
Carlo Costino
9b5a5d5eba More enum fixes!
Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2025-05-07 18:43:54 -04:00
Carlo Costino
7835ef1dd9 Fix handling of Notification-based schemas and missing fields
Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2025-05-07 17:48:05 -04:00
Carlo Costino
f9f7333d72 Fix a couple more enum schema fields and attempt to fix template_version field
Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2025-05-06 13:31:50 -04:00
Carlo Costino
d2fc023622 Fix dead-code related to the addition of the data_key argument
Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2025-05-06 12:03:14 -04:00
Carlo Costino
21e4345d2e Merge branch 'main' into update-marshmallow-deps 2025-05-06 11:43:22 -04:00
Carlo Costino
01ff34b364 Merge branch 'main' into update-marshmallow-deps 2025-05-06 10:59:24 -04:00
Carlo Costino
66c77b5c82 Fix data_key argument errors
This is due to a change in marshmallow 4.0.0: https://marshmallow.readthedocs.io/en/latest/upgrading.html#validates-accepts-multiple-field-names

Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2025-05-05 15:29:24 -04:00
Carlo Costino
05e5565286 Merge branch 'main' into update-marshmallow-deps 2025-05-05 11:34:35 -04:00
Carlo Costino
71344ce079 Merge branch 'main' into update-marshmallow-deps 2025-05-02 14:59:06 -04:00
Carlo Costino
f8858c944f Fix references to TemplateType enum in the schema
Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2025-05-02 14:56:46 -04:00
Carlo Costino
43fb72d79c Update schema to account for Marshmallow updates
Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2025-04-30 12:53:16 -04:00
Carlo Costino
3250e22d3b Update Marshmallow and SQLAlchemy dependencies
This changeset gets all of our Marshmallow and SQLAlchemy dependencies updated and accounts for recent major version changes.

Signed-off-by: Carlo Costino <carlo.costino@gsa.gov>
2025-04-29 12:09:13 -04:00
32 changed files with 1203 additions and 854 deletions

View File

@@ -127,16 +127,6 @@
}
],
"results": {
".github/actions/deploy-proxy/action.yml": [
{
"type": "Hex High Entropy String",
"filename": ".github/actions/deploy-proxy/action.yml",
"hashed_secret": "a6c13f5da3788e8d654cd24001dc79a238723248",
"is_verified": false,
"line_number": 18,
"is_secret": false
}
],
".github/workflows/checks.yml": [
{
"type": "Secret Keyword",
@@ -161,7 +151,7 @@
"filename": ".github/workflows/daily_checks.yml",
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_verified": false,
"line_number": 71,
"line_number": 63,
"is_secret": false
},
{
@@ -169,7 +159,7 @@
"filename": ".github/workflows/daily_checks.yml",
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_verified": false,
"line_number": 87,
"line_number": 79,
"is_secret": false
}
],
@@ -305,7 +295,7 @@
"filename": "tests/app/service/test_rest.py",
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_verified": false,
"line_number": 1285,
"line_number": 1282,
"is_secret": false
}
],
@@ -384,5 +374,5 @@
}
]
},
"generated_at": "2025-06-02T13:22:36Z"
"generated_at": "2025-06-09T16:07:54Z"
}

View File

@@ -15,7 +15,7 @@ inputs:
default: https://github.com/GSA-TTS/cg-egress-proxy.git
proxy_version:
description: git ref to be deployed
default: 1500c67157c1a7a6fbbda7a2de172b3d0a67e703
default: main
runs:
using: composite
steps:

View File

@@ -9,10 +9,10 @@ runs:
sudo apt-get update \
&& sudo apt-get install -y --no-install-recommends \
libcurl4-openssl-dev
- name: Set up Python 3.13.2
- name: Set up Python 3.12.2
uses: actions/setup-python@v4
with:
python-version: "3.13.2"
python-version: "3.12.2"
- name: Install poetry
shell: bash
run: pip install poetry==2.1.3

View File

@@ -87,15 +87,7 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
- name: Create requirements.txt
run: poetry export --output requirements_tmp.txt --without-hashes
- name: Filter requirements.txt
run: grep -v "oscrypto@ git" requirements_tmp.txt > requirements.txt
- name: Verify requirements.txt
run: ls -l requirements.txt
- name: Print requirements.txt
run: |
echo "Contents of requirements.txt:"
cat requirements.txt
run: poetry export --output requirements.txt
- uses: pypa/gh-action-pip-audit@v1.1.0
with:
inputs: requirements.txt

View File

@@ -26,15 +26,7 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-project
- name: Create requirements.txt
run: poetry export --output requirements_tmp.txt --without-hashes
- name: Filter requirements.txt
run: grep -v "oscrypto@ git" requirements_tmp.txt > requirements.txt
- name: Verify requirements.txt
run: ls -l requirements.txt
- name: Print requirements.txt
run: |
echo "Contents of requirements.txt:"
cat requirements.txt
run: poetry export --output requirements.txt
- uses: pypa/gh-action-pip-audit@v1.1.0
with:
inputs: requirements.txt

View File

@@ -108,6 +108,10 @@ test: ## Run tests and create coverage report
poetry run coverage report -m --fail-under=93
poetry run coverage html -d .coverage_cache
.PHONY: test-debug
test-debug:
poetry run pytest --pdb -x
.PHONY: py-lock
py-lock: ## Syncs dependencies and updates lock file without performing recursive internal updates
poetry sync --no-root

View File

@@ -259,12 +259,12 @@ git clone git@github.com:GSA/notifications-api.git
Now go into the project directory (`notifications-api` by default), create a
virtual environment, and set the local Python version to point to the virtual
environment (assumes version Python `3.13.2` is what is installed on your
environment (assumes version Python `3.12.2` is what is installed on your
machine):
```sh
cd notifications-api
pyenv virtualenv 3.13.2 notify-api
pyenv virtualenv 3.12.2 notify-api
pyenv local notify-api
```
@@ -329,7 +329,7 @@ environment with the newer version of Python you just installed:
```sh
cd notifications-api
pyenv virtualenv 3.13.2 notify-api
pyenv virtualenv 3.12.2 notify-api
pyenv local notify-api
```

View File

@@ -11,7 +11,6 @@ from time import monotonic
from celery import Celery, Task, current_task
from flask import current_app, g, has_request_context, jsonify, make_response, request
from flask.ctx import has_app_context
from flask_marshmallow import Marshmallow
from flask_migrate import Migrate
from flask_socketio import SocketIO
from flask_sqlalchemy import SQLAlchemy as _SQLAlchemy
@@ -86,7 +85,6 @@ db = SQLAlchemy(
}
)
migrate = Migrate()
ma = Marshmallow()
notify_celery = NotifyCelery()
aws_ses_client = AwsSesClient()
aws_ses_stub_client = AwsSesStubClient()
@@ -131,7 +129,6 @@ def create_app(application):
request_helper.init_app(application)
db.init_app(application)
migrate.init_app(application, db=db)
ma.init_app(application)
zendesk_client.init_app(application)
logging.init_app(application)
aws_sns_client.init_app(application)

View File

@@ -11,6 +11,8 @@ from flask import current_app
from app import job_cache, job_cache_lock
from app.clients import AWS_CLIENT_CONFIG
# from app.service.rest import get_service_by_id
from notifications_utils import aware_utcnow
FILE_LOCATION_STRUCTURE = "service-{}-notify/{}.csv"
@@ -162,6 +164,34 @@ def cleanup_old_s3_objects():
current_app.logger.exception(
"#delete-old-s3-objects An error occurred while cleaning up old s3 objects",
)
try:
response = s3_client.list_objects_v2(Bucket=bucket_name)
service_ids = set()
while True:
for obj in response.get("Contents", []):
# Get the service id out of the upload key
key = obj["Key"]
object_arr = key.split("/")
service_id = object_arr[0]
service_id = service_id.replace("-service-notify", "")
service_ids.add(service_id)
if "NextContinuationToken" in response:
response = s3_client.list_objects_v2(
Bucket=bucket_name,
ContinuationToken=response["NextContinuationToken"],
)
else:
break
retained_services = []
for service_id in service_ids:
retained_services.append(service_id)
return service_ids
except Exception as error:
current_app.logger.exception(
f"#delete-old-s3-objects An error occurred while cleaning up old s3 objects: {str(error)}"
)
def get_job_id_from_s3_object_key(key):

View File

@@ -19,6 +19,7 @@ from app.dao.notifications_dao import (
from app.dao.service_email_reply_to_dao import dao_get_reply_to_by_id
from app.dao.service_inbound_api_dao import get_service_inbound_api_for_service
from app.dao.service_sms_sender_dao import dao_get_service_sms_senders_by_id
from app.dao.services_dao import dao_fetch_service_by_id
from app.dao.templates_dao import dao_get_template_by_id
from app.enums import JobStatus, KeyType, NotificationType
from app.errors import TotalRequestsError
@@ -496,7 +497,15 @@ def clean_job_cache():
@notify_celery.task(name="delete-old-s3-objects")
def delete_old_s3_objects():
s3.cleanup_old_s3_objects()
existing_service_ids = s3.cleanup_old_s3_objects()
service_names = []
for service_id in existing_service_ids:
service = dao_fetch_service_by_id(service_id)
service_names.append(service.name)
current_app.logger.info(
f"#delete-old-s3-objects Services with retained csvs: {service_names}"
)
@notify_celery.task(name="process-incomplete-jobs")

View File

@@ -1,5 +1,6 @@
from flask import Blueprint, jsonify, request
from app import db
from app.dao.events_dao import dao_create_event
from app.errors import register_errors
from app.schemas import event_schema
@@ -11,6 +12,6 @@ register_errors(events)
@events.route("", methods=["POST"])
def create_event():
data = request.get_json()
event = event_schema.load(data)
event = event_schema.load(data, session=db.session)
dao_create_event(event)
return jsonify(data=event_schema.dump(event)), 201

View File

@@ -3,6 +3,7 @@ from zoneinfo import ZoneInfo
import dateutil
from flask import Blueprint, current_app, jsonify, request
from app import db
from app.aws.s3 import (
get_job_metadata_from_s3,
get_personalisation_from_s3,
@@ -30,10 +31,10 @@ from app.dao.templates_dao import dao_get_template_by_id
from app.enums import JobStatus
from app.errors import InvalidRequest, register_errors
from app.schemas import (
job_schema,
JobSchema,
UnarchivedTemplateSchema,
notification_with_template_schema,
notifications_filter_schema,
unarchived_template_schema,
)
from app.utils import midnight_n_days_ago, pagination_links
@@ -47,7 +48,7 @@ register_errors(job_blueprint)
def get_job_by_service_and_job_id(service_id, job_id):
job = dao_get_job_by_service_id_and_job_id(service_id, job_id)
statistics = dao_get_notification_outcomes_for_job(service_id, job_id)
data = job_schema.dump(job)
data = JobSchema(session=db.session).dump(job)
data["statistics"] = [
{"status": statistic[1], "count": statistic[0]} for statistic in statistics
@@ -257,14 +258,15 @@ def create_job(service_id):
if data.get("valid") != "True":
raise InvalidRequest("File is not valid, can't create job", 400)
errors = unarchived_template_schema.validate({"archived": template.archived})
schema = UnarchivedTemplateSchema(session=db.session)
errors = schema.validate({"archived": template.archived})
if errors:
raise InvalidRequest(errors, status_code=400)
data.update({"template_version": template.version})
job = job_schema.load(data)
job = JobSchema(session=db.session).load(data)
# See admin #1148, for whatever reason schema loading doesn't load this
if original_file_name is not None:
job.original_file_name = original_file_name
@@ -281,7 +283,7 @@ def create_job(service_id):
[str(job.id)], {"sender_id": sender_id}, queue=QueueNames.JOBS
)
job_json = job_schema.dump(job)
job_json = JobSchema(session=db.session).dump(job)
job_json["statistics"] = []
return jsonify(data=job_json), 201
@@ -317,7 +319,7 @@ def get_paginated_jobs(
page_size=current_app.config["PAGE_SIZE"],
statuses=statuses,
)
data = job_schema.dump(pagination.items, many=True)
data = JobSchema(session=db.session).dump(pagination.items, many=True)
for job_data in data:
start = job_data["processing_started"]
start = dateutil.parser.parse(start).replace(tzinfo=None) if start else None

View File

@@ -15,18 +15,22 @@ from app.notifications.validators import (
service_has_permission,
validate_template,
)
from app.public_schemas.public import PublicNotificationResponseSchema
from app.schemas import (
email_notification_schema,
notification_with_personalisation_schema,
notifications_filter_schema,
sms_template_notification_schema,
)
from app.service.utils import service_allowed_to_send_to
from app.utils import get_public_notify_type_text, pagination_links
from app.utils import (
get_public_notify_type_text,
get_template_instance,
pagination_links,
template_model_to_dict,
)
from notifications_utils import SMS_CHAR_COUNT_LIMIT
notifications = Blueprint("notifications", __name__)
register_errors(notifications)
@@ -35,6 +39,7 @@ def get_notification_by_id(notification_id):
notification = notifications_dao.get_notification_with_personalisation(
str(authenticated_service.id), notification_id, key_type=None
)
if notification.job_id is not None:
notification.personalisation = get_personalisation_from_s3(
notification.service_id,
@@ -48,16 +53,22 @@ def get_notification_by_id(notification_id):
)
notification.to = recipient
notification.normalised_to = recipient
return (
jsonify(
data={
"notification": notification_with_personalisation_schema.dump(
notification
)
}
),
200,
)
if not hasattr(notification, "body") or not notification.body:
template_dict = template_model_to_dict(notification.template)
template = get_template_instance(
template_dict, notification.personalisation or {}
)
notification.body = template.content_with_placeholders_filled_in
schema = PublicNotificationResponseSchema()
schema.context = {
"notification_instance": notification,
"template_subject": getattr(template, "subject", None) if hasattr(template, "subject") else None
}
serialized = schema.dump(notification)
return jsonify(data={"notification": serialized}), 200
@notifications.route("/notifications", methods=["GET"])
@@ -83,9 +94,13 @@ def get_all_notifications():
key_type=api_user.key_type,
include_jobs=include_jobs,
)
serialized = []
for notification in pagination.items:
personalisation = notification.personalisation
if notification.job_id is not None:
notification.personalisation = get_personalisation_from_s3(
personalisation = get_personalisation_from_s3(
notification.service_id,
notification.job_id,
notification.job_row_number,
@@ -95,28 +110,47 @@ def get_all_notifications():
notification.job_id,
notification.job_row_number,
)
# Safe to set dynamically for serialization purposes
notification.to = recipient
notification.normalised_to = recipient
subject = None
if not getattr(notification, "body", None):
template_dict = template_model_to_dict(notification.template)
template = get_template_instance(template_dict, personalisation or {})
notification.body = template.content_with_placeholders_filled_in
if hasattr(template, "subject"):
subject = template.subject
notification.personalisation = personalisation
schema = PublicNotificationResponseSchema()
schema.context = {"notification_instance": notification}
notification_data = schema.dump(notification)
if subject is not None:
notification_data["subject"] = subject
serialized.append(notification_data)
result = jsonify(
notifications=notification_with_personalisation_schema.dump(
pagination.items, many=True
),
notifications=serialized,
page_size=page_size,
total=pagination.total,
links=pagination_links(
pagination, ".get_all_notifications", **request.args.to_dict()
),
)
current_app.logger.debug(f"result={result}")
return result, 200
@notifications.route("/notifications/<string:notification_type>", methods=["POST"])
def send_notification(notification_type):
if notification_type not in {NotificationType.SMS, NotificationType.EMAIL}:
msg = f"{notification_type} notification type is not supported"
raise InvalidRequest(msg, 400)
raise InvalidRequest(
f"{notification_type} notification type is not supported", 400
)
notification_form = (
sms_template_notification_schema
@@ -132,16 +166,15 @@ def send_notification(notification_type):
)
_service_allowed_to_send_to(notification_form, authenticated_service)
if not service_has_permission(notification_type, authenticated_service.permissions):
raise InvalidRequest(
{
"service": [
"Cannot send {}".format(
get_public_notify_type_text(notification_type, plural=True)
)
f"Cannot send {get_public_notify_type_text(notification_type, plural=True)}"
]
},
status_code=400,
400,
)
if notification_type == NotificationType.SMS:
@@ -149,28 +182,28 @@ def send_notification(notification_type):
authenticated_service, notification_form["to"]
)
# Do not persist or send notification to the queue if it is a simulated recipient
simulated = simulated_recipient(notification_form["to"], notification_type)
notification_model = persist_notification(
template_id=template.id,
template_version=template.version,
recipient=request.get_json()["to"],
recipient=notification_form["to"],
service=authenticated_service,
personalisation=notification_form.get("personalisation", None),
personalisation=notification_form.get("personalisation"),
notification_type=notification_type,
api_key_id=api_user.id,
key_type=api_user.key_type,
simulated=simulated,
reply_to_text=template.reply_to_text,
)
if not simulated:
queue_name = None
send_notification_to_queue(notification=notification_model, queue=queue_name)
if not simulated:
send_notification_to_queue(notification=notification_model, queue=None)
else:
current_app.logger.debug(
"POST simulated notification for id: {}".format(notification_model.id)
f"POST simulated notification for id: {notification_model.id}"
)
notification_form.update({"template_version": template.version})
return (
@@ -189,10 +222,8 @@ def get_notification_return_data(notification_id, notification, template):
"notification": {"id": notification_id},
"body": template.content_with_placeholders_filled_in,
}
if hasattr(template, "subject"):
output["subject"] = template.subject
return output
@@ -205,7 +236,7 @@ def _service_allowed_to_send_to(notification, service):
"Cant send to this recipient when service is in trial mode "
" see https://www.notifications.service.gov.uk/trial-mode"
)
raise InvalidRequest({"to": [message]}, status_code=400)
raise InvalidRequest({"to": [message]}, 400)
def create_template_object_for_notification(template, personalisation):
@@ -215,16 +246,18 @@ def create_template_object_for_notification(template, personalisation):
message = "Missing personalisation: {}".format(
", ".join(template_object.missing_data)
)
errors = {"template": [message]}
raise InvalidRequest(errors, status_code=400)
raise InvalidRequest({"template": [message]}, 400)
if (
template_object.template_type == NotificationType.SMS
and template_object.is_message_too_long()
):
message = "Content has a character count greater than the limit of {}".format(
SMS_CHAR_COUNT_LIMIT
raise InvalidRequest(
{
"content": [
f"Content has a character count greater than the limit of {SMS_CHAR_COUNT_LIMIT}"
]
},
400,
)
errors = {"content": [message]}
raise InvalidRequest(errors, status_code=400)
return template_object

View File

@@ -2,10 +2,12 @@ import base64
import re
from urllib.parse import urlparse
import oscrypto.asymmetric
import oscrypto.errors
import requests
import six
from cryptography import x509
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
from app import redis_store
from app.config import Config
@@ -110,15 +112,16 @@ def validate_sns_cert(sns_payload):
if isinstance(certificate, six.text_type):
certificate = certificate.encode()
# load the certificate
certificate = x509.load_pem_x509_certificate(certificate)
signature = base64.b64decode(sns_payload["Signature"])
try:
oscrypto.asymmetric.rsa_pkcs1v15_verify(
oscrypto.asymmetric.load_certificate(certificate),
signature,
string_to_sign,
"sha1",
public_key = certificate.public_key()
public_key.verify(
signature, string_to_sign, padding.PKCS1v15(), hashes.SHA256() # or SHA1?
)
return True
except oscrypto.errors.SignatureError:
except InvalidSignature:
raise ValidationError("Invalid signature")

View File

View File

@@ -0,0 +1,135 @@
from datetime import datetime, timezone
from uuid import UUID
from flask import current_app
from marshmallow import EXCLUDE, Schema, fields, post_dump
class PublicTemplateSchema(Schema):
id = fields.UUID(required=True)
name = fields.String(required=True)
template_type = fields.String(required=True)
version = fields.Integer(required=True)
content = fields.String(allow_none=True) # for fallback rendering
class PublicJobSchema(Schema):
id = fields.UUID(required=True)
original_file_name = fields.String(required=True)
class PublicNotificationSchema(Schema):
id = fields.UUID(required=True)
to = fields.String(required=True)
job_row_number = fields.Integer(allow_none=True)
template_version = fields.Integer(required=True)
billable_units = fields.Integer(required=True)
notification_type = fields.String(required=True)
created_at = fields.String(required=True)
sent_at = fields.String(allow_none=True)
updated_at = fields.String(allow_none=True)
sent_by = fields.String(allow_none=True)
status = fields.String(required=True)
reference = fields.String(allow_none=True)
template = fields.Nested(PublicTemplateSchema, required=True)
service = fields.Raw(required=True)
job = fields.Nested(PublicJobSchema, allow_none=True)
api_key = fields.Raw(allow_none=True)
body = fields.String(required=True)
content_char_count = fields.Integer(allow_none=True)
@post_dump
def transform_common_fields(self, data, **kwargs):
def to_rfc3339(dt):
if dt is None:
return None
if isinstance(dt, str):
try:
dt = datetime.fromisoformat(dt)
except ValueError:
return dt
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat().replace("+00:00", "Z")
def normalize_uuid(val):
if hasattr(val, "id"):
return str(val.id)
elif isinstance(val, UUID):
return str(val)
elif isinstance(val, str):
if val.startswith("Service "):
return val.replace("Service ", "").strip()
elif val.startswith("ApiKey "):
return val.replace("ApiKey ", "").strip()
return val
elif hasattr(val, "__str__") and "Service " in str(val):
return str(val).replace("Service ", "").strip()
return str(val) if val else None
data["created_at"] = to_rfc3339(data.get("created_at"))
data["sent_at"] = to_rfc3339(data.get("sent_at"))
data["updated_at"] = to_rfc3339(data.get("updated_at"))
data["service"] = normalize_uuid(data.get("service"))
data["api_key"] = normalize_uuid(data.get("api_key"))
if "job" in data and isinstance(data["job"], dict) and "id" in data["job"]:
data["job"]["id"] = normalize_uuid(data["job"]["id"])
if "body" not in data or not data["body"]:
data["body"] = data.get("template", {}).get("content") or ""
notification = getattr(self, "context", {}).get("notification_instance")
if "content_char_count" not in data:
if (
notification
and getattr(notification, "content_char_count", None) is not None
):
data["content_char_count"] = notification.content_char_count
elif (
notification
and notification.template
and notification.template.template_type == "email"
):
# this is expected to make the test pass, but I suspect the test might be wrong and should have a count
data["content_char_count"] = None
elif data.get("body") is not None:
data["content_char_count"] = len(data["body"])
else:
data["content_char_count"] = None
if "template" in data:
data["template"].pop("content", None)
return data
class PublicNotificationResponseSchema(PublicNotificationSchema):
class Meta:
unknown = EXCLUDE
@post_dump
def transform_subject(self, data, **kwargs):
notification = getattr(self, "context", {}).get("notification_instance")
subject = getattr(self, "context", {}).get("template_subject")
template_type = data.get("template", {}).get("template_type")
if template_type != "email":
data.pop("subject", None)
elif "subject" not in data:
if subject:
data["subject"] = subject
elif notification and hasattr(notification, "subject"):
try:
data["subject"] = str(notification.subject)
except AttributeError:
data["subject"] = ""
current_app.logger.debug("Notification has no subject attribute")
except Exception as e:
data["subject"] = ""
current_app.logger.warning(
f"Error getting notification subject: {e}"
)
return data

View File

@@ -2,10 +2,12 @@ from datetime import timedelta
from uuid import UUID
from dateutil.parser import parse
from flask_marshmallow.fields import fields
from flask import current_app
from marshmallow import (
EXCLUDE,
Schema,
ValidationError,
fields,
post_dump,
post_load,
pre_dump,
@@ -13,13 +15,20 @@ from marshmallow import (
validates,
validates_schema,
)
from marshmallow_sqlalchemy import auto_field, field_for
from marshmallow_enum import EnumField as BaseEnumField
from marshmallow_sqlalchemy import SQLAlchemyAutoSchema, auto_field, field_for
from app import ma, models
from app import models
from app.dao.permissions_dao import permission_dao
from app.enums import ServicePermissionType, TemplateType
from app.enums import (
NotificationStatus,
OrganizationType,
ServicePermissionType,
TemplateProcessType,
TemplateType,
)
from app.models import ServicePermission
from app.utils import DATETIME_FORMAT_NO_TIMEZONE, get_template_instance, utc_now
from app.utils import DATETIME_FORMAT_NO_TIMEZONE, utc_now
from notifications_utils.recipients import (
InvalidEmailError,
InvalidPhoneError,
@@ -29,6 +38,14 @@ from notifications_utils.recipients import (
)
class SafeEnumField(BaseEnumField):
def fail(self, key, **kwargs):
kwargs["values"] = ", ".join([str(mem.value) for mem in self.enum])
kwargs["names"] = ", ".join([mem.name for mem in self.enum])
msg = self.error or self.default_error_messages.get(key, "Invalid input")
raise ValidationError(msg.format(**kwargs))
def _validate_positive_number(value, msg="Not a positive integer"):
try:
page_int = int(value)
@@ -81,7 +98,7 @@ class UUIDsAsStringsMixin:
return data
class BaseSchema(ma.SQLAlchemyAutoSchema):
class BaseSchema(SQLAlchemyAutoSchema):
class Meta:
load_instance = True
include_relationships = True
@@ -113,7 +130,7 @@ class UserSchema(BaseSchema):
)
updated_at = FlexibleDateTime()
logged_in_at = FlexibleDateTime()
auth_type = field_for(models.User, "auth_type")
auth_type = auto_field(by_value=True)
password = fields.String(required=True, load_only=True)
def user_permissions(self, usr):
@@ -136,28 +153,31 @@ class UserSchema(BaseSchema):
)
@validates("name")
def validate_name(self, value):
def validate_name(self, value, data_key):
if not value:
current_app.logger.exception(f"{data_key}: Invalid name")
raise ValidationError("Invalid name")
@validates("email_address")
def validate_email_address(self, value):
def validate_email_address(self, value, data_key):
try:
validate_email_address(value)
except InvalidEmailError as e:
current_app.logger.exception(f"{data_key}: {str(e)}")
raise ValidationError(str(e))
@validates("mobile_number")
def validate_mobile_number(self, value):
def validate_mobile_number(self, value, data_key):
try:
if value is not None:
validate_phone_number(value, international=True)
except InvalidPhoneError as error:
raise ValidationError(f"Invalid phone number: {error}")
current_app.logger.exception(f"{data_key}: {str(error)}")
raise ValidationError(f"Invalid phone number: {str(error)}")
class UserUpdateAttributeSchema(BaseSchema):
auth_type = field_for(models.User, "auth_type")
auth_type = auto_field(by_value=True)
email_access_validated_at = FlexibleDateTime()
class Meta(BaseSchema.Meta):
@@ -176,24 +196,29 @@ class UserUpdateAttributeSchema(BaseSchema):
)
@validates("name")
def validate_name(self, value):
def validate_name(self, value, data_key):
if not value:
current_app.logger.exception(f"{data_key}: Invalid name")
raise ValidationError("Invalid name")
@validates("email_address")
def validate_email_address(self, value):
def validate_email_address(self, value, data_key):
try:
validate_email_address(value)
except InvalidEmailError as e:
current_app.logger.exception(f"{data_key}: {str(e)}")
raise ValidationError(str(e))
@validates("mobile_number")
def validate_mobile_number(self, value):
def validate_mobile_number(self, value, data_key):
try:
if value is not None:
validate_phone_number(value, international=True)
except InvalidPhoneError as error:
raise ValidationError(f"Invalid phone number: {error}")
current_app.logger.exception(
f"{data_key}: Invalid phone number ({str(error)})"
)
raise ValidationError(f"Invalid phone number: {str(error)}")
@validates_schema(pass_original=True)
def check_unknown_fields(self, data, original_data, **kwargs):
@@ -235,7 +260,10 @@ class ProviderDetailsHistorySchema(BaseSchema):
class ServiceSchema(BaseSchema, UUIDsAsStringsMixin):
created_by = field_for(models.Service, "created_by", required=True)
organization_type = field_for(models.Service, "organization_type")
organization_type = SafeEnumField(
OrganizationType, by_value=True, required=False, allow_none=True
)
permissions = fields.Method(
"serialize_service_permissions", "deserialize_service_permissions"
)
@@ -282,14 +310,20 @@ class ServiceSchema(BaseSchema, UUIDsAsStringsMixin):
)
@validates("permissions")
def validate_permissions(self, value):
def validate_permissions(self, value, data_key):
permissions = [v.permission for v in value]
for p in permissions:
if p not in {e for e in ServicePermissionType}:
current_app.logger.exception(
f"{data_key}: Invalid Service Permission: '{p}'"
)
raise ValidationError(f"Invalid Service Permission: '{p}'")
if len(set(permissions)) != len(permissions):
duplicates = list(set([x for x in permissions if permissions.count(x) > 1]))
current_app.logger.exception(
f"{data_key}: Duplicate Service Permission: {duplicates}"
)
raise ValidationError(f"Duplicate Service Permission: {duplicates}")
@pre_load()
@@ -306,6 +340,14 @@ class ServiceSchema(BaseSchema, UUIDsAsStringsMixin):
return in_data
class TemplateTypeFieldOnlySchema(Schema):
template_type = fields.String(required=True)
class NotificationStatusFieldOnlySchema(Schema):
status = fields.String(required=True)
class DetailedServiceSchema(BaseSchema):
statistics = fields.Dict()
organization_type = field_for(models.Service, "organization_type")
@@ -350,7 +392,7 @@ class NotificationModelSchema(BaseSchema):
"api_key",
)
status = fields.String(required=False)
status = auto_field(by_value=True)
created_at = FlexibleDateTime()
sent_at = FlexibleDateTime()
updated_at = FlexibleDateTime()
@@ -359,6 +401,7 @@ class NotificationModelSchema(BaseSchema):
class BaseTemplateSchema(BaseSchema):
reply_to = fields.Method("get_reply_to", allow_none=True)
reply_to_text = fields.Method("get_reply_to_text", allow_none=True)
template_type = auto_field(by_value=True)
def get_reply_to(self, template):
return template.reply_to
@@ -373,7 +416,7 @@ class BaseTemplateSchema(BaseSchema):
class TemplateSchema(BaseTemplateSchema, UUIDsAsStringsMixin):
created_by = field_for(models.Template, "created_by", required=True)
process_type = field_for(models.Template, "process_type")
process_type = auto_field(by_value=True)
redact_personalisation = fields.Method("redact")
created_at = FlexibleDateTime()
updated_at = FlexibleDateTime()
@@ -418,7 +461,8 @@ class TemplateSchemaNoDetail(TemplateSchema):
class TemplateHistorySchema(BaseSchema):
reply_to = fields.Method("get_reply_to", allow_none=True)
reply_to_text = fields.Method("get_reply_to_text", allow_none=True)
process_type = field_for(models.Template, "process_type")
process_type = SafeEnumField(TemplateProcessType, by_value=True)
template_type = auto_field(by_value=True)
created_by = fields.Nested(
UserSchema, only=["id", "name", "email_address"], dump_only=True
@@ -440,7 +484,7 @@ class TemplateHistorySchema(BaseSchema):
class ApiKeySchema(BaseSchema):
created_by = field_for(models.ApiKey, "created_by", required=True)
key_type = field_for(models.ApiKey, "key_type", required=True)
key_type = auto_field(by_value=True)
expiry_date = FlexibleDateTime()
created_at = FlexibleDateTime()
updated_at = FlexibleDateTime()
@@ -464,7 +508,7 @@ class JobSchema(BaseSchema):
processing_started = FlexibleDateTime()
processing_finished = FlexibleDateTime()
job_status = auto_field()
job_status = auto_field(by_value=True)
scheduled_for = FlexibleDateTime()
service_name = fields.Nested(
@@ -482,10 +526,10 @@ class JobSchema(BaseSchema):
return job.template.name
def get_template_type(self, job):
return job.template.template_type
return job.template.template_type.value
@validates("scheduled_for")
def validate_scheduled_for(self, value):
def validate_scheduled_for(self, value, data_key):
_validate_datetime_not_in_past(value)
_validate_datetime_not_more_than_96_hours_in_future(value)
@@ -499,11 +543,11 @@ class JobSchema(BaseSchema):
)
class NotificationSchema(ma.Schema):
class NotificationSchema(Schema):
class Meta:
unknown = EXCLUDE
status = fields.String(required=False)
status = fields.Enum(NotificationStatus, by_value=True, required=False)
personalisation = fields.Dict(required=False)
@@ -511,11 +555,14 @@ class SmsNotificationSchema(NotificationSchema):
to = fields.Str(required=True)
@validates("to")
def validate_to(self, value):
def validate_to(self, value, data_key):
try:
validate_phone_number(value, international=True)
except InvalidPhoneError as error:
raise ValidationError("Invalid phone number: {}".format(error))
current_app.logger.exception(
f"{data_key}: Invalid phone number ({str(error)}"
)
raise ValidationError(f"Invalid phone number: {str(error)}")
@post_load
def format_phone_number(self, item, **kwargs):
@@ -528,10 +575,11 @@ class EmailNotificationSchema(NotificationSchema):
template = fields.Str(required=True)
@validates("to")
def validate_to(self, value):
def validate_to(self, value, data_key):
try:
validate_email_address(value)
except InvalidEmailError as e:
current_app.logger.exception(f"{data_key}: {str(e)}")
raise ValidationError(str(e))
@@ -542,6 +590,7 @@ class SmsTemplateNotificationSchema(SmsNotificationSchema):
class NotificationWithTemplateSchema(BaseSchema):
class Meta(BaseSchema.Meta):
unknown = EXCLUDE
model = models.Notification
exclude = ("_personalisation",)
@@ -558,13 +607,15 @@ class NotificationWithTemplateSchema(BaseSchema):
],
dump_only=True,
)
template_version = fields.Integer()
job = fields.Nested(JobSchema, only=["id", "original_file_name"], dump_only=True)
created_by = fields.Nested(
UserSchema, only=["id", "name", "email_address"], dump_only=True
)
status = fields.String(required=False)
status = auto_field(by_value=True)
personalisation = fields.Dict(required=False)
key_type = field_for(models.Notification, "key_type", required=True)
notification_type = auto_field(by_value=True)
key_type = auto_field(by_value=True)
key_name = fields.String()
created_at = FlexibleDateTime()
updated_at = FlexibleDateTime()
@@ -579,84 +630,24 @@ class NotificationWithTemplateSchema(BaseSchema):
return in_data
class NotificationWithPersonalisationSchema(NotificationWithTemplateSchema):
template_history = fields.Nested(
TemplateHistorySchema,
attribute="template",
only=["id", "name", "template_type", "content", "subject", "version"],
dump_only=True,
)
class Meta(NotificationWithTemplateSchema.Meta):
# mark as many fields as possible as required since this is a public api.
# WARNING: Does _not_ reference fields computed in handle_template_merge, such as
# 'body', 'subject' [for emails], and 'content_char_count'
fields = (
# db rows
"billable_units",
"created_at",
"id",
"job_row_number",
"notification_type",
"reference",
"sent_at",
"sent_by",
"status",
"template_version",
"to",
"updated_at",
# computed fields
"personalisation",
# relationships
"api_key",
"job",
"service",
"template_history",
)
# Overwrite the `NotificationWithTemplateSchema` base class to not exclude `_personalisation`, which
# isn't a defined field for this class
exclude = ()
@pre_dump
def handle_personalisation_property(self, in_data, **kwargs):
self.personalisation = in_data.personalisation
return in_data
@post_dump
def handle_template_merge(self, in_data, **kwargs):
in_data["template"] = in_data.pop("template_history")
template = get_template_instance(
in_data["template"], in_data["personalisation"]
)
in_data["body"] = template.content_with_placeholders_filled_in
if in_data["template"]["template_type"] != TemplateType.SMS:
in_data["subject"] = template.subject
in_data["content_char_count"] = None
else:
in_data["content_char_count"] = template.content_count
in_data.pop("personalisation", None)
in_data["template"].pop("content", None)
in_data["template"].pop("subject", None)
return in_data
class InvitedUserSchema(BaseSchema):
auth_type = field_for(models.InvitedUser, "auth_type")
auth_type = auto_field(by_value=True)
created_at = FlexibleDateTime()
status = auto_field(by_value=True)
class Meta(BaseSchema.Meta):
model = models.InvitedUser
@validates("email_address")
def validate_to(self, value):
def validate_to(self, value, data_key):
try:
validate_email_address(value)
except InvalidEmailError as e:
current_app.logger.exception(f"{data_key}: {str(e)}")
raise ValidationError(str(e))
class EmailDataSchema(ma.Schema):
class EmailDataSchema(Schema):
class Meta:
unknown = EXCLUDE
@@ -669,36 +660,38 @@ class EmailDataSchema(ma.Schema):
self.partial_email = partial_email
@validates("email")
def validate_email(self, value):
def validate_email(self, value, data_key):
if self.partial_email:
return
try:
validate_email_address(value)
except InvalidEmailError as e:
current_app.logger.exception(f"{data_key}: {str(e)}")
raise ValidationError(str(e))
class NotificationsFilterSchema(ma.Schema):
class NotificationsFilterSchema(Schema):
class Meta:
unknown = EXCLUDE
template_type = fields.Nested(BaseTemplateSchema, only=["template_type"], many=True)
status = fields.Nested(NotificationModelSchema, only=["status"], many=True)
template_type = fields.Nested(TemplateTypeFieldOnlySchema, many=True)
status = fields.Nested(NotificationStatusFieldOnlySchema, many=True)
page = fields.Int(required=False)
page_size = fields.Int(required=False)
limit_days = fields.Int(required=False)
include_jobs = fields.Boolean(required=False)
include_from_test_key = fields.Boolean(required=False)
older_than = fields.UUID(required=False)
format_for_csv = fields.String()
format_for_csv = fields.Boolean()
to = fields.String()
include_one_off = fields.Boolean(required=False)
count_pages = fields.Boolean(required=False)
@pre_load
def handle_multidict(self, in_data, **kwargs):
out_data = dict(in_data)
if isinstance(in_data, dict) and hasattr(in_data, "getlist"):
out_data = dict([(k, in_data.get(k)) for k in in_data.keys()])
if "template_type" in in_data:
out_data["template_type"] = [
{"template_type": x} for x in in_data.getlist("template_type")
@@ -712,22 +705,22 @@ class NotificationsFilterSchema(ma.Schema):
def convert_schema_object_to_field(self, in_data, **kwargs):
if "template_type" in in_data:
in_data["template_type"] = [
x.template_type for x in in_data["template_type"]
x["template_type"] for x in in_data["template_type"]
]
if "status" in in_data:
in_data["status"] = [x.status for x in in_data["status"]]
in_data["status"] = [x["status"] for x in in_data["status"]]
return in_data
@validates("page")
def validate_page(self, value):
def validate_page(self, value, data_key):
_validate_positive_number(value)
@validates("page_size")
def validate_page_size(self, value):
def validate_page_size(self, value, data_key):
_validate_positive_number(value)
class ServiceHistorySchema(ma.Schema):
class ServiceHistorySchema(Schema):
class Meta:
unknown = EXCLUDE
@@ -744,7 +737,7 @@ class ServiceHistorySchema(ma.Schema):
version = fields.Integer()
class ApiKeyHistorySchema(ma.Schema):
class ApiKeyHistorySchema(Schema):
class Meta:
unknown = EXCLUDE
@@ -774,8 +767,10 @@ class UnarchivedTemplateSchema(BaseSchema):
# should not be used on its own for dumping - only for loading
create_user_schema = UserSchema()
user_update_schema_load_json = UserUpdateAttributeSchema(load_json=True, partial=True)
create_user_schema = UserSchema(transient=True)
user_update_schema_load_json = UserUpdateAttributeSchema(
load_json=True, partial=True, transient=True
)
user_update_password_schema_load_json = UserUpdatePasswordSchema(
only=("_password",), load_json=True, partial=True
)
@@ -784,20 +779,19 @@ detailed_service_schema = DetailedServiceSchema()
template_schema = TemplateSchema()
template_schema_no_detail = TemplateSchemaNoDetail()
api_key_schema = ApiKeySchema()
job_schema = JobSchema()
sms_template_notification_schema = SmsTemplateNotificationSchema()
email_notification_schema = EmailNotificationSchema()
notification_schema = NotificationModelSchema()
notification_with_template_schema = NotificationWithTemplateSchema()
notification_with_personalisation_schema = NotificationWithPersonalisationSchema()
invited_user_schema = InvitedUserSchema()
email_data_request_schema = EmailDataSchema()
partial_email_data_request_schema = EmailDataSchema(partial_email=True)
notifications_filter_schema = NotificationsFilterSchema()
public_notification_response_schema = NotificationWithTemplateSchema()
service_history_schema = ServiceHistorySchema()
api_key_history_schema = ApiKeyHistorySchema()
template_history_schema = TemplateHistorySchema()
event_schema = EventSchema()
provider_details_schema = ProviderDetailsSchema()
provider_details_history_schema = ProviderDetailsHistorySchema()
unarchived_template_schema = UnarchivedTemplateSchema()
job_schema = JobSchema()

View File

@@ -4,6 +4,7 @@ from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from flask import Blueprint, current_app, jsonify, request
from jsonschema import ValidationError
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.exc import NoResultFound
@@ -306,7 +307,7 @@ def create_service():
data["total_message_limit"] = current_app.config["TOTAL_MESSAGE_LIMIT"]
# validate json with marshmallow
service_schema.load(data)
service_schema.load(data, session=db.session)
user = get_user_by_id(data.pop("user_id"))
@@ -324,14 +325,21 @@ def create_service():
def update_service(service_id):
req_json = request.get_json()
fetched_service = dao_fetch_service_by_id(service_id)
# Capture the status change here as Marshmallow changes this later
service_going_live = fetched_service.restricted and not req_json.get(
"restricted", True
)
current_data = dict(service_schema.dump(fetched_service).items())
current_data.update(request.get_json())
current_data.update(req_json)
service = service_schema.load(current_data)
try:
service = service_schema.load(
current_data, session=db.session, instance=fetched_service, partial=True
)
except ValidationError as e:
current_app.logger.error(
f"Validation error during service update: {e.messages}"
)
return jsonify(errors=e.messages), 400
if "email_branding" in req_json:
email_branding_id = req_json["email_branding"]
@@ -340,6 +348,7 @@ def update_service(service_id):
if not email_branding_id
else db.session.get(EmailBranding, email_branding_id)
)
dao_update_service(service)
if service_going_live:
@@ -356,7 +365,7 @@ def update_service(service_id):
@service_blueprint.route("/<uuid:service_id>/api-key", methods=["POST"])
def create_api_key(service_id=None):
fetched_service = dao_fetch_service_by_id(service_id=service_id)
valid_api_key = api_key_schema.load(request.get_json())
valid_api_key = api_key_schema.load(request.get_json(), session=db.session)
valid_api_key.service = fetched_service
save_model_api_key(valid_api_key)
unsigned_api_key = get_unsigned_secret(valid_api_key.id)

View File

@@ -24,7 +24,7 @@ from app.notifications.process_notifications import (
persist_notification,
send_notification_to_queue,
)
from app.schemas import invited_user_schema
from app.schemas import InvitedUserSchema
from app.utils import utc_now
from notifications_utils.url_safe_token import check_token, generate_token
@@ -105,30 +105,48 @@ def create_invited_user(service_id):
current_app.logger.exception("state not found in submitted data.")
raise
invited_user = invited_user_schema.load(request_json)
invited_user = InvitedUserSchema(session=db.session).load(request_json)
save_invited_user(invited_user)
invite_data = _create_service_invite(invited_user, nonce, state)
return jsonify(data=invited_user_schema.dump(invited_user), invite=invite_data), 201
return (
jsonify(
data=InvitedUserSchema(session=db.session).dump(invited_user),
invite=invite_data,
),
201,
)
@service_invite.route("/service/<service_id>/invite/expired", methods=["GET"])
def get_expired_invited_users_by_service(service_id):
expired_invited_users = get_expired_invited_users_for_service(service_id)
return jsonify(data=invited_user_schema.dump(expired_invited_users, many=True)), 200
return (
jsonify(
data=InvitedUserSchema(session=db.session).dump(
expired_invited_users, many=True
)
),
200,
)
@service_invite.route("/service/<service_id>/invite", methods=["GET"])
def get_invited_users_by_service(service_id):
invited_users = get_invited_users_for_service(service_id)
return jsonify(data=invited_user_schema.dump(invited_users, many=True)), 200
return (
jsonify(
data=InvitedUserSchema(session=db.session).dump(invited_users, many=True)
),
200,
)
@service_invite.route("/service/<service_id>/invite/<invited_user_id>", methods=["GET"])
def get_invited_user_by_service(service_id, invited_user_id):
invited_user = get_invited_user_by_service_and_id(service_id, invited_user_id)
return jsonify(data=invited_user_schema.dump(invited_user)), 200
return jsonify(data=InvitedUserSchema(session=db.session).dump(invited_user)), 200
@service_invite.route(
@@ -139,11 +157,11 @@ def update_invited_user(service_id, invited_user_id):
service_id=service_id, invited_user_id=invited_user_id
)
current_data = dict(invited_user_schema.dump(fetched).items())
current_data = dict(InvitedUserSchema(session=db.session).dump(fetched).items())
current_data.update(request.get_json())
update_dict = invited_user_schema.load(current_data)
update_dict = InvitedUserSchema(session=db.session).load(current_data)
save_invited_user(update_dict)
return jsonify(data=invited_user_schema.dump(fetched)), 200
return jsonify(data=InvitedUserSchema(session=db.session).dump(fetched)), 200
@service_invite.route(
@@ -178,14 +196,21 @@ def resend_service_invite(service_id, invited_user_id):
fetched.created_at = utc_now()
fetched.status = InvitedUserStatus.PENDING
current_data = {k: v for k, v in invited_user_schema.dump(fetched).items()}
update_dict = invited_user_schema.load(current_data)
current_data = {
k: v for k, v in InvitedUserSchema(session=db.session).dump(fetched).items()
}
update_dict = InvitedUserSchema(session=db.session).load(current_data)
save_invited_user(update_dict)
invite_data = _create_service_invite(fetched, nonce, state)
return jsonify(data=invited_user_schema.dump(fetched), invite=invite_data), 200
return (
jsonify(
data=InvitedUserSchema(session=db.session).dump(fetched), invite=invite_data
),
200,
)
def invited_user_url(invited_user_id, invite_link_host=None):
@@ -204,7 +229,7 @@ def invited_user_url(invited_user_id, invite_link_host=None):
@service_invite.route("/invite/service/<uuid:invited_user_id>", methods=["GET"])
def get_invited_user(invited_user_id):
invited_user = get_invited_user_by_id(invited_user_id)
return jsonify(data=invited_user_schema.dump(invited_user)), 200
return jsonify(data=InvitedUserSchema(session=db.session).dump(invited_user)), 200
@service_invite.route("/invite/service/<token>", methods=["GET"])
@@ -232,7 +257,7 @@ def validate_service_invitation_token(token):
raise InvalidRequest(errors, status_code=400)
invited_user = get_invited_user_by_id(invited_user_id)
return jsonify(data=invited_user_schema.dump(invited_user)), 200
return jsonify(data=InvitedUserSchema(session=db.session).dump(invited_user)), 200
def get_user_data_url_safe(data):

View File

@@ -1,4 +1,4 @@
from flask import Blueprint, jsonify, request
from flask import Blueprint, current_app, jsonify, request
from sqlalchemy import text
from app import db, version
@@ -11,32 +11,52 @@ status = Blueprint("status", __name__)
@status.route("/", methods=["GET"])
@status.route("/_status", methods=["GET", "POST"])
def show_status():
if request.args.get("simple", None):
return jsonify(status="ok"), 200
else:
return (
jsonify(
status="ok", # This should be considered part of the public API
git_commit=version.__git_commit__,
build_time=version.__time__,
db_version=get_db_version(),
),
200,
try:
if request.args.get("simple", None):
return jsonify(status="ok"), 200
else:
return (
jsonify(
status="ok", # This should be considered part of the public API
git_commit=version.__git_commit__,
build_time=version.__time__,
db_version=get_db_version(),
),
200,
)
except Exception as e:
current_app.logger.error(
f"Unexpected error in show_status: {str(e)}", exc_info=True
)
raise Exception(status_code=503, detail="Service temporarily unavailable")
@status.route("/_status/live-service-and-organization-counts")
def live_service_and_organization_counts():
return (
jsonify(
organizations=dao_count_organizations_with_live_services(),
services=dao_count_live_services(),
),
200,
)
try:
return (
jsonify(
organizations=dao_count_organizations_with_live_services(),
services=dao_count_live_services(),
),
200,
)
except Exception as e:
current_app.logger.error(
f"Unexpected error in live_service_and_organization_counts: {str(e)}",
exc_info=True,
)
raise Exception(status_code=503, detail="Service temporarily unavailable")
def get_db_version():
query = "SELECT version_num FROM alembic_version"
full_name = db.session.execute(text(query)).fetchone()[0]
return full_name
try:
query = "SELECT version_num FROM alembic_version"
full_name = db.session.execute(text(query)).fetchone()[0]
return full_name
except Exception as e:
current_app.logger.error(
f"Unexpected error in get_db_version: {str(e)}",
exc_info=True,
)
raise Exception(status_code=503, detail="Database temporarily unavailable")

View File

@@ -1,6 +1,7 @@
from flask import Blueprint, jsonify, request
from sqlalchemy.orm.exc import NoResultFound
from app import db
from app.dao.services_dao import dao_fetch_service_by_id
from app.dao.template_folder_dao import dao_get_template_folder_by_id_and_service_id
from app.dao.templates_dao import (
@@ -135,7 +136,8 @@ def update_template(service_id, template_id):
errors = {"content": [message]}
raise InvalidRequest(errors, status_code=400)
update_dict = template_schema.load(updated_template)
update_dict = template_schema.load(updated_template, session=db.session)
if update_dict.archived:
update_dict.folder = None
dao_update_template(update_dict)

View File

@@ -42,7 +42,7 @@ def url_with_token(data, url, config, base_url=None):
return base_url + token
def get_template_instance(template, values):
def get_template_instance(template, values=None):
from app.enums import TemplateType
return {
@@ -51,6 +51,18 @@ def get_template_instance(template, values):
}[template["template_type"]](template, values)
def template_model_to_dict(template):
return {
"id": str(template.id),
"template_type": template.template_type,
"content": template.content,
"subject": getattr(template, "subject", None),
"created_at": template.created_at,
"name": template.name,
"version": template.version,
}
def get_midnight_in_utc(date):
"""
This function converts date to midnight in UTC,

View File

@@ -0,0 +1,51 @@
# Bring in the notifications-python-client directly to the API and Admin apps
Status: Accepted
Date: 10 June 2025
### Context
We still pull in the `notifications-python-client` as a third party library, and it is what underpins all of the requests made throughout the API and admin applications. Our apps have diverged enough from the original UK Notify applications that we ought to consider making our own copy of this library for our apps to make sure the client continues to meet our use cases and we aren't suddenly caught in a bind with an update that won't work for us.
Furthermore, we need to make some adjustments to this library in order to continue updating Python (e.g. Python 3.13) due to some incompatibilities, such as how SSL certificates are validated.
### Decision
We're going to pull in the `notifications-python-client` library (source code found here: https://github.com/alphagov/notifications-python-client to both the API and Admin apps just like we did with the `notifications-utils` library/repo. This will involve doing the following:
* Making a local copy of the `https://github.com/alphagov/notifications-python-client` code base (the code for the library itself - likely just what's in https://github.com/alphagov/notifications-python-client/tree/main/notifications_python_client but we need to double check) within a `notifications_python_client` folder at the root of the project directory.
* Incorporating any of the dependencies required for the library into our own directly (in the `pyproject.toml` file) that aren't already accounted for.
* Making sure all namespaces (e.g., `import` statements) within our app for references to the library continue to work still.
* Make sure tests are included/excluded as appropriate (similar to what we did with `notifications_utils`).
### Consequences
We anticipate the impacts to our project and team to be the following:
* No longer gaining any updates the library directly that are published to PyPI.
* Slight increased burden in codebase maintenance.
* A bit of extra work to incorporate the library in fully and completely to the API and Admin directly.
* No longer sharing code between the API and Admin; any changes made to the `notifications-python-client` in one app will need to be mirrored in the other.
However, we also anticipate these benefits in doing this work:
* Gaining full control of future changes to the `notifications-python-client` code.
* Ability to reduce the `notifications-python-client`'s footprint for our own needs and use cases.
* Ability to make changes and updates necessary to keep the other parts of the application up-to-date (e.g., Python updates)
* Removing reliance on a third-party dependency that we have no control over.
### Author
@ccostino
### Stakeholders
@ccostino
### Next Steps
Next steps once this draft ADR is posted:
* Team reviews the ADR and makes adjustments as necessary
* Team agrees on the approach of the ADR and finalizes it for acceptance (creates a proper ADR file for it)
* Issue(s) get created to perform the work in the API and the Admin

View File

@@ -54,5 +54,6 @@ applications:
SECRET_KEY: ((SECRET_KEY))
AWS_US_TOLL_FREE_NUMBER: ((default_toll_free_number))
SSL_CERT_FILE: "/etc/ssl/certs/ca-certificates.crt"
REQUESTS_CA_BUNDLE: "/etc/ssl/certs/ca-certificates.crt"
NEW_RELIC_CA_BUNDLE_PATH: "/etc/ssl/certs/ca-certificates.crt"

1059
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,12 +8,12 @@ readme = "README.md"
[tool.poetry.dependencies]
python = "^3.13.2"
python = "^3.12.2"
alembic = "==1.16.1"
amqp = "==5.3.1"
beautifulsoup4 = "==4.13.4"
boto3 = "^1.34.150"
botocore = "^1.34.159"
boto3 = "^1.38.27"
botocore = "^1.38.27"
cachetools = "==6.0.0"
celery = {version = "==5.5.3", extras = ["redis"]}
certifi = ">=2022.12.7"
@@ -29,19 +29,17 @@ eventlet = "==0.40.0"
expiringdict = "==1.2.2"
flask = "~=3.1"
flask-bcrypt = "==1.0.1"
flask-marshmallow = "==1.2.1"
flask-migrate = "==4.1.0"
flask-redis = "==0.4.0"
flask-sqlalchemy = "==3.1.1"
flask-sqlalchemy = "^3.1.1"
gunicorn = {version = "==23.0.0", extras = ["eventlet"]}
iso8601 = "==2.1.0"
jsonschema = {version = "==4.24.0", extras = ["format"]}
lxml = "==5.4.0"
marshmallow = "==3.26.1"
marshmallow-sqlalchemy = "==1.0.0"
marshmallow = "^4.0.0"
marshmallow-sqlalchemy = "^1.4.2"
newrelic = "*"
notifications-python-client = "==10.0.1"
oscrypto = { git = "https://github.com/wbond/oscrypto.git", rev = "1547f53" }
packaging = "==25.0"
poetry-dotenv-plugin = "==0.2.0"
psycopg2-binary = "==2.9.10"
@@ -49,36 +47,42 @@ pyjwt = "==2.10.1"
python-dotenv = "==1.1.0"
sqlalchemy = "==2.0.41"
werkzeug = "^3.0.6"
faker = "^37.3.0"
faker = "^37.4.0"
async-timeout = "^5.0.1"
bleach = "^6.1.0"
geojson = "^3.2.0"
numpy = "^2.2.6"
ordered-set = "^4.1.0"
phonenumbers = "^9.0.6"
phonenumbers = "^9.0.7"
python-json-logger = "^3.3.0"
regex = "^2024.11.6"
shapely = "^2.1.1"
smartypants = "^2.0.1"
mistune = "^3.1.3"
blinker = "^1.9.0"
cryptography = "^45.0.3"
cryptography = "^45.0.4"
idna = "^3.7"
jmespath = "^1.0.1"
markupsafe = "^3.0.2"
pycparser = "^2.22"
python-dateutil = "^2.9.0.post0"
pyyaml = "^6.0.2"
s3transfer = "^0.10.2"
s3transfer = "^0.13.0"
six = "^1.16.0"
urllib3 = "^2.3.0"
webencodings = "^0.5.1"
itsdangerous = "^2.2.0"
jinja2 = "^3.1.6"
redis = "^5.0.8"
requests = "^2.32.3"
requests = "^2.32.4"
flask-socketio = "^5.5.1"
virtualenv = "<20.30"
virtualenv = "^20.31.2"
marshmallow-enum = "^1.5.1"
awscli = "^1.40.26"
typing-extensions = "^4.14.0"
aiohttp = "^3.12.12"
pytest = "^8.4.0"
cyclonedx-python-lib = "^10.2.0"
[tool.poetry.group.dev.dependencies]

View File

@@ -1 +1 @@
python-3.13.x
python-3.12.x

View File

@@ -558,8 +558,8 @@ def test_get_notification_by_id_returns_merged_template_content(
def test_get_notification_by_id_returns_merged_template_content_for_email(
client, sample_email_template_with_placeholders, mocker
):
mock_s3 = mocker.patch("app.notifications.rest.get_personalisation_from_s3")
mock_s3.return_value = {"name": "foo"}
# mock_s3 = mocker.patch("app.notifications.rest.get_personalisation_from_s3")
# mock_s3.return_value = {"name": "foo"}
sample_notification = create_notification(
sample_email_template_with_placeholders, personalisation={"name": "world"}
)

View File

@@ -1,5 +1,3 @@
import pytest
from app.dao.api_key_dao import save_model_api_key
from app.enums import KeyType
from app.models import ApiKey
@@ -24,8 +22,6 @@ def _get_notification(client, notification, url):
# v0
def test_get_api_sms_contract(client, sample_notification):
response_json = return_json_from_response(
_get_notification(
@@ -37,18 +33,6 @@ def test_get_api_sms_contract(client, sample_notification):
validate_v0(response_json, "GET_notification_return_sms.json")
@pytest.mark.skip(reason="Update to fetch email from s3")
def test_get_api_email_contract(client, sample_email_notification):
response_json = return_json_from_response(
_get_notification(
client,
sample_email_notification,
"/notifications/{}".format(sample_email_notification.id),
)
)
validate_v0(response_json, "GET_notification_return_email.json")
def test_get_job_sms_contract(client, sample_notification):
response_json = return_json_from_response(
_get_notification(
@@ -60,22 +44,13 @@ def test_get_job_sms_contract(client, sample_notification):
validate_v0(response_json, "GET_notification_return_sms.json")
@pytest.mark.skip(reason="Update to fetch email from s3")
def test_get_job_email_contract(client, sample_email_notification):
response_json = return_json_from_response(
_get_notification(
client,
sample_email_notification,
"/notifications/{}".format(sample_email_notification.id),
)
)
validate_v0(response_json, "GET_notification_return_email.json")
def test_get_notifications_contract(
client, sample_notification, sample_email_notification
):
response_json = return_json_from_response(
_get_notification(client, sample_notification, "/notifications")
)
notifications = response_json["notifications"]
assert notifications, "No notifications returned"
assert notifications[0]["template"]["template_type"] == "sms"
validate_v0(response_json, "GET_notifications_return.json")

View File

@@ -738,6 +738,7 @@ def test_update_service(client, notify_db_session, sample_service):
headers=[("Content-Type", "application/json"), auth_header],
)
result = resp.json
assert resp.status_code == 200
assert result["data"]["name"] == "updated service name"
assert result["data"]["email_from"] == "updated.service.name"
@@ -1278,11 +1279,12 @@ def test_add_existing_user_to_another_service_with_all_permissions(
== user_already_in_service.email_address
)
fake_password = "password"
# add new user to service
user_to_add = User(
name="Invited User",
email_address="invited@digital.fake.gov",
password="password",
password=fake_password,
mobile_number="+14254147755",
)
# they must exist in db first
@@ -2013,9 +2015,6 @@ def test_get_monthly_notification_stats_by_user(
headers=[auth_header],
)
resp = json.loads(response.get_data(as_text=True))
print(f"RESP is {resp}")
# TODO This test could be a little more complete
assert response.status_code == 200
@@ -2040,8 +2039,6 @@ def test_get_single_month_notification_stats_by_user(
headers=[auth_header],
)
resp = json.loads(response.get_data(as_text=True))
print(f"RESP is {resp}")
# TODO This test could be a little more complete
assert response.status_code == 200
@@ -2064,9 +2061,6 @@ def test_get_single_month_notification_stats_for_service(
headers=[auth_header],
)
resp = json.loads(response.get_data(as_text=True))
print(f"RESP is {resp}")
# TODO This test could be a little more complete
assert response.status_code == 200

View File

@@ -48,7 +48,7 @@ def test_notification_schema_adds_api_key_name(sample_notification):
"notification_with_template_schema",
"notification_schema",
"notification_with_template_schema",
"notification_with_personalisation_schema",
"public_notification_response_schema",
],
)
def test_notification_schema_has_correct_status(sample_notification, schema_name):
@@ -67,7 +67,9 @@ def test_notification_schema_has_correct_status(sample_notification, schema_name
("mobile_number", "+14254147755"),
],
)
def test_user_update_schema_accepts_valid_attribute_pairs(user_attribute, user_value):
def test_user_update_schema_accepts_valid_attribute_pairs(
notify_api, user_attribute, user_value
):
update_dict = {user_attribute: user_value}
from app.schemas import user_update_schema_load_json
@@ -84,7 +86,9 @@ def test_user_update_schema_accepts_valid_attribute_pairs(user_attribute, user_v
("mobile_number", "+44077009"),
],
)
def test_user_update_schema_rejects_invalid_attribute_pairs(user_attribute, user_value):
def test_user_update_schema_rejects_invalid_attribute_pairs(
notify_api, user_attribute, user_value
):
from app.schemas import user_update_schema_load_json
update_dict = {user_attribute: user_value}
@@ -109,7 +113,9 @@ def test_user_update_schema_rejects_invalid_attribute_pairs(user_attribute, user
"platform_admin",
],
)
def test_user_update_schema_rejects_disallowed_attribute_keys(user_attribute):
def test_user_update_schema_rejects_disallowed_attribute_keys(
notify_api, user_attribute
):
update_dict = {user_attribute: "not important"}
from app.schemas import user_update_schema_load_json

View File

@@ -3,12 +3,14 @@ from datetime import date, datetime
import pytest
from freezegun import freeze_time
from app.enums import ServicePermissionType
from app.enums import ServicePermissionType, TemplateType
from app.utils import (
get_midnight_in_utc,
get_public_notify_type_text,
get_template_instance,
midnight_n_days_ago,
)
from notifications_utils.template import HTMLEmailTemplate, SMSMessageTemplate
@pytest.mark.parametrize(
@@ -52,3 +54,90 @@ def test_get_public_notify_type_text():
assert (
get_public_notify_type_text(ServicePermissionType.UPLOAD_DOCUMENT) == "document"
)
@pytest.mark.parametrize(
"template_type, expected_class",
[
(TemplateType.SMS, SMSMessageTemplate),
(TemplateType.EMAIL, HTMLEmailTemplate),
],
)
def test_get_template_instance_with_none_values(template_type, expected_class):
"""Test that get_template_instance handles None values safely for both template types"""
template = {
"template_type": template_type,
"id": "test-id",
"content": "Test content",
"subject": "Test subject" if template_type == TemplateType.EMAIL else None,
}
result = get_template_instance(template, values=None)
assert isinstance(result, expected_class)
result_default = get_template_instance(template)
assert isinstance(result_default, expected_class)
@pytest.mark.parametrize(
"template_type, expected_class",
[
(TemplateType.SMS, SMSMessageTemplate),
(TemplateType.EMAIL, HTMLEmailTemplate),
],
)
def test_get_template_instance_with_actual_values(template_type, expected_class):
"""Test that get_template_instance works normally with actual values"""
template = {
"template_type": template_type,
"id": "test-id",
"content": "Hello ((name))",
"subject": "Test subject" if template_type == TemplateType.EMAIL else None,
}
values = {"name": "World"}
result = get_template_instance(template, values=values)
assert isinstance(result, expected_class)
def test_get_template_instance_invalid_template_type():
"""Test that get_template_instance raises KeyError for invalid template types"""
template = {
"template_type": "INVALID_TYPE",
"id": "test-id",
"content": "Test content",
}
with pytest.raises(KeyError):
get_template_instance(template, values=None)
@pytest.mark.parametrize(
"template_type, values",
[
(TemplateType.SMS, None),
(TemplateType.SMS, {}),
(TemplateType.SMS, {"name": "test"}),
(TemplateType.EMAIL, None),
(TemplateType.EMAIL, {}),
(TemplateType.EMAIL, {"name": "test"}),
],
)
def test_get_template_instance_comprehensive(template_type, values):
"""Comprehensive test covering all combinations of template types and value scenarios"""
template = {
"template_type": template_type,
"id": "test-id",
"content": (
"Test content ((name))" if values and "name" in values else "Test content"
),
"subject": "Test subject" if template_type == TemplateType.EMAIL else None,
}
result = get_template_instance(template, values=values)
if template_type == TemplateType.SMS:
assert isinstance(result, SMSMessageTemplate)
else:
assert isinstance(result, HTMLEmailTemplate)