mirror of
https://github.com/GSA/notifications-api.git
synced 2026-08-27 01:33:42 -04:00
Compare commits
94 Commits
5-30-2025
...
26b94b82bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26b94b82bc | ||
|
|
eda0612561 | ||
|
|
eb50ca7554 | ||
|
|
0044a1580a | ||
|
|
fe1f333d4c | ||
|
|
5d9e612ecf | ||
|
|
ccf6f10169 | ||
|
|
79598ca498 | ||
|
|
669470202f | ||
|
|
7c41012dce | ||
|
|
aebf3ec92d | ||
|
|
f2f9bcce5c | ||
|
|
a1bf2918dc | ||
|
|
419e70bfd7 | ||
|
|
ca6ed1dca5 | ||
|
|
fb4d2624a0 | ||
|
|
2cc2022f83 | ||
|
|
414298fd79 | ||
|
|
172293d33a | ||
|
|
11c5f77806 | ||
|
|
18be2fc179 | ||
|
|
e04b3cf438 | ||
|
|
df48988db1 | ||
|
|
27e73343b8 | ||
|
|
1472755b51 | ||
|
|
82deb9633d | ||
|
|
8e7b3dead3 | ||
|
|
0582a775be | ||
|
|
bcbd98114c | ||
|
|
214ee24836 | ||
|
|
c4c3967def | ||
|
|
bc68f80958 | ||
|
|
e7406aaf60 | ||
|
|
97ea02e85a | ||
|
|
fbe733bc7e | ||
|
|
8e0bb22634 | ||
|
|
a6bc7b4985 | ||
|
|
dc71f03692 | ||
|
|
2abcbc5560 | ||
|
|
aa9cafb5f1 | ||
|
|
630641af1d | ||
|
|
ed4cbbc05b | ||
|
|
1547478bbf | ||
|
|
8c70b2b7bd | ||
|
|
57f98b1507 | ||
|
|
5eade384c5 | ||
|
|
9379ca0436 | ||
|
|
acfbffa3d8 | ||
|
|
965bb3047c | ||
|
|
bcd33e7af8 | ||
|
|
64535abb7c | ||
|
|
25ee386155 | ||
|
|
e15a49ba18 | ||
|
|
6a5c4de30f | ||
|
|
c8eae375f4 | ||
|
|
07195ba145 | ||
|
|
6d8e2221d0 | ||
|
|
8af59b1c55 | ||
|
|
baa8cc4bfa | ||
|
|
abdaa74d0b | ||
|
|
a431ccfb73 | ||
|
|
44f2c52a52 | ||
|
|
cf4deb083b | ||
|
|
aa445f5f40 | ||
|
|
fb3bf6d9b1 | ||
|
|
86c7e77197 | ||
|
|
f7f92dd1f4 | ||
|
|
ad17ddc23f | ||
|
|
ac9e3a49d8 | ||
|
|
72289ace08 | ||
|
|
7c1f3f2c3a | ||
|
|
8feced8d7f | ||
|
|
f0fefbef21 | ||
|
|
898607b40a | ||
|
|
c1507ec060 | ||
|
|
56bcf0dd68 | ||
|
|
a100fa6eb8 | ||
|
|
b7d87b6db8 | ||
|
|
68aa63945a | ||
|
|
2824056a7d | ||
|
|
1c54593e9e | ||
|
|
0402f6dc71 | ||
|
|
9b5a5d5eba | ||
|
|
7835ef1dd9 | ||
|
|
f9f7333d72 | ||
|
|
d2fc023622 | ||
|
|
21e4345d2e | ||
|
|
01ff34b364 | ||
|
|
66c77b5c82 | ||
|
|
05e5565286 | ||
|
|
71344ce079 | ||
|
|
f8858c944f | ||
|
|
43fb72d79c | ||
|
|
3250e22d3b |
14
.ds.baseline
14
.ds.baseline
@@ -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",
|
||||
@@ -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-05-29T17:15:40Z"
|
||||
"generated_at": "2025-06-09T16:07:54Z"
|
||||
}
|
||||
|
||||
2
.github/actions/deploy-proxy/action.yml
vendored
2
.github/actions/deploy-proxy/action.yml
vendored
@@ -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:
|
||||
|
||||
4
.github/actions/setup-project/action.yml
vendored
4
.github/actions/setup-project/action.yml
vendored
@@ -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.12.3
|
||||
- name: Set up Python 3.12.2
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.12.3"
|
||||
python-version: "3.12.2"
|
||||
- name: Install poetry
|
||||
shell: bash
|
||||
run: pip install poetry==2.1.3
|
||||
|
||||
13
.github/workflows/checks.yml
vendored
13
.github/workflows/checks.yml
vendored
@@ -87,20 +87,11 @@ 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
|
||||
- uses: pypa/gh-action-pip-audit@v1.0.8
|
||||
run: poetry export --output requirements.txt
|
||||
- uses: pypa/gh-action-pip-audit@v1.1.0
|
||||
with:
|
||||
inputs: requirements.txt
|
||||
ignore-vulns: |
|
||||
PYSEC-2022-43162
|
||||
PYSEC-2023-312
|
||||
|
||||
static-scan:
|
||||
|
||||
4
Makefile
4
Makefile
@@ -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
|
||||
|
||||
12
README.md
12
README.md
@@ -40,7 +40,7 @@ You will need the following items:
|
||||
This project currently works with these major versions of the following main
|
||||
components:
|
||||
|
||||
- Python 3.12.x
|
||||
- Python 3.13.x
|
||||
- PostgreSQL 15.x (version 12.x is used in the hosted environments)
|
||||
|
||||
These instructions will walk you through how to set your machine up with all of
|
||||
@@ -173,12 +173,12 @@ session to make the changes take effect.
|
||||
Now we're ready to install the Python version we need with `pyenv`, like so:
|
||||
|
||||
```sh
|
||||
pyenv install 3.12
|
||||
pyenv install 3.13
|
||||
```
|
||||
|
||||
This will install the latest version of Python 3.12.
|
||||
This will install the latest version of Python 3.13.
|
||||
|
||||
_NOTE: This project currently runs on Python 3.12.x._
|
||||
_NOTE: This project currently runs on Python 3.13.x._
|
||||
|
||||
#### Python Dependency Installation
|
||||
|
||||
@@ -311,10 +311,10 @@ If you're upgrading an existing project to a newer version of Python, you can
|
||||
follow these steps to get yourself up-to-date.
|
||||
|
||||
First, use `pyenv` to install the newer version of Python you'd like to use;
|
||||
we'll use `3.12` in our example here since we recently upgraded to this version:
|
||||
we'll use `3.13` in our example here since we recently upgraded to this version:
|
||||
|
||||
```sh
|
||||
pyenv install 3.12
|
||||
pyenv install 3.13
|
||||
```
|
||||
|
||||
Next, delete the virtual environment you previously had set up. If you followed
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"Can’t 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
|
||||
|
||||
@@ -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")
|
||||
|
||||
0
app/public_schemas/__init__.py
Normal file
0
app/public_schemas/__init__.py
Normal file
135
app/public_schemas/public.py
Normal file
135
app/public_schemas/public.py
Normal 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
|
||||
222
app/schemas.py
222
app/schemas.py
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
14
app/utils.py
14
app/utils.py
@@ -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,
|
||||
|
||||
51
docs/adrs/0014-adr-localize-notifications-python-client.md
Normal file
51
docs/adrs/0014-adr-localize-notifications-python-client.md
Normal 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
|
||||
@@ -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"
|
||||
|
||||
1265
poetry.lock
generated
1265
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -12,10 +12,10 @@ 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.2", extras = ["redis"]}
|
||||
celery = {version = "==5.5.3", extras = ["redis"]}
|
||||
certifi = ">=2022.12.7"
|
||||
cffi = "==1.17.1"
|
||||
charset-normalizer = "^3.4.2"
|
||||
@@ -25,60 +25,64 @@ click-didyoumean = "==0.3.1"
|
||||
click-plugins = "==1.1.1"
|
||||
click-repl = "==0.3.0"
|
||||
deprecated = "==1.2.14"
|
||||
eventlet = "==0.36.1"
|
||||
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.9"
|
||||
psycopg2-binary = "==2.9.10"
|
||||
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]
|
||||
@@ -96,7 +100,7 @@ jinja2-cli = {version = "==0.8.2", extras = ["yaml"]}
|
||||
moto = "==5.1.5"
|
||||
pip-audit = "*"
|
||||
pre-commit = "^4.2.0"
|
||||
pytest = "^8.3.2"
|
||||
pytest = "^8.4.0"
|
||||
pytest-env = "^1.1.3"
|
||||
pytest-mock = "^3.14.1"
|
||||
pytest-cov = "^6.1.1"
|
||||
|
||||
@@ -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"}
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user