merge from main

This commit is contained in:
Kenneth Kehl
2024-07-16 10:54:46 -07:00
60 changed files with 1313 additions and 673 deletions
+3 -3
View File
@@ -92,7 +92,7 @@
},
{
"path": "detect_secrets.filters.common.is_baseline_file",
"filename": ".secrets.baseline"
"filename": ".ds.baseline"
},
{
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
@@ -239,7 +239,7 @@
"filename": "tests/app/dao/test_services_dao.py",
"hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8",
"is_verified": false,
"line_number": 261,
"line_number": 265,
"is_secret": false
}
],
@@ -384,5 +384,5 @@
}
]
},
"generated_at": "2024-05-20T15:20:28Z"
"generated_at": "2024-07-10T20:12:22Z"
}
+10 -67
View File
@@ -1,79 +1,22 @@
<!--
Please follow the instructions found in this pull request template so that we
have all of the relevant details needed for our work.
At the minimum, please be sure to fill in all sections found below and also do
the following:
- Provide an appropriate and descriptive title for the pull request
- Link the pull request to its corresponding issue (must be done after creating
the pull request itself)
- Assign yourself as the author
- Attach the appropriate labels to it
- Set it to be on the Notify.gov project board
- Select one or more reviewers from the team or mark the pull request as a draft
depending on its current state
- If the pull request is a draft, please be sure to add reviewers once it is
ready for review and mark it ready for review
For each section, please delete the instructions/sample text (that includes this
text, though it is wrapped in an HTML comment just in case) and put in your own
information. Thank you!
Not sure what you should include or write in a pull request? Please read the
[pull request documentation in our docs!](https://github.com/GSA/notifications-api/blob/main/docs/all.md#pull-requests)
-->
*A note to PR reviewers: it may be helpful to review our
[code review documentation](https://github.com/GSA/notifications-api/blob/main/docs/all.md#code-reviews)
to know what to keep in mind while reviewing pull requests.*
*A note to PR reviewers: it may be helpful to review our [code review documentation](https://github.com/GSA/notifications-api/blob/main/docs/all.md#code-reviews) to know what to keep in mind while reviewing pull requests.*
## Description
Please enter a clear description about your proposed changes and what the
expected outcome(s) is/are from there. If there are complex implementation
details within the changes, this is a great place to explain those details using
plain language.
This should include:
- Links to issues that this PR addresses
- Screenshots or screen captures of any visible changes, especially for UI work
- Dependency changes
If there are any caveats, known issues, follow-up items, etc., make a quick note
of them here as well, though more details are probably warranted in the issue
itself in this case.
Please enter a detailed description here.
## TODO (optional)
If you're opening a draft PR, it might be helpful to list any outstanding work,
especially if you're asking folks to take a look before it's ready for full
review. In this case, create a small checklist with the outstanding items:
- [ ] TODO item 1
- [ ] TODO item 2
- [ ] TODO item ...
* [ ] TODO item 1
* [ ] TODO item 2
* [ ] TODO item ...
## Security Considerations
Please think about the security compliance aspect of your changes and what the
potential impacts might be.
**NOTE: Please be mindful of sharing sensitive information here! If you're not
sure of what to write, please ask the team first before writing anything here.**
Relevant details could include (and are not limited to) the following:
- Handling secrets/credential management (or specifically calling out that there
is nothing to handle)
- Any adjustments to the flow of data in and out the system, or even within it
- Connecting or disconnecting any external services to the application
- Handling of any sensitive information, such as PII
- Handling of information within log statements or other application monitoring
services/hooks
- The inclusion of a new external dependency or the removal of an existing one
- ... (anything else relevant from a security compliance perspective)
There are some cases where there are no security considerations to be had, e.g.,
updating our documentation with publicly available information. In those cases
it is fine to simply put something like this:
- None; this is a documentation update with publicly available information.
* Consideration 1
* Consideration 2
* Consideration ...
+5 -5
View File
@@ -18,11 +18,11 @@ jobs:
- name: Check for changes to Terraform
id: changed-terraform-files
uses: tj-actions/changed-files@v41
uses: tj-actions/changed-files@v44
with:
files: |
terraform/demo
terraform/shared
terraform/demo/**
terraform/shared/**
.github/workflows/deploy-demo.yml
- name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true'
@@ -76,7 +76,7 @@ jobs:
- name: Check for changes to templates.json
id: changed-templates
uses: tj-actions/changed-files@v41
uses: tj-actions/changed-files@v44
with:
files: |
app/config_files/templates.json
@@ -86,7 +86,7 @@ jobs:
- name: Check for changes to egress config
id: changed-egress-config
uses: tj-actions/changed-files@v41
uses: tj-actions/changed-files@v44
with:
files: |
deploy-config/egress_proxy/notify-api-demo.*.acl
+5 -5
View File
@@ -22,11 +22,11 @@ jobs:
- name: Check for changes to Terraform
id: changed-terraform-files
uses: tj-actions/changed-files@v41
uses: tj-actions/changed-files@v44
with:
files: |
terraform/production
terraform/shared
terraform/production/**
terraform/shared/**
.github/workflows/deploy-prod.yml
- name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true'
@@ -80,7 +80,7 @@ jobs:
- name: Check for changes to templates.json
id: changed-templates
uses: tj-actions/changed-files@v41
uses: tj-actions/changed-files@v44
with:
files: |
app/config_files/templates.json
@@ -90,7 +90,7 @@ jobs:
- name: Check for changes to egress config
id: changed-egress-config
uses: tj-actions/changed-files@v41
uses: tj-actions/changed-files@v44
with:
files: |
deploy-config/egress_proxy/notify-api-production.*.acl
+5 -5
View File
@@ -23,11 +23,11 @@ jobs:
- name: Check for changes to Terraform
id: changed-terraform-files
uses: tj-actions/changed-files@v41
uses: tj-actions/changed-files@v44
with:
files: |
terraform/staging
terraform/shared
terraform/staging/**
terraform/shared/**
.github/workflows/deploy.yml
- name: Terraform init
if: steps.changed-terraform-files.outputs.any_changed == 'true'
@@ -81,7 +81,7 @@ jobs:
- name: Check for changes to templates.json
id: changed-templates
uses: tj-actions/changed-files@v41
uses: tj-actions/changed-files@v44
with:
files: |
app/config_files/templates.json
@@ -91,7 +91,7 @@ jobs:
- name: Check for changes to egress config
id: changed-egress-config
uses: tj-actions/changed-files@v41
uses: tj-actions/changed-files@v44
with:
files: |
deploy-config/egress_proxy/notify-api-staging.*.acl
+8
View File
@@ -493,6 +493,14 @@ instructions above for more details.
- [Celery scheduled tasks](./docs/all.md#celery-scheduled-tasks)
- [Notify.gov](./docs/all.md#notifygov)
- [System Description](./docs/all.md#system-description)
- [Pull Requests](.docs/all.md#pull-requests)
- [Getting Started](.docs/all.md#getting-started)
- [Description](.docs/all.md#description)
- [TODO (optional)](.docs/all.md#todo-(optional))
- [Security Considerations](.docs/all.md#security-considerations)
- [Code Reviews](.docs/all.md#code-reviews)
- [For the reviewer](.docs/all.md#for-the-reviewer)
- [For the author](.docs/all.md#for-the-author)
- [Run Book](./docs/all.md#run-book)
- [ Alerts, Notifications, Monitoring](./docs/all.md#-alerts-notifications-monitoring)
- [ Restaging Apps](./docs/all.md#-restaging-apps)
+6 -3
View File
@@ -24,7 +24,7 @@ from app.notifications.process_notifications import persist_notification
from app.notifications.validators import check_service_over_total_message_limit
from app.serialised_models import SerialisedService, SerialisedTemplate
from app.service.utils import service_allowed_to_send_to
from app.utils import DATETIME_FORMAT, hilite, scrub, utc_now
from app.utils import DATETIME_FORMAT, hilite, utc_now
from notifications_utils.recipients import RecipientCSV
@@ -191,7 +191,7 @@ def save_sms(self, service_id, notification_id, encrypted_notification, sender_i
if not service_allowed_to_send_to(notification["to"], service, KeyType.NORMAL):
current_app.logger.info(
hilite(
scrub(f"service not allowed to send to {notification['to']}, aborting")
f"service not allowed to send for job_id {notification.get('job', None)}, aborting"
)
)
current_app.logger.debug(
@@ -224,8 +224,11 @@ def save_sms(self, service_id, notification_id, encrypted_notification, sender_i
)
# Kick off sns process in provider_tasks.py
sn = saved_notification
current_app.logger.info(
hilite(scrub(f"Going to deliver sms for recipient: {notification['to']}"))
hilite(
f"Deliver sms for job_id: {sn.job_id} row_number: {sn.job_row_number}"
)
)
provider_tasks.deliver_sms.apply_async(
[str(saved_notification.id)], queue=QueueNames.SEND_SMS
+12 -3
View File
@@ -9,7 +9,7 @@ from flask import current_app
from app.clients import AWS_CLIENT_CONFIG, Client
from app.cloudfoundry_config import cloud_config
from app.exceptions import NotificationTechnicalFailureException
from app.utils import hilite, scrub, utc_now
from app.utils import hilite, utc_now
class AwsCloudwatchClient(Client):
@@ -124,7 +124,14 @@ class AwsCloudwatchClient(Client):
self.warn_if_dev_is_opted_out(
message["delivery"]["providerResponse"], notification_id
)
current_app.logger.info(hilite(scrub(f"DELIVERED: {message}")))
# Here we map the answer from aws to the message_id.
# Previously, in send_to_providers, we mapped the job_id and row number
# to the message id. And on the admin side we mapped the csv filename
# to the job_id. So by tracing through all the logs we can go:
# filename->job_id->message_id->what really happened
current_app.logger.info(
hilite(f"DELIVERED: {message} for message_id {message_id}")
)
return (
"success",
message["delivery"]["providerResponse"],
@@ -142,7 +149,9 @@ class AwsCloudwatchClient(Client):
message["delivery"]["providerResponse"], notification_id
)
current_app.logger.info(hilite(scrub(f"FAILED: {message}")))
current_app.logger.info(
hilite(f"FAILED: {message} for message_id {message_id}")
)
return (
"failure",
message["delivery"]["providerResponse"],
+16 -6
View File
@@ -589,6 +589,17 @@ def process_row_from_job(job_id, job_row_number):
)
@notify_command(name="download-csv-file-by-name")
@click.option("-f", "--csv_filename", required=True, help="csv file name")
def download_csv_file_by_name(csv_filename):
bucket_name = current_app.config["CSV_UPLOAD_BUCKET"]["bucket"]
access_key = current_app.config["CSV_UPLOAD_BUCKET"]["access_key_id"]
secret = current_app.config["CSV_UPLOAD_BUCKET"]["secret_access_key"]
region = current_app.config["CSV_UPLOAD_BUCKET"]["region"]
print(s3.get_s3_file(bucket_name, csv_filename, access_key, secret, region))
@notify_command(name="populate-annual-billing-with-the-previous-years-allowance")
@click.option(
"-y",
@@ -854,13 +865,12 @@ def promote_user_to_platform_admin(user_email_address):
@notify_command(name="purge-csv-bucket")
def purge_csv_bucket():
bucket_name = getenv("CSV_BUCKET_NAME")
access_key = getenv("CSV_AWS_ACCESS_KEY_ID")
secret = getenv("CSV_AWS_SECRET_ACCESS_KEY")
region = getenv("CSV_AWS_REGION")
print("ABOUT TO RUN PURGE CSV BUCKET")
bucket_name = current_app.config["CSV_UPLOAD_BUCKET"]["bucket"]
access_key = current_app.config["CSV_UPLOAD_BUCKET"]["access_key_id"]
secret = current_app.config["CSV_UPLOAD_BUCKET"]["secret_access_key"]
region = current_app.config["CSV_UPLOAD_BUCKET"]["region"]
s3.purge_bucket(bucket_name, access_key, secret, region)
print("RAN PURGE CSV BUCKET")
"""
+27
View File
@@ -1,3 +1,4 @@
import calendar
from datetime import date, datetime, time, timedelta
from app.utils import utc_now
@@ -66,3 +67,29 @@ def get_calendar_year_for_datetime(start_date):
return year - 1
else:
return year
def get_number_of_days_for_month(year, month):
return calendar.monthrange(year, month)[1]
def generate_date_range(start_date, end_date=None, days=0):
if end_date:
current_date = start_date
while current_date <= end_date:
try:
yield current_date.date()
except ValueError:
pass
current_date += timedelta(days=1)
elif days > 0:
end_date = start_date + timedelta(days=days)
current_date = start_date
while current_date < end_date:
try:
yield current_date.date()
except ValueError:
pass
current_date += timedelta(days=1)
else:
return "An end_date or number of days must be specified"
+11 -11
View File
@@ -84,21 +84,21 @@ def update_fact_notification_status(process_day, notification_type, service_id):
def fetch_notification_status_for_service_by_month(start_date, end_date, service_id):
return (
db.session.query(
func.date_trunc("month", FactNotificationStatus.local_date).label("month"),
FactNotificationStatus.notification_type,
FactNotificationStatus.notification_status,
func.sum(FactNotificationStatus.notification_count).label("count"),
func.date_trunc("month", NotificationAllTimeView.created_at).label("month"),
NotificationAllTimeView.notification_type,
NotificationAllTimeView.status.label("notification_status"),
func.count(NotificationAllTimeView.id).label("count"),
)
.filter(
FactNotificationStatus.service_id == service_id,
FactNotificationStatus.local_date >= start_date,
FactNotificationStatus.local_date < end_date,
FactNotificationStatus.key_type != KeyType.TEST,
NotificationAllTimeView.service_id == service_id,
NotificationAllTimeView.created_at >= start_date,
NotificationAllTimeView.created_at < end_date,
NotificationAllTimeView.key_type != KeyType.TEST,
)
.group_by(
func.date_trunc("month", FactNotificationStatus.local_date).label("month"),
FactNotificationStatus.notification_type,
FactNotificationStatus.notification_status,
func.date_trunc("month", NotificationAllTimeView.created_at).label("month"),
NotificationAllTimeView.notification_type,
NotificationAllTimeView.status,
)
.all()
)
+107 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy.sql.expression import and_, asc, case, func
from app import db
from app.dao.dao_utils import VersionOptions, autocommit, version_class
from app.dao.date_util import get_current_calendar_year
from app.dao.date_util import generate_date_range, get_current_calendar_year
from app.dao.organization_dao import dao_get_organization_by_email_address
from app.dao.service_sms_sender_dao import insert_service_sms_sender
from app.dao.service_user_dao import dao_get_service_user
@@ -27,6 +27,7 @@ from app.models import (
InvitedUser,
Job,
Notification,
NotificationAllTimeView,
NotificationHistory,
Organization,
Permission,
@@ -40,6 +41,7 @@ from app.models import (
User,
VerifyCode,
)
from app.service import statistics
from app.utils import (
escape_special_characters,
get_archived_db_column_value,
@@ -426,6 +428,61 @@ def dao_fetch_todays_stats_for_service(service_id):
)
def dao_fetch_stats_for_service_from_days(service_id, start_date, end_date):
start_date = get_midnight_in_utc(start_date)
end_date = get_midnight_in_utc(end_date + timedelta(days=1))
return (
db.session.query(
NotificationAllTimeView.notification_type,
NotificationAllTimeView.status,
func.date_trunc("day", NotificationAllTimeView.created_at).label("day"),
func.count(NotificationAllTimeView.id).label("count"),
)
.filter(
NotificationAllTimeView.service_id == service_id,
NotificationAllTimeView.key_type != KeyType.TEST,
NotificationAllTimeView.created_at >= start_date,
NotificationAllTimeView.created_at < end_date,
)
.group_by(
NotificationAllTimeView.notification_type,
NotificationAllTimeView.status,
func.date_trunc("day", NotificationAllTimeView.created_at),
)
.all()
)
def dao_fetch_stats_for_service_from_days_for_user(
service_id, start_date, end_date, user_id
):
start_date = get_midnight_in_utc(start_date)
end_date = get_midnight_in_utc(end_date + timedelta(days=1))
return (
db.session.query(
NotificationAllTimeView.notification_type,
NotificationAllTimeView.status,
func.date_trunc("day", NotificationAllTimeView.created_at).label("day"),
func.count(NotificationAllTimeView.id).label("count"),
)
.filter(
NotificationAllTimeView.service_id == service_id,
NotificationAllTimeView.key_type != KeyType.TEST,
NotificationAllTimeView.created_at >= start_date,
NotificationAllTimeView.created_at < end_date,
NotificationAllTimeView.created_by_id == user_id,
)
.group_by(
NotificationAllTimeView.notification_type,
NotificationAllTimeView.status,
func.date_trunc("day", NotificationAllTimeView.created_at),
)
.all()
)
def dao_fetch_todays_stats_for_all_services(
include_from_test_key=True, only_active=True
):
@@ -607,3 +664,52 @@ def get_live_services_with_organization():
)
return query.all()
def fetch_notification_stats_for_service_by_month_by_user(
start_date, end_date, service_id, user_id
):
return (
db.session.query(
func.date_trunc("month", NotificationAllTimeView.created_at).label("month"),
NotificationAllTimeView.notification_type,
(NotificationAllTimeView.status).label("notification_status"),
func.count(NotificationAllTimeView.id).label("count"),
)
.filter(
NotificationAllTimeView.service_id == service_id,
NotificationAllTimeView.created_at >= start_date,
NotificationAllTimeView.created_at < end_date,
NotificationAllTimeView.key_type != KeyType.TEST,
NotificationAllTimeView.created_by_id == user_id,
)
.group_by(
func.date_trunc("month", NotificationAllTimeView.created_at).label("month"),
NotificationAllTimeView.notification_type,
NotificationAllTimeView.status,
)
.all()
)
def get_specific_days_stats(data, start_date, days=None, end_date=None):
if days is not None and end_date is not None:
raise ValueError("Only set days OR set end_date, not both.")
elif days is not None:
gen_range = generate_date_range(start_date, days=days)
elif end_date is not None:
gen_range = generate_date_range(start_date, end_date)
else:
raise ValueError("Either days or end_date must be set.")
grouped_data = {date: [] for date in gen_range} | {
day: [row for row in data if row.day.date() == day]
for day in {item.day.date() for item in data}
}
stats = {
day.strftime("%Y-%m-%d"): statistics.format_statistics(rows)
for day, rows in grouped_data.items()
}
return stats
+13 -1
View File
@@ -4,7 +4,7 @@ from secrets import randbelow
import sqlalchemy
from flask import current_app
from sqlalchemy import func
from sqlalchemy import func, text
from sqlalchemy.orm import joinedload
from app import db
@@ -244,3 +244,15 @@ def user_can_be_archived(user):
return False
return True
def dao_report_users():
sql = """
select users.name, users.email_address, users.mobile_number, services.name as service_name
from users
inner join user_to_service on users.id=user_to_service.user_id
inner join services on services.id=user_to_service.service_id
where services.name not like '_archived%'
order by services.name asc, users.name asc
"""
return db.session.execute(text(sql))
+9 -6
View File
@@ -13,7 +13,7 @@ from app.dao.provider_details_dao import get_provider_details_by_notification_ty
from app.enums import BrandType, KeyType, NotificationStatus, NotificationType
from app.exceptions import NotificationTechnicalFailureException
from app.serialised_models import SerialisedService, SerialisedTemplate
from app.utils import hilite, scrub, utc_now
from app.utils import hilite, utc_now
from notifications_utils.template import (
HTMLEmailTemplate,
PlainTextEmailTemplate,
@@ -89,7 +89,7 @@ def send_sms_to_provider(notification):
except Exception:
# It is our 2facode, maybe
key = f"2facode-{notification.id}".replace(" ", "")
recipient = redis_store.raw_get(key)
recipient = redis_store.get(key)
if recipient:
recipient = recipient.decode("utf-8")
@@ -113,15 +113,18 @@ def send_sms_to_provider(notification):
message_id = provider.send_sms(**send_sms_kwargs)
current_app.logger.info(f"got message_id {message_id}")
except Exception as e:
msg = f"FAILED sending message for this recipient: {recipient} to sms"
current_app.logger.error(hilite(scrub(f"{msg} {e}")))
n = notification
msg = f"FAILED send to sms, job_id: {n.job_id} row_number {n.job_row_number} message_id {message_id}"
current_app.logger.error(hilite(f"{msg} {e}"))
notification.billable_units = template.fragment_count
dao_update_notification(notification)
raise e
else:
msg = f"Sending message for this recipient: {recipient} to sms"
current_app.logger.info(hilite(scrub(msg)))
# Here we map the job_id and row number to the aws message_id
n = notification
msg = f"Send to aws for job_id {n.job_id} row_number {n.job_row_number} message_id {message_id}"
current_app.logger.info(hilite(msg))
notification.billable_units = template.fragment_count
update_notification_to_sending(notification, provider)
return message_id
+2 -2
View File
@@ -12,7 +12,7 @@ from app.dao.notifications_dao import (
from app.enums import KeyType, NotificationStatus, NotificationType
from app.errors import BadRequestError
from app.models import Notification
from app.utils import hilite, scrub, utc_now
from app.utils import hilite, utc_now
from notifications_utils.recipients import (
format_email_address,
get_international_phone_info,
@@ -112,7 +112,7 @@ def persist_notification(
)
current_app.logger.info(
hilite(
scrub(f"Persisting notification with recipient {formatted_recipient}")
f"Persisting notification with job_id: {job_id} row_number: {job_row_number}"
)
)
recipient_info = get_international_phone_info(formatted_recipient)
+179 -14
View File
@@ -1,6 +1,7 @@
import itertools
from datetime import datetime
from datetime import datetime, timedelta
from botocore.exceptions import ClientError
from flask import Blueprint, current_app, jsonify, request
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.exc import NoResultFound
@@ -17,7 +18,7 @@ from app.dao.api_key_dao import (
save_model_api_key,
)
from app.dao.dao_utils import dao_rollback, transaction
from app.dao.date_util import get_calendar_year
from app.dao.date_util import get_calendar_year, get_month_start_and_end_date_in_utc
from app.dao.fact_notification_status_dao import (
fetch_monthly_template_usage_for_service,
fetch_notification_status_for_service_by_month,
@@ -63,13 +64,17 @@ from app.dao.services_dao import (
dao_fetch_all_services_by_user,
dao_fetch_live_services_data,
dao_fetch_service_by_id,
dao_fetch_stats_for_service_from_days,
dao_fetch_stats_for_service_from_days_for_user,
dao_fetch_todays_stats_for_all_services,
dao_fetch_todays_stats_for_service,
dao_remove_user_from_service,
dao_resume_service,
dao_suspend_service,
dao_update_service,
fetch_notification_stats_for_service_by_month_by_user,
get_services_by_partial_name,
get_specific_days_stats,
)
from app.dao.templates_dao import dao_get_template_by_id
from app.dao.users_dao import get_user_by_id
@@ -210,6 +215,58 @@ def get_service_notification_statistics(service_id):
)
@service_blueprint.route("/<uuid:service_id>/statistics/<string:start>/<int:days>")
def get_service_notification_statistics_by_day(service_id, start, days):
return jsonify(
data=get_service_statistics_for_specific_days(service_id, start, int(days))
)
def get_service_statistics_for_specific_days(service_id, start, days=1):
# start and end dates needs to be reversed because
# the end date is today and the start is x days in the past
# a day needs to be substracted to allow for today
end_date = datetime.strptime(start, "%Y-%m-%d")
start_date = end_date - timedelta(days=days - 1)
results = dao_fetch_stats_for_service_from_days(service_id, start_date, end_date)
stats = get_specific_days_stats(results, start_date, days=days)
return stats
@service_blueprint.route(
"/<uuid:service_id>/statistics/user/<uuid:user_id>/<string:start>/<int:days>"
)
def get_service_notification_statistics_by_day_by_user(
service_id, user_id, start, days
):
return jsonify(
data=get_service_statistics_for_specific_days_by_user(
service_id, user_id, start, int(days)
)
)
def get_service_statistics_for_specific_days_by_user(
service_id, user_id, start, days=1
):
# start and end dates needs to be reversed because
# the end date is today and the start is x days in the past
# a day needs to be substracted to allow for today
end_date = datetime.strptime(start, "%Y-%m-%d")
start_date = end_date - timedelta(days=days - 1)
results = dao_fetch_stats_for_service_from_days_for_user(
service_id, start_date, end_date, user_id
)
stats = get_specific_days_stats(results, start_date, days=days)
return stats
@service_blueprint.route("", methods=["POST"])
def create_service():
data = request.get_json()
@@ -444,18 +501,44 @@ def get_all_notifications_for_service(service_id):
for notification in pagination.items:
if notification.job_id is not None:
notification.personalisation = get_personalisation_from_s3(
notification.service_id,
notification.job_id,
notification.job_row_number,
)
recipient = get_phone_number_from_s3(
notification.service_id,
notification.job_id,
notification.job_row_number,
)
notification.to = recipient
notification.normalised_to = recipient
try:
notification.personalisation = get_personalisation_from_s3(
notification.service_id,
notification.job_id,
notification.job_row_number,
)
except ClientError as ex:
if ex.response["Error"]["Code"] == "NoSuchKey":
s = notification.service_id
j = notification.job_id
current_app.logger.warning(
f"No personalisation found for s3 file location service: service-{s}-notify/{j}.csv"
)
notification.personalisation = ""
else:
raise ex
try:
recipient = get_phone_number_from_s3(
notification.service_id,
notification.job_id,
notification.job_row_number,
)
notification.to = recipient
notification.normalised_to = recipient
except ClientError as ex:
if ex.response["Error"]["Code"] == "NoSuchKey":
s = notification.service_id
j = notification.job_id
current_app.logger.warning(
f"No phone number found for s3 file location service: service-{s}-notify/{j}.csv"
)
notification.to = ""
notification.normalised_to = ""
else:
raise ex
else:
notification.to = "1"
notification.normalised_to = "1"
@@ -592,6 +675,7 @@ def get_monthly_notification_stats(service_id):
stats = fetch_notification_status_for_service_by_month(
start_date, end_date, service_id
)
statistics.add_monthly_notification_status_stats(data, stats)
now = utc_now()
@@ -604,6 +688,87 @@ def get_monthly_notification_stats(service_id):
return jsonify(data=data)
@service_blueprint.route(
"/<uuid:service_id>/notifications/<uuid:user_id>/monthly", methods=["GET"]
)
def get_monthly_notification_stats_by_user(service_id, user_id):
# check service_id validity
dao_fetch_service_by_id(service_id)
# user = get_user_by_id(user_id=user_id)
try:
year = int(request.args.get("year", "NaN"))
except ValueError:
raise InvalidRequest("Year must be a number", status_code=400)
start_date, end_date = get_calendar_year(year)
data = statistics.create_empty_monthly_notification_status_stats_dict(year)
stats = fetch_notification_stats_for_service_by_month_by_user(
start_date, end_date, service_id, user_id
)
statistics.add_monthly_notification_status_stats(data, stats)
now = utc_now()
if end_date > now:
todays_deltas = fetch_notification_status_for_service_for_day(
now, service_id=service_id
)
statistics.add_monthly_notification_status_stats(data, todays_deltas)
return jsonify(data=data)
@service_blueprint.route(
"/<uuid:service_id>/notifications/<uuid:user_id>/month", methods=["GET"]
)
def get_single_month_notification_stats_by_user(service_id, user_id):
# check service_id validity
dao_fetch_service_by_id(service_id)
try:
month = int(request.args.get("month", "NaN"))
year = int(request.args.get("year", "NaN"))
except ValueError:
raise InvalidRequest(
"Both a month and year are required as numbers", status_code=400
)
month_year = datetime(year, month, 10, 00, 00, 00)
start_date, end_date = get_month_start_and_end_date_in_utc(month_year)
results = dao_fetch_stats_for_service_from_days_for_user(
service_id, start_date, end_date, user_id
)
stats = get_specific_days_stats(results, start_date, end_date=end_date)
return jsonify(stats)
@service_blueprint.route("/<uuid:service_id>/notifications/month", methods=["GET"])
def get_single_month_notification_stats_for_service(service_id):
# check service_id validity
dao_fetch_service_by_id(service_id)
try:
month = int(request.args.get("month", "NaN"))
year = int(request.args.get("year", "NaN"))
except ValueError:
raise InvalidRequest(
"Both a month and year are required as numbers", status_code=400
)
month_year = datetime(year, month, 10, 00, 00, 00)
start_date, end_date = get_month_start_and_end_date_in_utc(month_year)
results = dao_fetch_stats_for_service_from_days(service_id, start_date, end_date)
stats = get_specific_days_stats(results, start_date, end_date=end_date)
return jsonify(stats)
def get_detailed_service(service_id, today_only=False):
service = dao_fetch_service_by_id(service_id)
+1 -2
View File
@@ -113,7 +113,6 @@ def create_empty_monthly_notification_status_stats_dict(year):
def add_monthly_notification_status_stats(data, stats):
for row in stats:
month = row.month.strftime("%Y-%m")
data[month][row.notification_type][row.notification_status] += row.count
data[month][row.notification_type][StatisticsType.REQUESTED] += row.count
return data
+19 -1
View File
@@ -18,6 +18,7 @@ from app.dao.users_dao import ( # get_user_and_accounts,; update_user_password,
create_secret_code,
create_user_code,
dao_archive_user,
dao_report_users,
get_login_gov_user,
get_user_and_accounts,
get_user_by_email,
@@ -175,6 +176,17 @@ def activate_user(user_id):
return jsonify(data=user.serialize()), 200
@user_blueprint.route("/<uuid:user_id>/deactivate", methods=["POST"])
def deactivate_user(user_id):
user = get_user_by_id(user_id=user_id)
if user.state == "pending":
raise InvalidRequest("User already inactive", status_code=400)
user.state = "pending"
save_model_user(user)
return jsonify(data=user.serialize()), 200
@user_blueprint.route("/<uuid:user_id>/reset-failed-login-count", methods=["POST"])
def user_reset_failed_login_count(user_id):
user_to_update = get_user_by_id(user_id=user_id)
@@ -359,7 +371,7 @@ def create_2fa_code(
saved_notification.personalisation = personalisation
key = f"2facode-{saved_notification.id}".replace(" ", "")
recipient = str(recipient)
redis_store.raw_set(key, recipient, ex=60 * 60)
redis_store.set(key, recipient, ex=60 * 60)
# Assume that we never want to observe the Notify service's research mode
# setting for this notification - we still need to be able to log into the
@@ -665,6 +677,12 @@ def get_all_users():
# return jsonify(data=user.serialize()), 200
@user_blueprint.route("/report-all-users", methods=["GET"])
def report_all_users():
users = dao_report_users()
return jsonify(data=users.serialize()), 200
@user_blueprint.route("/<uuid:user_id>/organizations-and-services", methods=["GET"])
def get_organizations_and_services_for_user(user_id):
user = get_user_and_accounts(user_id)
-13
View File
@@ -1,4 +1,3 @@
import re
from datetime import datetime, timedelta, timezone
from flask import url_for
@@ -145,15 +144,3 @@ def naive_utcnow():
def utc_now():
return naive_utcnow()
def scrub(msg):
# Eventually we want to scrub all messages in all logs for phone numbers
# and email addresses, masking them. Ultimately this will probably get
# refactored into a 'SafeLogger' subclass or something, but let's start here
# with phones.
phones = re.findall("(?:\\+ *)?\\d[\\d\\- ]{7,}\\d", msg)
phones = [phone.replace("-", "").replace(" ", "") for phone in phones]
for phone in phones:
msg = msg.replace(phone, f"1XXXXX{phone[-5:]}")
return msg
+2 -2
View File
@@ -1,8 +1,8 @@
env: production
web_instances: 2
web_memory: 1G
web_memory: 2G
worker_instances: 1
worker_memory: 1G
worker_memory: 2G
scheduler_memory: 256M
public_api_route: notify-api.app.cloud.gov
admin_base_url: https://beta.notify.gov
+148 -12
View File
@@ -38,6 +38,11 @@
- [Celery scheduled tasks](#celery-scheduled-tasks)
- [Notify.gov](#notifygov)
- [System Description](#system-description)
- [Pull Requests](#pull-requests)
- [Getting Started](#getting-started)
- [Description](#description)
- [TODO (optional)](#todo-(optional))
- [Security Considerations](#security-considerations)
- [Code Reviews](#code-reviews)
- [For the reviewer](#for-the-reviewer)
- [For the author](#for-the-author)
@@ -55,6 +60,9 @@
- [Data Storage Policies \& Procedures](#data-storage-policies--procedures)
- [Potential PII Locations](#potential-pii-locations)
- [Data Retention Policy](#data-retention-policy)
- [Debug messages not being sent](#debug-messages-not-being-sent)
- [Getting the file location and tracing what happens](#getting-the-file-location-and-tracing-what-happens)
- [Viewing the csv file](#viewing-the-csv-file)
# Infrastructure overview
@@ -497,7 +505,7 @@ flask command purge_functional_test_data -u <functional tests user name prefix>
Running on cloud.gov:
```
cf run-task notify-api "flask command purge_functional_test_data -u <functional tests user name prefix>"
cf run-task notify-api --command "flask command purge_functional_test_data -u <functional tests user name prefix>"
```
@@ -817,6 +825,97 @@ Notify.gov also provisions and uses two AWS services via a [supplemental service
For further details of the system and how it connects to supporting services, see the [application boundary diagram](https://github.com/GSA/us-notify-compliance/blob/main/diagrams/rendered/apps/application.boundary.png)
Pull Requests
=============
Changes are made to our applications via pull requests, which show a diff
(the before and after state of all proposed changes in the code) of of the work
done for that particular branch. We use pull requests as the basis for working
on Notify.gov and modifying the application over time for improvements, bug
fixes, new features, and more.
There are several things that make for a good and complete pull request:
* An appropriate and descriptive title
* A detailed description of what's being changed, including any outstanding work
(TODOs)
* A list of security considerations, which contains information about anything
we need to be mindful of from a security compliance perspective
* The proper labels, assignee, code reviewer, and other project metadata set
### Getting Started
When you first open a pull request, start off by making sure the metadata for it
is in place:
* Provide an appropriate and descriptive title for the pull request
* Link the pull request to its corresponding issue (must be done after creating
the pull request itself)
* Assign yourself as the author
* Attach the appropriate labels to it
* Set it to be on the Notify.gov project board
* Select one or more reviewers from the team or mark the pull request as a draft
depending on its current state
* If the pull request is a draft, please be sure to add reviewers once it is
ready for review and mark it ready for review
### Description
Please enter a clear description about your proposed changes and what the
expected outcome(s) is/are from there. If there are complex implementation
details within the changes, this is a great place to explain those details using
plain language.
This should include:
* Links to issues that this PR addresses (especially if more than one)
* Screenshots or screen captures of any visible changes, especially for UI work
* Dependency changes
If there are any caveats, known issues, follow-up items, etc., make a quick note
of them here as well, though more details are probably warranted in the issue
itself in this case.
### TODO (optional)
If you're opening a draft PR, it might be helpful to list any outstanding work,
especially if you're asking folks to take a look before it's ready for full
review. In this case, create a small checklist with the outstanding items:
* [ ] TODO item 1
* [ ] TODO item 2
* [ ] TODO item ...
### Security Considerations
Please think about the security compliance aspect of your changes and what the
potential impacts might be.
**NOTE: Please be mindful of sharing sensitive information here! If you're not sure of what to write, please ask the team first before writing anything here.**
Relevant details could include (and are not limited to) the following:
* Handling secrets/credential management (or specifically calling out that there
is nothing to handle)
* Any adjustments to the flow of data in and out the system, or even within it
* Connecting or disconnecting any external services to the application
* Handling of any sensitive information, such as PII
* Handling of information within log statements or other application monitoring
services/hooks
* The inclusion of a new external dependency or the removal of an existing one
* ... (anything else relevant from a security compliance perspective)
There are some cases where there are no security considerations to be had, e.g.,
updating our documentation with publicly available information. In those cases
it is fine to simply put something like this:
* None; this is a documentation update with publicly available information.
This way it shows that we still gave this section consideration and that nothing
happens to apply in this scenario.
Code Reviews
============
@@ -856,19 +955,19 @@ behavior and lack of professionalism is not acceptable or tolerated.**
When performing a code review, it is helpful to keep the following guidelines in
mind:
- Be on the lookout for any sensitive information and/or leaked credentials,
* Be on the lookout for any sensitive information and/or leaked credentials,
secrets, PII, etc.
- Ask and call out things that aren't clear to you; it never hurts to double
* Ask and call out things that aren't clear to you; it never hurts to double
check your understanding of something!
- Check that things are named descriptively and appropriately and call out
* Check that things are named descriptively and appropriately and call out
anything that is not.
- Check that comments are present for complex areas when needed.
- Make sure the pull request itself is properly prepared - it has a clear
* Check that comments are present for complex areas when needed.
* Make sure the pull request itself is properly prepared - it has a clear
description, calls out security concerns, and has the necessary labels, flags,
issue link, etc., set on it.
- Do not be shy about using the suggested changes feature in GitHub pull request
* Do not be shy about using the suggested changes feature in GitHub pull request
comments; this can help save a lot of time!
- Do not be shy about marking a review with the `Request Changes` status - yes,
* Do not be shy about marking a review with the `Request Changes` status - yes,
it looks big and red when it shows up, but this is completely fine and not to
be taken as a personal mark against the author(s) of the pull request!
@@ -896,14 +995,14 @@ behavior and lack of professionalism is not acceptable or tolerated.**
When going over a review, it may be helpful to keep these perspectives in mind:
- Approach the review with an open mind, curiosity, and appreciation.
- If anything the reviewer(s) mentions is unclear to you, please ask for
* Approach the review with an open mind, curiosity, and appreciation.
* If anything the reviewer(s) mentions is unclear to you, please ask for
clarification and engage them in further dialogue!
- If you disagree with a suggestion or request, please say so and engage in an
* If you disagree with a suggestion or request, please say so and engage in an
open and respecful dialogue to come to a mutual understanding of what the
appropriate next step(S) should be - accept the change, reject the change,
take a different path entirely, etc.
- If there are no issues with any suggested edits or requested changes, make
* If there are no issues with any suggested edits or requested changes, make
the necessary adjustments and let the reviewer(s) know when the work is ready
for review again.
@@ -1224,3 +1323,40 @@ Data Retention Policy
Seven (7) days by default. Each service can be set with a custom policy via `ServiceDataRetention` by a Platform Admin. The `ServiceDataRetention` setting applies per-service and per-message type and controls both entries in the `notifications` table as well as `csv` contact files uploaded to s3
Data cleanup is controlled by several tasks in the `nightly_tasks.py` file, kicked off by Celery Beat.
# Debug messages not being sent
## Getting the file location and tracing what happens
Ask the user to provide the csv file name. Either the csv file they uploaded, or the one that is autogenerated when they do a one-off send and is visible in the UI
Starting with the admin logs, search for this file name. When you find it, the log line should have the file name linked to the job_id and the csv file location. Save both of these.
In the api logs, search by job_id. Either you will see evidence of the job failing and retrying over and over (in which case search for a stack trace using timestamp), or you will ultimately get to a log line that links the job_id to a message_id. In this case, now search by message_id. You should be able to find the actual result from AWS, either success or failure, with hopefully some helpful info.
## Viewing the csv file
If you need to view the questionable csv file on production, run the following command:
```
cf run-task notify-api-production --command "flask command download-csv-file-by-name -f <file location found in admin logs>"
```
locally, just do:
```
poetry run flask command download-csv-file-by-name -f <file location in admin logs>
```
## Debug steps
1. Either send a message and capture the csv file name, or get a csv file name from a user
2. Using the log tool at logs.fr.cloud.gov, use filters to limit what you're searching on (cf.app is 'notify-admin-production' for example) and then search with the csv file name in double quotes over the relevant time period (last 5 minutes if you just sent a message, or else whatever time the user sent at)
3. When you find the log line, you should also find the job_id and the s3 file location. Save these somewhere.
4. To get the csv file contents, you can run the command above. This command currently prints to the notify-api log, so after you run the command,
you need to search in notify-api-production for the last 5 minutes with the logs sorted by timestamp. The contents of the csv file unfortunately appear on separate lines so it's very important to sort by time.
5. If you want to see where the message actually failed, search with cf.app is notify-api-production using the job_id that you saved in step #3. If you get far enough, you might see one of the log lines has a message_id. If you see it, you can switch and search on that, which should tell you what happened in AWS (success or failure).
+1 -1
View File
@@ -11,7 +11,7 @@ applications:
services:
- notify-api-rds-((env))
- notify-api-redis-((env))
- notify-api-redis-v70-((env))
- notify-api-csv-upload-bucket-((env))
- name: notify-api-ses-((env))
parameters:
+12 -11
View File
@@ -15,6 +15,7 @@ from alembic import op
from sqlalchemy import text
from app.hashing import hashpw
from app.utils import utc_now
revision = "0025_notify_service_data"
down_revision = "0024_add_research_mode_defaults"
@@ -32,7 +33,7 @@ def upgrade():
"""
conn.execute(
text(user_insert),
{"user_id": user_id, "time_now": datetime.utcnow(), "password": password},
{"user_id": user_id, "time_now": utc_now(), "password": password},
)
service_history_insert = """INSERT INTO services_history (id, name, created_at, active, message_limit, restricted, research_mode, email_from, created_by_id, reply_to_email_address, version)
VALUES (:service_id, 'Notify service', :time_now, True, 1000, False, False, 'testsender@dispostable.com',
@@ -41,7 +42,7 @@ def upgrade():
"""
conn.execute(
text(service_history_insert),
{"service_id": service_id, "time_now": datetime.utcnow(), "user_id": user_id},
{"service_id": service_id, "time_now": utc_now(), "user_id": user_id},
)
service_insert = """INSERT INTO services (id, name, created_at, active, message_limit, restricted, research_mode, email_from, created_by_id, reply_to_email_address, version)
VALUES (:service_id, 'Notify service', :time_now, True, 1000, False, False, 'testsender@dispostable.com',
@@ -49,7 +50,7 @@ def upgrade():
"""
conn.execute(
text(service_insert),
{"service_id": service_id, "time_now": datetime.utcnow(), "user_id": user_id},
{"service_id": service_id, "time_now": utc_now(), "user_id": user_id},
)
user_to_service_insert = """INSERT INTO user_to_service (user_id, service_id) VALUES (:user_id, :service_id)"""
conn.execute(
@@ -74,7 +75,7 @@ def upgrade():
"template_id": uuid.uuid4(),
"template_name": "Notify email verification code",
"template_type": "email",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": email_verification_content,
"service_id": service_id,
"subject": "Confirm GOV.UK Notify registration",
@@ -87,7 +88,7 @@ def upgrade():
"template_id": "ece42649-22a8-4d06-b87f-d52d5d3f0a27",
"template_name": "Notify email verification code",
"template_type": "email",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": email_verification_content,
"service_id": service_id,
"subject": "Confirm GOV.UK Notify registration",
@@ -107,7 +108,7 @@ def upgrade():
"template_id": "4f46df42-f795-4cc4-83bb-65ca312f49cc",
"template_name": "Notify invitation email",
"template_type": "email",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": invitation_content,
"service_id": service_id,
"subject": invitation_subject,
@@ -120,7 +121,7 @@ def upgrade():
"template_id": "4f46df42-f795-4cc4-83bb-65ca312f49cc",
"template_name": "Notify invitation email",
"template_type": "email",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": invitation_content,
"service_id": service_id,
"subject": invitation_subject,
@@ -135,7 +136,7 @@ def upgrade():
"template_id": "36fb0730-6259-4da1-8a80-c8de22ad4246",
"template_name": "Notify SMS verify code",
"template_type": "sms",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": sms_code_content,
"service_id": service_id,
"subject": None,
@@ -149,7 +150,7 @@ def upgrade():
"template_id": "36fb0730-6259-4da1-8a80-c8de22ad4246",
"template_name": "Notify SMS verify code",
"template_type": "sms",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": sms_code_content,
"service_id": service_id,
"subject": None,
@@ -172,7 +173,7 @@ def upgrade():
"template_id": "474e9242-823b-4f99-813d-ed392e7f1201",
"template_name": "Notify password reset email",
"template_type": "email",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": password_reset_content,
"service_id": service_id,
"subject": "Reset your GOV.UK Notify password",
@@ -186,7 +187,7 @@ def upgrade():
"template_id": "474e9242-823b-4f99-813d-ed392e7f1201",
"template_name": "Notify password reset email",
"template_type": "email",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": password_reset_content,
"service_id": service_id,
"subject": "Reset your GOV.UK Notify password",
@@ -11,6 +11,8 @@ from datetime import datetime
from sqlalchemy import text
from app.utils import utc_now
revision = "0028_fix_reg_template_history"
down_revision = "0026_rename_notify_service"
@@ -38,7 +40,7 @@ def upgrade():
"id": "ece42649-22a8-4d06-b87f-d52d5d3f0a27",
"name": "Notify email verification code",
"type": "email",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": email_verification_content,
"service_id": service_id,
"subject": "Confirm GOV.UK Notify registration",
@@ -14,6 +14,8 @@ from alembic import op
from flask import current_app
from sqlalchemy import text
from app.utils import utc_now
revision = "0082_add_go_live_template"
down_revision = "0081_noti_status_as_enum"
@@ -89,7 +91,7 @@ GOV.UK Notify team
"template_id": template_id,
"template_name": template_name,
"template_type": "email",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": template_content,
"notify_service_id": current_app.config["NOTIFY_SERVICE_ID"],
"subject": template_subject,
@@ -9,6 +9,8 @@ Create Date: 2017-08-29 14:09:41.042061
# revision identifiers, used by Alembic.
from sqlalchemy import text
from app.utils import utc_now
revision = "0117_international_sms_notify"
down_revision = "0115_add_inbound_numbers"
@@ -22,7 +24,7 @@ NOTIFY_SERVICE_ID = "d6aa2c68-a2d9-4437-ab19-3ae8eb202553"
def upgrade():
input_params = {
"notify_service_id": NOTIFY_SERVICE_ID,
"datetime_now": datetime.utcnow(),
"datetime_now": utc_now(),
}
conn = op.get_bind()
conn.execute(
@@ -12,6 +12,8 @@ from alembic import op
from flask import current_app
from sqlalchemy import text
from app.utils import utc_now
revision = "0134_add_email_2fa_template"
down_revision = "0133_set_services_sms_prefix"
@@ -44,7 +46,7 @@ def upgrade():
"template_id": template_id,
"template_name": template_name,
"template_type": "email",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": template_content,
"notify_service_id": current_app.config["NOTIFY_SERVICE_ID"],
"subject": template_subject,
@@ -13,6 +13,7 @@ from alembic import op
from sqlalchemy import text
from app.dao.date_util import get_current_calendar_year_start_year
from app.utils import utc_now
revision = "0139_migrate_sms_allowance_data"
down_revision = "0138_sms_sender_nullable"
@@ -34,7 +35,7 @@ def upgrade():
input_params = {
"current_year": current_year,
"default_limit": default_limit,
"time_now": datetime.utcnow(),
"time_now": utc_now(),
}
insert_row_if_not_exist = """
INSERT INTO annual_billing
@@ -12,6 +12,8 @@ from alembic import op
from flask import current_app
from sqlalchemy import text
from app.utils import utc_now
revision = "0171_add_org_invite_template"
down_revision = "0170_hidden_non_nullable"
@@ -53,7 +55,7 @@ def upgrade():
"template_id": template_id,
"template_name": template_name,
"template_type": "email",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": template_content,
"notify_service_id": current_app.config["NOTIFY_SERVICE_ID"],
"subject": template_subject,
@@ -12,6 +12,8 @@ from alembic import op
from flask import current_app
from sqlalchemy import text
from app.utils import utc_now
revision = "0265_add_confirm_edit_templates"
down_revision = "0264_add_folder_permissions_perm"
@@ -57,7 +59,7 @@ def upgrade():
"template_id": email_template_id,
"template_name": email_template_name,
"template_type": "email",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": email_template_content,
"notify_service_id": current_app.config["NOTIFY_SERVICE_ID"],
"subject": email_template_subject,
@@ -78,7 +80,7 @@ def upgrade():
"template_id": mobile_template_id,
"template_name": mobile_template_name,
"template_type": "sms",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": mobile_template_content,
"notify_service_id": current_app.config["NOTIFY_SERVICE_ID"],
"subject": None,
@@ -12,6 +12,8 @@ from alembic import op
from flask import current_app
from sqlalchemy import text
from app.utils import utc_now
revision = "0294_add_verify_reply_to"
down_revision = "0293_drop_complaint_fk"
@@ -58,7 +60,7 @@ def upgrade():
"template_id": email_template_id,
"template_name": email_template_name,
"template_type": "email",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": email_template_content,
"notify_service_id": current_app.config["NOTIFY_SERVICE_ID"],
"subject": email_template_subject,
@@ -12,6 +12,8 @@ from datetime import datetime
from alembic import op
from sqlalchemy import text
from app.utils import utc_now
revision = "0330_broadcast_invite_email"
down_revision = "0329_purge_broadcast_data"
@@ -60,7 +62,7 @@ def upgrade():
input_params = {
"template_id": template_id,
"template_name": broadcast_invitation_template_name,
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": broadcast_invitation_content,
"service_id": service_id,
"subject": broadcast_invitation_subject,
@@ -13,6 +13,8 @@ from alembic import op
from flask import current_app
from sqlalchemy import text
from app.utils import utc_now
revision = "0347_add_dvla_volumes_template"
down_revision = "0346_notify_number_sms_sender"
@@ -57,7 +59,7 @@ def upgrade():
"template_id": email_template_id,
"template_name": email_template_name,
"template_type": "email",
"time_now": datetime.utcnow(),
"time_now": utc_now(),
"content": email_template_content,
"notify_service_id": current_app.config["NOTIFY_SERVICE_ID"],
"subject": email_template_subject,
@@ -16,6 +16,7 @@ from alembic import op
from app import db
from app.dao.users_dao import get_user_by_email
from app.models import User
from app.utils import utc_now
revision = "0401_add_e2e_test_user"
down_revision = "0400_add_total_message_limit"
@@ -32,11 +33,11 @@ def upgrade():
"password": password,
"mobile_number": "+12025555555",
"state": "active",
"created_at": datetime.datetime.utcnow(),
"password_changed_at": datetime.datetime.utcnow(),
"created_at": utc_now(),
"password_changed_at": utc_now(),
"failed_login_count": 0,
"platform_admin": "f",
"email_access_validated_at": datetime.datetime.utcnow(),
"email_access_validated_at": utc_now(),
}
conn = op.get_bind()
insert_sql = """
@@ -133,19 +133,13 @@ class RedisClient:
else:
return False
def raw_set(self, key, value, ex=None, px=None, nx=False, xx=False):
self.redis_store.set(key, value, ex, px, nx, xx)
def set(
self, key, value, ex=None, px=None, nx=False, xx=False, raise_exception=False
):
key = prepare_value(key)
value = prepare_value(value)
if self.active:
try:
self.redis_store.set(key, value, ex, px, nx, xx)
except Exception as e:
self.__handle_exception(e, raise_exception, "set", key)
self.redis_store.set(key, value, ex, px, nx, xx)
def incr(self, key, raise_exception=False):
key = prepare_value(key)
@@ -155,16 +149,10 @@ class RedisClient:
except Exception as e:
self.__handle_exception(e, raise_exception, "incr", key)
def raw_get(self, key):
return self.redis_store.get(key)
def get(self, key, raise_exception=False):
key = prepare_value(key)
if self.active:
try:
return self.redis_store.get(key)
except Exception as e:
self.__handle_exception(e, raise_exception, "get", key)
return self.redis_store.get(key)
return None
+32
View File
@@ -1,5 +1,6 @@
import logging
import logging.handlers
import re
import sys
from itertools import product
@@ -69,6 +70,7 @@ def configure_handler(handler, app, formatter):
handler.addFilter(AppNameFilter(app.config["NOTIFY_APP_NAME"]))
handler.addFilter(RequestIdFilter())
handler.addFilter(ServiceIdFilter())
handler.addFilter(PIIFilter())
return handler
@@ -115,6 +117,36 @@ class ServiceIdFilter(logging.Filter):
return record
class PIIFilter(logging.Filter):
def scrub(self, msg):
# Eventually we want to scrub all messages in all logs for phone numbers
# and email addresses, masking them. Ultimately this will probably get
# refactored into a 'SafeLogger' subclass or something, but let's start here
# with phones.
# Sometimes just an exception object is passed in for the message, skip those.
if not isinstance(msg, str):
return msg
phones = re.findall("(?:\\+ *)?\\d[\\d\\- ]{7,}\\d", msg)
phones = [phone.replace("-", "").replace(" ", "") for phone in phones]
for phone in phones:
msg = msg.replace(phone, "1XXXXXXXXXX")
emails = re.findall(
r"[\w\.-]+@[\w\.-]+", msg
) # ['alice@google.com', 'bob@abc.com']
for email in emails:
# do something with each found email string
masked_email = "XXXXX@XXXXXXX"
msg = msg.replace(email, masked_email)
return msg
def filter(self, record):
record.msg = self.scrub(record.msg)
return record
class JSONFormatter(BaseJSONFormatter):
def process_log_record(self, log_record):
rename_map = {
+25 -1
View File
@@ -602,6 +602,26 @@ def validate_us_phone_number(number):
raise InvalidPhoneError(exc._msg) from exc
def show_mangled_number_clues(number):
translator = {
"1": "X",
"2": "X",
"3": "X",
"4": "X",
"5": "X",
"6": "X",
"7": "X",
"8": "X",
"9": "X",
"0": "X",
}
for key in translator:
number = number.replace(key, translator[key])
return number
def validate_phone_number(number, international=False):
if (not international) or is_us_phone_number(number):
return validate_us_phone_number(number)
@@ -619,7 +639,11 @@ def validate_phone_number(number, international=False):
except NumberParseException as exc:
if exc._msg == "Could not interpret numbers after plus-sign.":
raise InvalidPhoneError("Not a valid country prefix") from exc
raise InvalidPhoneError(exc._msg) from exc
if not isinstance(number, str):
raise InvalidPhoneError(f"Number must be string, not type {type(number)}")
raise InvalidPhoneError(
f"Invalid phone number looks like {show_mangled_number_clues(number)} {exc._msg}"
)
validate_and_format_phone_number = validate_phone_number
Generated
+168 -173
View File
@@ -111,13 +111,13 @@ frozenlist = ">=1.1.0"
[[package]]
name = "alembic"
version = "1.13.1"
version = "1.13.2"
description = "A database migration tool for SQLAlchemy."
optional = false
python-versions = ">=3.8"
files = [
{file = "alembic-1.13.1-py3-none-any.whl", hash = "sha256:2edcc97bed0bd3272611ce3a98d98279e9c209e7186e43e75bbb1b2bdfdbcc43"},
{file = "alembic-1.13.1.tar.gz", hash = "sha256:4932c8558bf68f2ee92b9bbcb8218671c627064d5b08939437af6d77dc05e595"},
{file = "alembic-1.13.2-py3-none-any.whl", hash = "sha256:6b8733129a6224a9a711e17c99b08462dbf7cc9670ba8f2e2ae9af860ceb1953"},
{file = "alembic-1.13.2.tar.gz", hash = "sha256:1ff0ae32975f4fd96028c39ed9bb3c867fe3af956bd7bb37343b54c9fe7445ef"},
]
[package.dependencies]
@@ -204,17 +204,17 @@ tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "p
[[package]]
name = "awscli"
version = "1.33.10"
version = "1.33.26"
description = "Universal Command Line Environment for AWS."
optional = false
python-versions = ">=3.8"
files = [
{file = "awscli-1.33.10-py3-none-any.whl", hash = "sha256:f3cdd988c4fd1cf25e8fe16fccc514147469ed102b43c128e29374d48337d7a0"},
{file = "awscli-1.33.10.tar.gz", hash = "sha256:0a5b4b5555be2c7324bf9e748bc24c17c95ef1b070e8f324354ef8747ce984e9"},
{file = "awscli-1.33.26-py3-none-any.whl", hash = "sha256:e542f2ae56dc8ac0bb1774f835031e818af2e3a0e3fd133363d5932621e8e66c"},
{file = "awscli-1.33.26.tar.gz", hash = "sha256:b3047b2b8ab6d4a8b5bb04e28e4531be8e72fc87e5fb8f2588196eca9dd11dd0"},
]
[package.dependencies]
botocore = "1.34.128"
botocore = "1.34.144"
colorama = ">=0.2.5,<0.4.7"
docutils = ">=0.10,<0.17"
PyYAML = ">=3.10,<6.1"
@@ -403,17 +403,17 @@ files = [
[[package]]
name = "boto3"
version = "1.34.128"
version = "1.34.143"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "boto3-1.34.128-py3-none-any.whl", hash = "sha256:a048ff980a81cd652724a73bc496c519b336fabe19cc8bfc6c53b2ff6eb22c7b"},
{file = "boto3-1.34.128.tar.gz", hash = "sha256:43a6e99f53a8d34b3b4dbe424dbcc6b894350dc41a85b0af7c7bc24a7ec2cead"},
{file = "boto3-1.34.143-py3-none-any.whl", hash = "sha256:0d16832f23e6bd3ae94e35ea8e625529850bfad9baccd426de96ad8f445d8e03"},
{file = "boto3-1.34.143.tar.gz", hash = "sha256:b590ce80c65149194def43ebf0ea1cf0533945502507837389a8d22e3ecbcf05"},
]
[package.dependencies]
botocore = ">=1.34.128,<1.35.0"
botocore = ">=1.34.143,<1.35.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.10.0,<0.11.0"
@@ -422,13 +422,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "botocore"
version = "1.34.128"
version = "1.34.144"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.8"
files = [
{file = "botocore-1.34.128-py3-none-any.whl", hash = "sha256:db67fda136c372ab3fa432580c819c89ba18d28a6152a4d2a7ea40d44082892e"},
{file = "botocore-1.34.128.tar.gz", hash = "sha256:8d8e03f7c8c080ecafda72036eb3b482d649f8417c90b5dca33b7c2c47adb0c9"},
{file = "botocore-1.34.144-py3-none-any.whl", hash = "sha256:a2cf26e1bf10d5917a2285e50257bc44e94a1d16574f282f3274f7a5d8d1f08b"},
{file = "botocore-1.34.144.tar.gz", hash = "sha256:4215db28d25309d59c99507f1f77df9089e5bebbad35f6e19c7c44ec5383a3e8"},
]
[package.dependencies]
@@ -553,13 +553,13 @@ zstd = ["zstandard (==0.22.0)"]
[[package]]
name = "certifi"
version = "2024.6.2"
version = "2024.7.4"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
files = [
{file = "certifi-2024.6.2-py3-none-any.whl", hash = "sha256:ddc6c8ce995e6987e7faf5e3f1b02b302836a0e5d98ece18392cb1a36c72ad56"},
{file = "certifi-2024.6.2.tar.gz", hash = "sha256:3cd43f1c6fa7dedc5899d69d3ad0398fd018ad1a17fba83ddaf78aa46c747516"},
{file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
{file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
]
[[package]]
@@ -1261,13 +1261,13 @@ tests = ["coverage", "coveralls", "dill", "mock", "nose"]
[[package]]
name = "faker"
version = "25.8.0"
version = "26.0.0"
description = "Faker is a Python package that generates fake data for you."
optional = false
python-versions = ">=3.8"
files = [
{file = "Faker-25.8.0-py3-none-any.whl", hash = "sha256:4c40b34a9c569018d4f9d6366d71a4da8a883d5ddf2b23197be5370f29b7e1b6"},
{file = "Faker-25.8.0.tar.gz", hash = "sha256:bdec5f2fb057d244ebef6e0ed318fea4dcbdf32c3a1a010766fc45f5d68fc68d"},
{file = "Faker-26.0.0-py3-none-any.whl", hash = "sha256:886ee28219be96949cd21ecc96c4c742ee1680e77f687b095202c8def1a08f06"},
{file = "Faker-26.0.0.tar.gz", hash = "sha256:0f60978314973de02c00474c2ae899785a42b2cf4f41b7987e93c132a2b8a4a9"},
]
[package.dependencies]
@@ -1305,18 +1305,18 @@ typing = ["typing-extensions (>=4.8)"]
[[package]]
name = "flake8"
version = "7.0.0"
version = "7.1.0"
description = "the modular source code checker: pep8 pyflakes and co"
optional = false
python-versions = ">=3.8.1"
files = [
{file = "flake8-7.0.0-py2.py3-none-any.whl", hash = "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3"},
{file = "flake8-7.0.0.tar.gz", hash = "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132"},
{file = "flake8-7.1.0-py2.py3-none-any.whl", hash = "sha256:2e416edcc62471a64cea09353f4e7bdba32aeb079b6e360554c659a122b1bc6a"},
{file = "flake8-7.1.0.tar.gz", hash = "sha256:48a07b626b55236e0fb4784ee69a465fbf59d79eec1f5b4785c3d3bc57d17aa5"},
]
[package.dependencies]
mccabe = ">=0.7.0,<0.8.0"
pycodestyle = ">=2.11.0,<2.12.0"
pycodestyle = ">=2.12.0,<2.13.0"
pyflakes = ">=3.2.0,<3.3.0"
[[package]]
@@ -1904,13 +1904,13 @@ files = [
[[package]]
name = "jsonschema"
version = "4.22.0"
version = "4.23.0"
description = "An implementation of JSON Schema validation for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "jsonschema-4.22.0-py3-none-any.whl", hash = "sha256:ff4cfd6b1367a40e7bc6411caec72effadd3db0bbe5017de188f2d6108335802"},
{file = "jsonschema-4.22.0.tar.gz", hash = "sha256:5b22d434a45935119af990552c862e5d6d564e8f6601206b305a61fdf661a2b7"},
{file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"},
{file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"},
]
[package.dependencies]
@@ -1929,7 +1929,7 @@ webcolors = {version = ">=1.11", optional = true, markers = "extra == \"format\"
[package.extras]
format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"]
format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=1.11)"]
format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"]
[[package]]
name = "jsonschema-specifications"
@@ -2385,13 +2385,13 @@ files = [
[[package]]
name = "moto"
version = "5.0.9"
version = "5.0.11"
description = ""
optional = false
python-versions = ">=3.8"
files = [
{file = "moto-5.0.9-py2.py3-none-any.whl", hash = "sha256:21a13e02f83d6a18cfcd99949c96abb2e889f4bd51c4c6a3ecc8b78765cb854e"},
{file = "moto-5.0.9.tar.gz", hash = "sha256:eb71f1cba01c70fff1f16086acb24d6d9aeb32830d646d8989f98a29aeae24ba"},
{file = "moto-5.0.11-py2.py3-none-any.whl", hash = "sha256:bdba9bec0afcde9f99b58c5271d6458dbfcda0a0a1e9beaecd808d2591db65ea"},
{file = "moto-5.0.11.tar.gz", hash = "sha256:606b641f4c6ef69f28a84147d6d6806d052011e7ae7b0fe46ae8858e7a27a0a3"},
]
[package.dependencies]
@@ -2605,40 +2605,40 @@ files = [
[[package]]
name = "newrelic"
version = "9.11.0"
version = "9.12.0"
description = "New Relic Python Agent"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "newrelic-9.11.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:87670d872c3abc36203e10f93d266c8f36ad2bd06fb54e790001a409f9e2f40f"},
{file = "newrelic-9.11.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:11653fd14f55999c5058b4dde8c721833076c0bd3efe668296725a622e9e7de8"},
{file = "newrelic-9.11.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:72dd3eb190c62bb54aa59029f0d6ac1420c2050b3aaf88d947fc7f62ec58d97f"},
{file = "newrelic-9.11.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:02eab15af4a08b870bcfdbc56390ecbb9dcacd144fe77f39a26d1be207bd30f0"},
{file = "newrelic-9.11.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f477cdda9b998205084b822089b3ee4a8a2d9cd66b6f12487c9f9002566c5cb"},
{file = "newrelic-9.11.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcec4173cd0f83420e6f61f92955065f1d460075af5e5bf88a5fea746e3cc180"},
{file = "newrelic-9.11.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8664e3b9e6ee0f78806b0cf7c90656a1a86d13232c2e0be18a1b1eb452f3f5d1"},
{file = "newrelic-9.11.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7f1e473eb0505cb91ab9a4155321eabe13a2f6b93fb3c41d6f10e5486276be60"},
{file = "newrelic-9.11.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9f95eb366ff714bce32476d256551b853247a72398ec46a89148ef5108509aa8"},
{file = "newrelic-9.11.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:553674a66ef2c2206852b415b74e3c2fb7ed2b92e9800b68394d577f6aa1133e"},
{file = "newrelic-9.11.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:21e7b52d5b214bba3534ced166e6ec991117772815020bec38b0571fdcecbaf4"},
{file = "newrelic-9.11.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:10cb7f7a78c49580602b90f367f3378264e495f2f3706734f88ced7e7ca9b033"},
{file = "newrelic-9.11.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34b25d1beaf19825409f3d915a5bafa87b7b9230415821422be1e78e988750b7"},
{file = "newrelic-9.11.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2b02139458aefba86a4572cb8214f91a942103d24d5502395f64d6d7a4ad3f25"},
{file = "newrelic-9.11.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3283885bcf31d9cbf8facb0004508a4eaa652a62471e0b724d26f9738a291979"},
{file = "newrelic-9.11.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0d43a0891bf71333f6a6253cf87dea2c9009e22699a2acfd93608125a33b1936"},
{file = "newrelic-9.11.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7903ba71ce5a4b2840f6d3c63ecd0fb3a018d2aceb915b48133c13c4a60185f"},
{file = "newrelic-9.11.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d88fa17a515fb002eb14570800e4bfa69ac87ac27e6e2a96bc2bc9b60c80057a"},
{file = "newrelic-9.11.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6ceac1d8f13da38fa1b41c8202a91d3b4345e06adb655deaae0df08911fda56f"},
{file = "newrelic-9.11.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ffc0d8d490de0f12df70db637481aaadb8a43fb6d71ba8866dc14242aa5edad4"},
{file = "newrelic-9.11.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f6e1bb0df8ff2b54195baac41fddc0e15ea1bdf1deb6af49153487696355181"},
{file = "newrelic-9.11.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5d2d0814e1aa9de5bd55797ff8c426d98200ba46ca14dbca15557d0f17cfb4e"},
{file = "newrelic-9.11.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b33539345c7cf349b65a176a30ab38e2998b071512a7450f5c5b89ac6c097006"},
{file = "newrelic-9.11.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7c073f4c26539d6d74fbf4bac7f5046cac578975fb2cf77b156f802f1b39835e"},
{file = "newrelic-9.11.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76eb4cc599645a38a459b0002696d9c84844fecb02cf07bc18a4a91f737e438e"},
{file = "newrelic-9.11.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35d08587e694f5c517e55fb7119f924c64569d2e7ec4968ef761fc1f7bd1f40c"},
{file = "newrelic-9.11.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bc5c1b8a51946f64c34fc5fa29ce0221c4927a65c7f4435b3b8adeb29b9812d2"},
{file = "newrelic-9.11.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2010ed2793294a7e3c1057ec301d48997ed05dcef114d4c25120ac771f66bac1"},
{file = "newrelic-9.11.0.tar.gz", hash = "sha256:94369792d61ccf21469c35cf66886c32350a180d8e782c0d28ec66411db29474"},
{file = "newrelic-9.12.0-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:c349e3b611e8da446aa8045c92e986d77bcd945903bfa08092b9a7c217036fd9"},
{file = "newrelic-9.12.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:2c8168a2bd5db45566471306ef962e925ab2c9fa92079c3f5863d4a4585dfcbd"},
{file = "newrelic-9.12.0-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:4c73d470a61a9f09a204fd47a4822af0d1e52ccac36a6737f72e0cbb2a22dba6"},
{file = "newrelic-9.12.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:0a3debb761aca68491f14fb6e5bc0100eeef1ab314073ab4696d55cd906b4bec"},
{file = "newrelic-9.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:51c8c4b3d103db423640fda4d6c6b58c79558097ebd111a62e957408a4cf1c71"},
{file = "newrelic-9.12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923e83e40e30fc7ca0f441bb9c745274f7236869bfbe65da487714bfcd4f46c0"},
{file = "newrelic-9.12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:f2bed7bfbcbd0e95b6ad1c82e30098d79678cbc6410fc2f88c439e6786c6640a"},
{file = "newrelic-9.12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8b4c343c0cf2a0b59467f9daf0f303d28dad6795dc75bc54582d3198e1d2b4da"},
{file = "newrelic-9.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d93402402a32905950d6e646ec220bdb10a522e896c219941c92e474cfa2cdb"},
{file = "newrelic-9.12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a614a1da090cbfbd9f5ab3fcdafc253408d76ffc0a22a73cc16fd5c97b67b97"},
{file = "newrelic-9.12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d5040601cedf308faa818cc9fc5c8e48283bdcb4c02a2e1e468e67e037200f83"},
{file = "newrelic-9.12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0fff006b6d6eb86a25483a4aed216f98293ec44c29b497c1f18f23f05e059991"},
{file = "newrelic-9.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:43cccc52c3ec9c0aa457d3d14557bb19383dbad1afe018d9063c0a7ffbe29232"},
{file = "newrelic-9.12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df75838fd766252282070a5dcdc78906c4b9e0934280c601815c5eb1cc6ce6ff"},
{file = "newrelic-9.12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d8f3f8f8d27a1bafc3c4cc930a762d96119897f1808bccd162597b510e236de9"},
{file = "newrelic-9.12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:98d5bd7222dd96b0fc194dd2142827e9b70959527f2480fef61da82857b00cb2"},
{file = "newrelic-9.12.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f83f8023a12e8b4ec217e59e1a56375ad141a2f7a620df363a688e1f1d3e93b"},
{file = "newrelic-9.12.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec7a46b5c4e77374a1c01b637fca63c187218c153be075bc806663881c53a03e"},
{file = "newrelic-9.12.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:12e721c19e78a7e7a1443c327acf133d94b0c12add6ec514235a668656732011"},
{file = "newrelic-9.12.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:353a11bfa737043309025a0949cb6d7cfd7c7209cdee7abe8af774af8f44586f"},
{file = "newrelic-9.12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bae6dfffca34591771bc4b6c493c68c15209d2b4e3d79c46239204014a20e53d"},
{file = "newrelic-9.12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be6315beacb0ac7ac99c24e38b8ef072e3930f4d06970fb2fa84da0a990c3467"},
{file = "newrelic-9.12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4cb87e19a2e522417e2b421b799fe0c20cedf953a1e061fbf50bb21683e2420f"},
{file = "newrelic-9.12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bde6db956d363d8d846d3d0c76d4a4a539c809cebb40e45a53d099a39cdd0ea3"},
{file = "newrelic-9.12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e94cacdeb15ddfc0a1f8353d896a1069da1302416b4afbf67a953f0706235be2"},
{file = "newrelic-9.12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434be59492f52c9b8401adada597a5dca037cf003374d5dd461bca1db64d3ca7"},
{file = "newrelic-9.12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:16de826ddc4af4cf45fe607aeca7117dd08c948edd41aaf90b86c596a3f0eaac"},
{file = "newrelic-9.12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:61253b5cf43787b0c19d00662eb7e60d4c1018c09374e9b0825338e831cad900"},
{file = "newrelic-9.12.0.tar.gz", hash = "sha256:e8c1ed86f9c2f0954817d4405a4fa1cb09b0cc720b3c702fa2cac1c4fffeaec1"},
]
[package.extras]
@@ -2823,13 +2823,13 @@ ptyprocess = ">=0.5"
[[package]]
name = "phonenumbers"
version = "8.13.39"
version = "8.13.40"
description = "Python version of Google's common library for parsing, formatting, storing and validating international phone numbers."
optional = false
python-versions = "*"
files = [
{file = "phonenumbers-8.13.39-py2.py3-none-any.whl", hash = "sha256:3ad2d086fa71e7eef409001b9195ac54bebb0c6e3e752209b558ca192c9229a0"},
{file = "phonenumbers-8.13.39.tar.gz", hash = "sha256:db7ca4970d206b2056231105300753b1a5b229f43416f8c2b3010e63fbb68d77"},
{file = "phonenumbers-8.13.40-py2.py3-none-any.whl", hash = "sha256:9582752c20a1da5ec4449f7f97542bf8a793c8e2fec0ab57f767177bb8fc0b1d"},
{file = "phonenumbers-8.13.40.tar.gz", hash = "sha256:f137c2848b8e83dd064b71881b65680584417efa202177fd330e2f7ff6c68113"},
]
[[package]]
@@ -3207,13 +3207,13 @@ files = [
[[package]]
name = "pycodestyle"
version = "2.11.1"
version = "2.12.0"
description = "Python style guide checker"
optional = false
python-versions = ">=3.8"
files = [
{file = "pycodestyle-2.11.1-py2.py3-none-any.whl", hash = "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67"},
{file = "pycodestyle-2.11.1.tar.gz", hash = "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f"},
{file = "pycodestyle-2.12.0-py2.py3-none-any.whl", hash = "sha256:949a39f6b86c3e1515ba1787c2022131d165a8ad271b11370a8819aa070269e4"},
{file = "pycodestyle-2.12.0.tar.gz", hash = "sha256:442f950141b4f43df752dd303511ffded3a04c2b6fb7f65980574f0c31e6e79c"},
]
[[package]]
@@ -3631,13 +3631,13 @@ full = ["numpy"]
[[package]]
name = "redis"
version = "5.0.6"
version = "5.0.7"
description = "Python client for Redis database and key-value store"
optional = false
python-versions = ">=3.7"
files = [
{file = "redis-5.0.6-py3-none-any.whl", hash = "sha256:c0d6d990850c627bbf7be01c5c4cbaadf67b48593e913bb71c9819c30df37eee"},
{file = "redis-5.0.6.tar.gz", hash = "sha256:38473cd7c6389ad3e44a91f4c3eaf6bcb8a9f746007f29bf4fb20824ff0b2197"},
{file = "redis-5.0.7-py3-none-any.whl", hash = "sha256:0e479e24da960c690be5d9b96d21f7b918a98c0cf49af3b6fafaa0753f93a0db"},
{file = "redis-5.0.7.tar.gz", hash = "sha256:8f611490b93c8109b50adc317b31bfd84fff31def3475b92e7e80bf39f48175b"},
]
[package.extras]
@@ -3985,13 +3985,13 @@ pyasn1 = ">=0.1.3"
[[package]]
name = "s3transfer"
version = "0.10.1"
version = "0.10.2"
description = "An Amazon S3 Transfer Manager"
optional = false
python-versions = ">= 3.8"
python-versions = ">=3.8"
files = [
{file = "s3transfer-0.10.1-py3-none-any.whl", hash = "sha256:ceb252b11bcf87080fb7850a224fb6e05c8a776bab8f2b64b7f25b969464839d"},
{file = "s3transfer-0.10.1.tar.gz", hash = "sha256:5683916b4c724f799e600f41dd9e10a9ff19871bf87623cc8f491cb4f5fa0a19"},
{file = "s3transfer-0.10.2-py3-none-any.whl", hash = "sha256:eca1c20de70a39daee580aef4986996620f365c4e0fda6a86100231d62f1bf69"},
{file = "s3transfer-0.10.2.tar.gz", hash = "sha256:0711534e9356d3cc692fdde846b4a1e4b0cb6519971860796e6bc4c7aea00ef6"},
]
[package.dependencies]
@@ -4017,67 +4017,62 @@ jeepney = ">=0.6"
[[package]]
name = "setuptools"
version = "70.0.0"
version = "70.3.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "setuptools-70.0.0-py3-none-any.whl", hash = "sha256:54faa7f2e8d2d11bcd2c07bed282eef1046b5c080d1c32add737d7b5817b1ad4"},
{file = "setuptools-70.0.0.tar.gz", hash = "sha256:f211a66637b8fa059bb28183da127d4e86396c991a942b028c6650d4319c3fd0"},
{file = "setuptools-70.3.0-py3-none-any.whl", hash = "sha256:fe384da74336c398e0d956d1cae0669bc02eed936cdb1d49b57de1990dc11ffc"},
{file = "setuptools-70.3.0.tar.gz", hash = "sha256:f171bab1dfbc86b132997f26a119f6056a57950d058587841a0082e8830f9dc5"},
]
[package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
testing = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "mypy (==1.10.0)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.3.2)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
[[package]]
name = "shapely"
version = "2.0.4"
version = "2.0.5"
description = "Manipulation and analysis of geometric objects"
optional = false
python-versions = ">=3.7"
files = [
{file = "shapely-2.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:011b77153906030b795791f2fdfa2d68f1a8d7e40bce78b029782ade3afe4f2f"},
{file = "shapely-2.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9831816a5d34d5170aa9ed32a64982c3d6f4332e7ecfe62dc97767e163cb0b17"},
{file = "shapely-2.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5c4849916f71dc44e19ed370421518c0d86cf73b26e8656192fcfcda08218fbd"},
{file = "shapely-2.0.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:841f93a0e31e4c64d62ea570d81c35de0f6cea224568b2430d832967536308e6"},
{file = "shapely-2.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b4431f522b277c79c34b65da128029a9955e4481462cbf7ebec23aab61fc58"},
{file = "shapely-2.0.4-cp310-cp310-win32.whl", hash = "sha256:92a41d936f7d6743f343be265ace93b7c57f5b231e21b9605716f5a47c2879e7"},
{file = "shapely-2.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:30982f79f21bb0ff7d7d4a4e531e3fcaa39b778584c2ce81a147f95be1cd58c9"},
{file = "shapely-2.0.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:de0205cb21ad5ddaef607cda9a3191eadd1e7a62a756ea3a356369675230ac35"},
{file = "shapely-2.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7d56ce3e2a6a556b59a288771cf9d091470116867e578bebced8bfc4147fbfd7"},
{file = "shapely-2.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:58b0ecc505bbe49a99551eea3f2e8a9b3b24b3edd2a4de1ac0dc17bc75c9ec07"},
{file = "shapely-2.0.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:790a168a808bd00ee42786b8ba883307c0e3684ebb292e0e20009588c426da47"},
{file = "shapely-2.0.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4310b5494271e18580d61022c0857eb85d30510d88606fa3b8314790df7f367d"},
{file = "shapely-2.0.4-cp311-cp311-win32.whl", hash = "sha256:63f3a80daf4f867bd80f5c97fbe03314348ac1b3b70fb1c0ad255a69e3749879"},
{file = "shapely-2.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:c52ed79f683f721b69a10fb9e3d940a468203f5054927215586c5d49a072de8d"},
{file = "shapely-2.0.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:5bbd974193e2cc274312da16b189b38f5f128410f3377721cadb76b1e8ca5328"},
{file = "shapely-2.0.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:41388321a73ba1a84edd90d86ecc8bfed55e6a1e51882eafb019f45895ec0f65"},
{file = "shapely-2.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0776c92d584f72f1e584d2e43cfc5542c2f3dd19d53f70df0900fda643f4bae6"},
{file = "shapely-2.0.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c75c98380b1ede1cae9a252c6dc247e6279403fae38c77060a5e6186c95073ac"},
{file = "shapely-2.0.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3e700abf4a37b7b8b90532fa6ed5c38a9bfc777098bc9fbae5ec8e618ac8f30"},
{file = "shapely-2.0.4-cp312-cp312-win32.whl", hash = "sha256:4f2ab0faf8188b9f99e6a273b24b97662194160cc8ca17cf9d1fb6f18d7fb93f"},
{file = "shapely-2.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:03152442d311a5e85ac73b39680dd64a9892fa42bb08fd83b3bab4fe6999bfa0"},
{file = "shapely-2.0.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:994c244e004bc3cfbea96257b883c90a86e8cbd76e069718eb4c6b222a56f78b"},
{file = "shapely-2.0.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05ffd6491e9e8958b742b0e2e7c346635033d0a5f1a0ea083547fcc854e5d5cf"},
{file = "shapely-2.0.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fbdc1140a7d08faa748256438291394967aa54b40009f54e8d9825e75ef6113"},
{file = "shapely-2.0.4-cp37-cp37m-win32.whl", hash = "sha256:5af4cd0d8cf2912bd95f33586600cac9c4b7c5053a036422b97cfe4728d2eb53"},
{file = "shapely-2.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:464157509ce4efa5ff285c646a38b49f8c5ef8d4b340f722685b09bb033c5ccf"},
{file = "shapely-2.0.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:489c19152ec1f0e5c5e525356bcbf7e532f311bff630c9b6bc2db6f04da6a8b9"},
{file = "shapely-2.0.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b79bbd648664aa6f44ef018474ff958b6b296fed5c2d42db60078de3cffbc8aa"},
{file = "shapely-2.0.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:674d7baf0015a6037d5758496d550fc1946f34bfc89c1bf247cabdc415d7747e"},
{file = "shapely-2.0.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cd4ccecc5ea5abd06deeaab52fcdba372f649728050c6143cc405ee0c166679"},
{file = "shapely-2.0.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb5cdcbbe3080181498931b52a91a21a781a35dcb859da741c0345c6402bf00c"},
{file = "shapely-2.0.4-cp38-cp38-win32.whl", hash = "sha256:55a38dcd1cee2f298d8c2ebc60fc7d39f3b4535684a1e9e2f39a80ae88b0cea7"},
{file = "shapely-2.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:ec555c9d0db12d7fd777ba3f8b75044c73e576c720a851667432fabb7057da6c"},
{file = "shapely-2.0.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3f9103abd1678cb1b5f7e8e1af565a652e036844166c91ec031eeb25c5ca8af0"},
{file = "shapely-2.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:263bcf0c24d7a57c80991e64ab57cba7a3906e31d2e21b455f493d4aab534aaa"},
{file = "shapely-2.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ddf4a9bfaac643e62702ed662afc36f6abed2a88a21270e891038f9a19bc08fc"},
{file = "shapely-2.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:485246fcdb93336105c29a5cfbff8a226949db37b7473c89caa26c9bae52a242"},
{file = "shapely-2.0.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8de4578e838a9409b5b134a18ee820730e507b2d21700c14b71a2b0757396acc"},
{file = "shapely-2.0.4-cp39-cp39-win32.whl", hash = "sha256:9dab4c98acfb5fb85f5a20548b5c0abe9b163ad3525ee28822ffecb5c40e724c"},
{file = "shapely-2.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:31c19a668b5a1eadab82ff070b5a260478ac6ddad3a5b62295095174a8d26398"},
{file = "shapely-2.0.4.tar.gz", hash = "sha256:5dc736127fac70009b8d309a0eeb74f3e08979e530cf7017f2f507ef62e6cfb8"},
{file = "shapely-2.0.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:89d34787c44f77a7d37d55ae821f3a784fa33592b9d217a45053a93ade899375"},
{file = "shapely-2.0.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:798090b426142df2c5258779c1d8d5734ec6942f778dab6c6c30cfe7f3bf64ff"},
{file = "shapely-2.0.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45211276900c4790d6bfc6105cbf1030742da67594ea4161a9ce6812a6721e68"},
{file = "shapely-2.0.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e119444bc27ca33e786772b81760f2028d930ac55dafe9bc50ef538b794a8e1"},
{file = "shapely-2.0.5-cp310-cp310-win32.whl", hash = "sha256:9a4492a2b2ccbeaebf181e7310d2dfff4fdd505aef59d6cb0f217607cb042fb3"},
{file = "shapely-2.0.5-cp310-cp310-win_amd64.whl", hash = "sha256:1e5cb5ee72f1bc7ace737c9ecd30dc174a5295fae412972d3879bac2e82c8fae"},
{file = "shapely-2.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5bbfb048a74cf273db9091ff3155d373020852805a37dfc846ab71dde4be93ec"},
{file = "shapely-2.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93be600cbe2fbaa86c8eb70656369f2f7104cd231f0d6585c7d0aa555d6878b8"},
{file = "shapely-2.0.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f8e71bb9a46814019f6644c4e2560a09d44b80100e46e371578f35eaaa9da1c"},
{file = "shapely-2.0.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5251c28a29012e92de01d2e84f11637eb1d48184ee8f22e2df6c8c578d26760"},
{file = "shapely-2.0.5-cp311-cp311-win32.whl", hash = "sha256:35110e80070d664781ec7955c7de557456b25727a0257b354830abb759bf8311"},
{file = "shapely-2.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:6c6b78c0007a34ce7144f98b7418800e0a6a5d9a762f2244b00ea560525290c9"},
{file = "shapely-2.0.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:03bd7b5fa5deb44795cc0a503999d10ae9d8a22df54ae8d4a4cd2e8a93466195"},
{file = "shapely-2.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ff9521991ed9e201c2e923da014e766c1aa04771bc93e6fe97c27dcf0d40ace"},
{file = "shapely-2.0.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b65365cfbf657604e50d15161ffcc68de5cdb22a601bbf7823540ab4918a98d"},
{file = "shapely-2.0.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21f64e647a025b61b19585d2247137b3a38a35314ea68c66aaf507a1c03ef6fe"},
{file = "shapely-2.0.5-cp312-cp312-win32.whl", hash = "sha256:3ac7dc1350700c139c956b03d9c3df49a5b34aaf91d024d1510a09717ea39199"},
{file = "shapely-2.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:30e8737983c9d954cd17feb49eb169f02f1da49e24e5171122cf2c2b62d65c95"},
{file = "shapely-2.0.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ff7731fea5face9ec08a861ed351734a79475631b7540ceb0b66fb9732a5f529"},
{file = "shapely-2.0.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ff9e520af0c5a578e174bca3c18713cd47a6c6a15b6cf1f50ac17dc8bb8db6a2"},
{file = "shapely-2.0.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b299b91557b04acb75e9732645428470825061f871a2edc36b9417d66c1fc5"},
{file = "shapely-2.0.5-cp37-cp37m-win32.whl", hash = "sha256:b5870633f8e684bf6d1ae4df527ddcb6f3895f7b12bced5c13266ac04f47d231"},
{file = "shapely-2.0.5-cp37-cp37m-win_amd64.whl", hash = "sha256:401cb794c5067598f50518e5a997e270cd7642c4992645479b915c503866abed"},
{file = "shapely-2.0.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e91ee179af539100eb520281ba5394919067c6b51824e6ab132ad4b3b3e76dd0"},
{file = "shapely-2.0.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8af6f7260f809c0862741ad08b1b89cb60c130ae30efab62320bbf4ee9cc71fa"},
{file = "shapely-2.0.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5456dd522800306ba3faef77c5ba847ec30a0bd73ab087a25e0acdd4db2514f"},
{file = "shapely-2.0.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b714a840402cde66fd7b663bb08cacb7211fa4412ea2a209688f671e0d0631fd"},
{file = "shapely-2.0.5-cp38-cp38-win32.whl", hash = "sha256:7e8cf5c252fac1ea51b3162be2ec3faddedc82c256a1160fc0e8ddbec81b06d2"},
{file = "shapely-2.0.5-cp38-cp38-win_amd64.whl", hash = "sha256:4461509afdb15051e73ab178fae79974387f39c47ab635a7330d7fee02c68a3f"},
{file = "shapely-2.0.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7545a39c55cad1562be302d74c74586f79e07b592df8ada56b79a209731c0219"},
{file = "shapely-2.0.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4c83a36f12ec8dee2066946d98d4d841ab6512a6ed7eb742e026a64854019b5f"},
{file = "shapely-2.0.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89e640c2cd37378480caf2eeda9a51be64201f01f786d127e78eaeff091ec897"},
{file = "shapely-2.0.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06efe39beafde3a18a21dde169d32f315c57da962826a6d7d22630025200c5e6"},
{file = "shapely-2.0.5-cp39-cp39-win32.whl", hash = "sha256:8203a8b2d44dcb366becbc8c3d553670320e4acf0616c39e218c9561dd738d92"},
{file = "shapely-2.0.5-cp39-cp39-win_amd64.whl", hash = "sha256:7fed9dbfbcfec2682d9a047b9699db8dcc890dfca857ecba872c42185fc9e64e"},
{file = "shapely-2.0.5.tar.gz", hash = "sha256:bff2366bc786bfa6cb353d6b47d0443c570c32776612e527ee47b6df63fcfe32"},
]
[package.dependencies]
@@ -4143,64 +4138,64 @@ files = [
[[package]]
name = "sqlalchemy"
version = "2.0.30"
version = "2.0.31"
description = "Database Abstraction Library"
optional = false
python-versions = ">=3.7"
files = [
{file = "SQLAlchemy-2.0.30-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3b48154678e76445c7ded1896715ce05319f74b1e73cf82d4f8b59b46e9c0ddc"},
{file = "SQLAlchemy-2.0.30-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2753743c2afd061bb95a61a51bbb6a1a11ac1c44292fad898f10c9839a7f75b2"},
{file = "SQLAlchemy-2.0.30-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7bfc726d167f425d4c16269a9a10fe8630ff6d14b683d588044dcef2d0f6be7"},
{file = "SQLAlchemy-2.0.30-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4f61ada6979223013d9ab83a3ed003ded6959eae37d0d685db2c147e9143797"},
{file = "SQLAlchemy-2.0.30-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a365eda439b7a00732638f11072907c1bc8e351c7665e7e5da91b169af794af"},
{file = "SQLAlchemy-2.0.30-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bba002a9447b291548e8d66fd8c96a6a7ed4f2def0bb155f4f0a1309fd2735d5"},
{file = "SQLAlchemy-2.0.30-cp310-cp310-win32.whl", hash = "sha256:0138c5c16be3600923fa2169532205d18891b28afa817cb49b50e08f62198bb8"},
{file = "SQLAlchemy-2.0.30-cp310-cp310-win_amd64.whl", hash = "sha256:99650e9f4cf3ad0d409fed3eec4f071fadd032e9a5edc7270cd646a26446feeb"},
{file = "SQLAlchemy-2.0.30-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:955991a09f0992c68a499791a753523f50f71a6885531568404fa0f231832aa0"},
{file = "SQLAlchemy-2.0.30-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f69e4c756ee2686767eb80f94c0125c8b0a0b87ede03eacc5c8ae3b54b99dc46"},
{file = "SQLAlchemy-2.0.30-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69c9db1ce00e59e8dd09d7bae852a9add716efdc070a3e2068377e6ff0d6fdaa"},
{file = "SQLAlchemy-2.0.30-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1429a4b0f709f19ff3b0cf13675b2b9bfa8a7e79990003207a011c0db880a13"},
{file = "SQLAlchemy-2.0.30-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:efedba7e13aa9a6c8407c48facfdfa108a5a4128e35f4c68f20c3407e4376aa9"},
{file = "SQLAlchemy-2.0.30-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:16863e2b132b761891d6c49f0a0f70030e0bcac4fd208117f6b7e053e68668d0"},
{file = "SQLAlchemy-2.0.30-cp311-cp311-win32.whl", hash = "sha256:2ecabd9ccaa6e914e3dbb2aa46b76dede7eadc8cbf1b8083c94d936bcd5ffb49"},
{file = "SQLAlchemy-2.0.30-cp311-cp311-win_amd64.whl", hash = "sha256:0b3f4c438e37d22b83e640f825ef0f37b95db9aa2d68203f2c9549375d0b2260"},
{file = "SQLAlchemy-2.0.30-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5a79d65395ac5e6b0c2890935bad892eabb911c4aa8e8015067ddb37eea3d56c"},
{file = "SQLAlchemy-2.0.30-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9a5baf9267b752390252889f0c802ea13b52dfee5e369527da229189b8bd592e"},
{file = "SQLAlchemy-2.0.30-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cb5a646930c5123f8461f6468901573f334c2c63c795b9af350063a736d0134"},
{file = "SQLAlchemy-2.0.30-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:296230899df0b77dec4eb799bcea6fbe39a43707ce7bb166519c97b583cfcab3"},
{file = "SQLAlchemy-2.0.30-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c62d401223f468eb4da32627bffc0c78ed516b03bb8a34a58be54d618b74d472"},
{file = "SQLAlchemy-2.0.30-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3b69e934f0f2b677ec111b4d83f92dc1a3210a779f69bf905273192cf4ed433e"},
{file = "SQLAlchemy-2.0.30-cp312-cp312-win32.whl", hash = "sha256:77d2edb1f54aff37e3318f611637171e8ec71472f1fdc7348b41dcb226f93d90"},
{file = "SQLAlchemy-2.0.30-cp312-cp312-win_amd64.whl", hash = "sha256:b6c7ec2b1f4969fc19b65b7059ed00497e25f54069407a8701091beb69e591a5"},
{file = "SQLAlchemy-2.0.30-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5a8e3b0a7e09e94be7510d1661339d6b52daf202ed2f5b1f9f48ea34ee6f2d57"},
{file = "SQLAlchemy-2.0.30-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b60203c63e8f984df92035610c5fb76d941254cf5d19751faab7d33b21e5ddc0"},
{file = "SQLAlchemy-2.0.30-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1dc3eabd8c0232ee8387fbe03e0a62220a6f089e278b1f0aaf5e2d6210741ad"},
{file = "SQLAlchemy-2.0.30-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:40ad017c672c00b9b663fcfcd5f0864a0a97828e2ee7ab0c140dc84058d194cf"},
{file = "SQLAlchemy-2.0.30-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e42203d8d20dc704604862977b1470a122e4892791fe3ed165f041e4bf447a1b"},
{file = "SQLAlchemy-2.0.30-cp37-cp37m-win32.whl", hash = "sha256:2a4f4da89c74435f2bc61878cd08f3646b699e7d2eba97144030d1be44e27584"},
{file = "SQLAlchemy-2.0.30-cp37-cp37m-win_amd64.whl", hash = "sha256:b6bf767d14b77f6a18b6982cbbf29d71bede087edae495d11ab358280f304d8e"},
{file = "SQLAlchemy-2.0.30-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc0c53579650a891f9b83fa3cecd4e00218e071d0ba00c4890f5be0c34887ed3"},
{file = "SQLAlchemy-2.0.30-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:311710f9a2ee235f1403537b10c7687214bb1f2b9ebb52702c5aa4a77f0b3af7"},
{file = "SQLAlchemy-2.0.30-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:408f8b0e2c04677e9c93f40eef3ab22f550fecb3011b187f66a096395ff3d9fd"},
{file = "SQLAlchemy-2.0.30-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37a4b4fb0dd4d2669070fb05b8b8824afd0af57587393015baee1cf9890242d9"},
{file = "SQLAlchemy-2.0.30-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a943d297126c9230719c27fcbbeab57ecd5d15b0bd6bfd26e91bfcfe64220621"},
{file = "SQLAlchemy-2.0.30-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0a089e218654e740a41388893e090d2e2c22c29028c9d1353feb38638820bbeb"},
{file = "SQLAlchemy-2.0.30-cp38-cp38-win32.whl", hash = "sha256:fa561138a64f949f3e889eb9ab8c58e1504ab351d6cf55259dc4c248eaa19da6"},
{file = "SQLAlchemy-2.0.30-cp38-cp38-win_amd64.whl", hash = "sha256:7d74336c65705b986d12a7e337ba27ab2b9d819993851b140efdf029248e818e"},
{file = "SQLAlchemy-2.0.30-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ae8c62fe2480dd61c532ccafdbce9b29dacc126fe8be0d9a927ca3e699b9491a"},
{file = "SQLAlchemy-2.0.30-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2383146973a15435e4717f94c7509982770e3e54974c71f76500a0136f22810b"},
{file = "SQLAlchemy-2.0.30-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8409de825f2c3b62ab15788635ccaec0c881c3f12a8af2b12ae4910a0a9aeef6"},
{file = "SQLAlchemy-2.0.30-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0094c5dc698a5f78d3d1539853e8ecec02516b62b8223c970c86d44e7a80f6c7"},
{file = "SQLAlchemy-2.0.30-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:edc16a50f5e1b7a06a2dcc1f2205b0b961074c123ed17ebda726f376a5ab0953"},
{file = "SQLAlchemy-2.0.30-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f7703c2010355dd28f53deb644a05fc30f796bd8598b43f0ba678878780b6e4c"},
{file = "SQLAlchemy-2.0.30-cp39-cp39-win32.whl", hash = "sha256:1f9a727312ff6ad5248a4367358e2cf7e625e98b1028b1d7ab7b806b7d757513"},
{file = "SQLAlchemy-2.0.30-cp39-cp39-win_amd64.whl", hash = "sha256:a0ef36b28534f2a5771191be6edb44cc2673c7b2edf6deac6562400288664221"},
{file = "SQLAlchemy-2.0.30-py3-none-any.whl", hash = "sha256:7108d569d3990c71e26a42f60474b4c02c8586c4681af5fd67e51a044fdea86a"},
{file = "SQLAlchemy-2.0.30.tar.gz", hash = "sha256:2b1708916730f4830bc69d6f49d37f7698b5bd7530aca7f04f785f8849e95255"},
{file = "SQLAlchemy-2.0.31-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f2a213c1b699d3f5768a7272de720387ae0122f1becf0901ed6eaa1abd1baf6c"},
{file = "SQLAlchemy-2.0.31-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9fea3d0884e82d1e33226935dac990b967bef21315cbcc894605db3441347443"},
{file = "SQLAlchemy-2.0.31-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ad7f221d8a69d32d197e5968d798217a4feebe30144986af71ada8c548e9fa"},
{file = "SQLAlchemy-2.0.31-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f2bee229715b6366f86a95d497c347c22ddffa2c7c96143b59a2aa5cc9eebbc"},
{file = "SQLAlchemy-2.0.31-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cd5b94d4819c0c89280b7c6109c7b788a576084bf0a480ae17c227b0bc41e109"},
{file = "SQLAlchemy-2.0.31-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:750900a471d39a7eeba57580b11983030517a1f512c2cb287d5ad0fcf3aebd58"},
{file = "SQLAlchemy-2.0.31-cp310-cp310-win32.whl", hash = "sha256:7bd112be780928c7f493c1a192cd8c5fc2a2a7b52b790bc5a84203fb4381c6be"},
{file = "SQLAlchemy-2.0.31-cp310-cp310-win_amd64.whl", hash = "sha256:5a48ac4d359f058474fadc2115f78a5cdac9988d4f99eae44917f36aa1476327"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f68470edd70c3ac3b6cd5c2a22a8daf18415203ca1b036aaeb9b0fb6f54e8298"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e2c38c2a4c5c634fe6c3c58a789712719fa1bf9b9d6ff5ebfce9a9e5b89c1ca"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd15026f77420eb2b324dcb93551ad9c5f22fab2c150c286ef1dc1160f110203"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2196208432deebdfe3b22185d46b08f00ac9d7b01284e168c212919891289396"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:352b2770097f41bff6029b280c0e03b217c2dcaddc40726f8f53ed58d8a85da4"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:56d51ae825d20d604583f82c9527d285e9e6d14f9a5516463d9705dab20c3740"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-win32.whl", hash = "sha256:6e2622844551945db81c26a02f27d94145b561f9d4b0c39ce7bfd2fda5776dac"},
{file = "SQLAlchemy-2.0.31-cp311-cp311-win_amd64.whl", hash = "sha256:ccaf1b0c90435b6e430f5dd30a5aede4764942a695552eb3a4ab74ed63c5b8d3"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3b74570d99126992d4b0f91fb87c586a574a5872651185de8297c6f90055ae42"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f77c4f042ad493cb8595e2f503c7a4fe44cd7bd59c7582fd6d78d7e7b8ec52c"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cd1591329333daf94467e699e11015d9c944f44c94d2091f4ac493ced0119449"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74afabeeff415e35525bf7a4ecdab015f00e06456166a2eba7590e49f8db940e"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b9c01990d9015df2c6f818aa8f4297d42ee71c9502026bb074e713d496e26b67"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:66f63278db425838b3c2b1c596654b31939427016ba030e951b292e32b99553e"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-win32.whl", hash = "sha256:0b0f658414ee4e4b8cbcd4a9bb0fd743c5eeb81fc858ca517217a8013d282c96"},
{file = "SQLAlchemy-2.0.31-cp312-cp312-win_amd64.whl", hash = "sha256:fa4b1af3e619b5b0b435e333f3967612db06351217c58bfb50cee5f003db2a5a"},
{file = "SQLAlchemy-2.0.31-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:f43e93057cf52a227eda401251c72b6fbe4756f35fa6bfebb5d73b86881e59b0"},
{file = "SQLAlchemy-2.0.31-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d337bf94052856d1b330d5fcad44582a30c532a2463776e1651bd3294ee7e58b"},
{file = "SQLAlchemy-2.0.31-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c06fb43a51ccdff3b4006aafee9fcf15f63f23c580675f7734245ceb6b6a9e05"},
{file = "SQLAlchemy-2.0.31-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:b6e22630e89f0e8c12332b2b4c282cb01cf4da0d26795b7eae16702a608e7ca1"},
{file = "SQLAlchemy-2.0.31-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:79a40771363c5e9f3a77f0e28b3302801db08040928146e6808b5b7a40749c88"},
{file = "SQLAlchemy-2.0.31-cp37-cp37m-win32.whl", hash = "sha256:501ff052229cb79dd4c49c402f6cb03b5a40ae4771efc8bb2bfac9f6c3d3508f"},
{file = "SQLAlchemy-2.0.31-cp37-cp37m-win_amd64.whl", hash = "sha256:597fec37c382a5442ffd471f66ce12d07d91b281fd474289356b1a0041bdf31d"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:dc6d69f8829712a4fd799d2ac8d79bdeff651c2301b081fd5d3fe697bd5b4ab9"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:23b9fbb2f5dd9e630db70fbe47d963c7779e9c81830869bd7d137c2dc1ad05fb"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a21c97efcbb9f255d5c12a96ae14da873233597dfd00a3a0c4ce5b3e5e79704"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26a6a9837589c42b16693cf7bf836f5d42218f44d198f9343dd71d3164ceeeac"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc251477eae03c20fae8db9c1c23ea2ebc47331bcd73927cdcaecd02af98d3c3"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2fd17e3bb8058359fa61248c52c7b09a97cf3c820e54207a50af529876451808"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-win32.whl", hash = "sha256:c76c81c52e1e08f12f4b6a07af2b96b9b15ea67ccdd40ae17019f1c373faa227"},
{file = "SQLAlchemy-2.0.31-cp38-cp38-win_amd64.whl", hash = "sha256:4b600e9a212ed59355813becbcf282cfda5c93678e15c25a0ef896b354423238"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b6cf796d9fcc9b37011d3f9936189b3c8074a02a4ed0c0fbbc126772c31a6d4"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:78fe11dbe37d92667c2c6e74379f75746dc947ee505555a0197cfba9a6d4f1a4"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2fc47dc6185a83c8100b37acda27658fe4dbd33b7d5e7324111f6521008ab4fe"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a41514c1a779e2aa9a19f67aaadeb5cbddf0b2b508843fcd7bafdf4c6864005"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:afb6dde6c11ea4525318e279cd93c8734b795ac8bb5dda0eedd9ebaca7fa23f1"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3f9faef422cfbb8fd53716cd14ba95e2ef655400235c3dfad1b5f467ba179c8c"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-win32.whl", hash = "sha256:fc6b14e8602f59c6ba893980bea96571dd0ed83d8ebb9c4479d9ed5425d562e9"},
{file = "SQLAlchemy-2.0.31-cp39-cp39-win_amd64.whl", hash = "sha256:3cb8a66b167b033ec72c3812ffc8441d4e9f5f78f5e31e54dcd4c90a4ca5bebc"},
{file = "SQLAlchemy-2.0.31-py3-none-any.whl", hash = "sha256:69f3e3c08867a8e4856e92d7afb618b95cdee18e0bc1647b77599722c9a28911"},
{file = "SQLAlchemy-2.0.31.tar.gz", hash = "sha256:b607489dd4a54de56984a0c7656247504bd5523d9d0ba799aef59d4add009484"},
]
[package.dependencies]
greenlet = {version = "!=0.4.17", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""}
greenlet = {version = "!=0.4.17", markers = "python_version < \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"}
typing-extensions = ">=4.6.0"
[package.extras]
@@ -4748,4 +4743,4 @@ multidict = ">=4.0"
[metadata]
lock-version = "2.0"
python-versions = "^3.12.2"
content-hash = "122fe6459adad8b8105d12739e4db159ae39fc32e969d02c7c24436ed3fb85b1"
content-hash = "e7bab493ea80525f7809ac86d3dc93264595dca04e2b51517b41dcf0407146ec"
+13 -13
View File
@@ -8,11 +8,11 @@ readme = "README.md"
[tool.poetry.dependencies]
python = "^3.12.2"
alembic = "==1.13.1"
alembic = "==1.13.2"
amqp = "==5.2.0"
beautifulsoup4 = "==4.12.3"
boto3 = "^1.34.128"
botocore = "^1.34.128"
boto3 = "^1.34.143"
botocore = "^1.34.144"
cachetools = "==5.3.3"
celery = {version = "==5.4.0", extras = ["redis"]}
certifi = ">=2022.12.7"
@@ -34,7 +34,7 @@ flask-redis = "==0.4.0"
flask-sqlalchemy = "==3.1.1"
gunicorn = {version = "==22.0.0", extras = ["eventlet"]}
iso8601 = "==2.1.0"
jsonschema = {version = "==4.22.0", extras = ["format"]}
jsonschema = {version = "==4.23.0", extras = ["format"]}
lxml = "==5.2.2"
marshmallow = "==3.21.3"
marshmallow-sqlalchemy = "==1.0.0"
@@ -46,20 +46,20 @@ poetry-dotenv-plugin = "==0.2.0"
psycopg2-binary = "==2.9.9"
pyjwt = "==2.8.0"
python-dotenv = "==1.0.1"
sqlalchemy = "==2.0.30"
sqlalchemy = "==2.0.31"
werkzeug = "^3.0.3"
faker = "^25.8.0"
faker = "^26.0.0"
async-timeout = "^4.0.3"
bleach = "^6.1.0"
geojson = "^3.1.0"
govuk-bank-holidays = "^0.14"
numpy = "^1.26.4"
ordered-set = "^4.1.0"
phonenumbers = "^8.13.39"
phonenumbers = "^8.13.40"
python-json-logger = "^2.0.7"
pytz = "^2024.1"
regex = "^2024.5.15"
shapely = "^2.0.4"
shapely = "^2.0.5"
smartypants = "^2.0.1"
mistune = "0.8.4"
blinker = "^1.8.2"
@@ -70,13 +70,13 @@ markupsafe = "^2.1.5"
pycparser = "^2.22"
python-dateutil = "^2.9.0.post0"
pyyaml = "^6.0.1"
s3transfer = "^0.10.1"
s3transfer = "^0.10.2"
six = "^1.16.0"
urllib3 = "^2.2.2"
webencodings = "^0.5.1"
itsdangerous = "^2.2.0"
jinja2 = "^3.1.4"
redis = "^5.0.6"
redis = "^5.0.7"
requests = "^2.32.3"
@@ -86,13 +86,13 @@ bandit = "*"
black = "^24.3.0"
cloudfoundry-client = "*"
exceptiongroup = "==1.2.1"
flake8 = "^7.0.0"
flake8 = "^7.1.0"
flake8-bugbear = "^24.1.17"
freezegun = "^1.5.1"
honcho = "*"
isort = "^5.13.2"
jinja2-cli = {version = "==0.8.2", extras = ["yaml"]}
moto = "==5.0.9"
moto = "==5.0.11"
pip-audit = "*"
pre-commit = "^3.7.1"
pytest = "^8.2.2"
@@ -102,7 +102,7 @@ pytest-cov = "^5.0.0"
pytest-xdist = "^3.5.0"
radon = "^6.0.1"
requests-mock = "^1.11.0"
setuptools = "^70.0.0"
setuptools = "^70.3.0"
sqlalchemy-utils = "^0.41.2"
vulture = "^2.10"
detect-secrets = "^1.5.0"
+5 -1
View File
@@ -3,7 +3,7 @@ locals {
}
module "s3" {
source = "github.com/18f/terraform-cloudgov//s3?ref=v0.9.1"
source = "github.com/GSA-TTS/terraform-cloudgov//s3?ref=v1.0.0"
cf_org_name = "gsa-tts-benefits-studio"
cf_space_name = "notify-management"
@@ -13,4 +13,8 @@ module "s3" {
resource "cloudfoundry_service_key" "bucket_creds" {
name = "${local.s3_service_name}-access"
service_instance = module.s3.bucket_id
lifecycle {
prevent_destroy = true
}
}
+4 -15
View File
@@ -1,12 +1,10 @@
locals {
cf_org_name = "gsa-tts-benefits-studio"
cf_space_name = "notify-demo"
env = "demo"
app_name = "notify-api"
recursive_delete = false # deprecated, still used in shared modules
cf_org_name = "gsa-tts-benefits-studio"
cf_space_name = "notify-demo"
env = "demo"
app_name = "notify-api"
}
resource "null_resource" "prevent_destroy" {
lifecycle {
@@ -23,15 +21,6 @@ module "database" {
rds_plan_name = "micro-psql"
}
module "redis" { # default v6.2; delete after v7.0 resource is bound
source = "github.com/GSA-TTS/terraform-cloudgov//redis?ref=v1.0.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-redis-${local.env}"
redis_plan_name = "redis-dev"
}
module "redis-v70" {
source = "github.com/GSA-TTS/terraform-cloudgov//redis?ref=v1.0.0"
+31 -25
View File
@@ -1,38 +1,46 @@
locals {
cf_org_name = "gsa-tts-benefits-studio"
cf_space_name = "notify-production"
env = "production"
app_name = "notify-api"
recursive_delete = false
cf_org_name = "gsa-tts-benefits-studio"
cf_space_name = "notify-production"
env = "production"
app_name = "notify-api"
}
resource "null_resource" "prevent_destroy" {
lifecycle {
prevent_destroy = true
}
}
module "database" {
source = "github.com/18f/terraform-cloudgov//database?ref=v0.7.1"
source = "github.com/GSA-TTS/terraform-cloudgov//database?ref=v1.0.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-rds-${local.env}"
recursive_delete = local.recursive_delete
rds_plan_name = "small-psql-redundant"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-rds-${local.env}"
rds_plan_name = "small-psql-redundant"
}
module "redis" {
source = "github.com/18f/terraform-cloudgov//redis?ref=v0.7.1"
module "redis-v70" {
source = "github.com/GSA-TTS/terraform-cloudgov//redis?ref=v1.0.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-redis-${local.env}"
recursive_delete = local.recursive_delete
redis_plan_name = "redis-3node-large"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-redis-v70-${local.env}"
redis_plan_name = "redis-3node-large"
json_params = jsonencode(
{
"engineVersion" : "7.0",
}
)
}
module "csv_upload_bucket" {
source = "github.com/18f/terraform-cloudgov//s3?ref=v0.7.1"
source = "github.com/GSA-TTS/terraform-cloudgov//s3?ref=v1.0.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
recursive_delete = local.recursive_delete
name = "${local.app_name}-csv-upload-bucket-${local.env}"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-csv-upload-bucket-${local.env}"
}
module "egress-space" {
@@ -51,7 +59,6 @@ module "ses_email" {
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-ses-${local.env}"
recursive_delete = local.recursive_delete
aws_region = "us-gov-west-1"
email_domain = "notify.gov"
mail_from_subdomain = "mail"
@@ -64,7 +71,6 @@ module "sns_sms" {
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-sns-${local.env}"
recursive_delete = local.recursive_delete
aws_region = "us-gov-west-1"
monthly_spend_limit = 1000
}
+5 -17
View File
@@ -1,9 +1,8 @@
locals {
cf_org_name = "gsa-tts-benefits-studio"
cf_space_name = "notify-sandbox"
env = "sandbox"
app_name = "notify-api"
recursive_delete = true # deprecated, still used in shared modules
cf_org_name = "gsa-tts-benefits-studio"
cf_space_name = "notify-sandbox"
env = "sandbox"
app_name = "notify-api"
}
resource "null_resource" "prevent_destroy" {
@@ -22,15 +21,6 @@ module "database" {
rds_plan_name = "micro-psql"
}
module "redis" { # default v6.2; delete after v7.0 resource is bound
source = "github.com/GSA-TTS/terraform-cloudgov//redis?ref=v1.0.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-redis-${local.env}"
redis_plan_name = "redis-dev"
}
module "redis-v70" {
source = "github.com/GSA-TTS/terraform-cloudgov//redis?ref=v1.0.0"
@@ -38,7 +28,7 @@ module "redis-v70" {
cf_space_name = local.cf_space_name
name = "${local.app_name}-redis-v70-${local.env}"
redis_plan_name = "redis-dev"
json_params = jsonencode(
json_params = jsonencode(
{
"engineVersion" : "7.0",
}
@@ -71,7 +61,6 @@ module "ses_email" {
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-ses-${local.env}"
recursive_delete = local.recursive_delete
aws_region = "us-west-2"
email_receipt_error = "notify-support@gsa.gov"
}
@@ -82,7 +71,6 @@ module "sns_sms" {
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-sns-${local.env}"
recursive_delete = local.recursive_delete
aws_region = "us-east-2"
monthly_spend_limit = 1
}
+3 -4
View File
@@ -16,10 +16,9 @@ data "cloudfoundry_service" "ses" {
}
resource "cloudfoundry_service_instance" "ses" {
name = var.name
space = data.cloudfoundry_space.space.id
service_plan = data.cloudfoundry_service.ses.service_plans["base"]
recursive_delete = var.recursive_delete
name = var.name
space = data.cloudfoundry_space.space.id
service_plan = data.cloudfoundry_service.ses.service_plans["base"]
json_params = jsonencode({
region = var.aws_region
domain = var.email_domain
-6
View File
@@ -13,12 +13,6 @@ variable "name" {
description = "name of the service instance"
}
variable "recursive_delete" {
type = bool
description = "when true, deletes service bindings attached to the resource (not recommended for production)"
default = false
}
variable "aws_region" {
type = string
description = "AWS region the SES instance is in"
+3 -4
View File
@@ -16,10 +16,9 @@ data "cloudfoundry_service" "sns" {
}
resource "cloudfoundry_service_instance" "sns" {
name = var.name
space = data.cloudfoundry_space.space.id
service_plan = data.cloudfoundry_service.sns.service_plans["base"]
recursive_delete = var.recursive_delete
name = var.name
space = data.cloudfoundry_space.space.id
service_plan = data.cloudfoundry_service.sns.service_plans["base"]
json_params = jsonencode({
region = var.aws_region
monthly_spend_limit = var.monthly_spend_limit
-6
View File
@@ -13,12 +13,6 @@ variable "name" {
description = "name of the service instance"
}
variable "recursive_delete" {
type = bool
description = "when true, deletes service bindings attached to the resource (not recommended for production)"
default = false
}
variable "aws_region" {
type = string
description = "AWS region the SNS settings are set in"
+31 -25
View File
@@ -1,38 +1,46 @@
locals {
cf_org_name = "gsa-tts-benefits-studio"
cf_space_name = "notify-staging"
env = "staging"
app_name = "notify-api"
recursive_delete = true
cf_org_name = "gsa-tts-benefits-studio"
cf_space_name = "notify-staging"
env = "staging"
app_name = "notify-api"
}
resource "null_resource" "prevent_destroy" {
lifecycle {
prevent_destroy = false # destroying staging is allowed
}
}
module "database" {
source = "github.com/18f/terraform-cloudgov//database?ref=v0.7.1"
source = "github.com/GSA-TTS/terraform-cloudgov//database?ref=v1.0.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-rds-${local.env}"
recursive_delete = local.recursive_delete
rds_plan_name = "micro-psql"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-rds-${local.env}"
rds_plan_name = "micro-psql"
}
module "redis" {
source = "github.com/18f/terraform-cloudgov//redis?ref=v0.7.1"
module "redis-v70" {
source = "github.com/GSA-TTS/terraform-cloudgov//redis?ref=v1.0.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-redis-${local.env}"
recursive_delete = local.recursive_delete
redis_plan_name = "redis-dev"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-redis-v70-${local.env}"
redis_plan_name = "redis-dev"
json_params = jsonencode(
{
"engineVersion" : "7.0",
}
)
}
module "csv_upload_bucket" {
source = "github.com/18f/terraform-cloudgov//s3?ref=v0.7.1"
source = "github.com/GSA-TTS/terraform-cloudgov//s3?ref=v1.0.0"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
recursive_delete = local.recursive_delete
name = "${local.app_name}-csv-upload-bucket-${local.env}"
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-csv-upload-bucket-${local.env}"
}
module "egress-space" {
@@ -53,7 +61,6 @@ module "ses_email" {
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-ses-${local.env}"
recursive_delete = local.recursive_delete
aws_region = "us-west-2"
mail_from_subdomain = "mail"
email_receipt_error = "notify-support@gsa.gov"
@@ -65,7 +72,6 @@ module "sns_sms" {
cf_org_name = local.cf_org_name
cf_space_name = local.cf_space_name
name = "${local.app_name}-sns-${local.env}"
recursive_delete = local.recursive_delete
aws_region = "us-west-2"
monthly_spend_limit = 25
}
@@ -33,31 +33,44 @@ def test_fetch_notification_status_for_service_by_month(notify_db_session):
service_1 = create_service(service_name="service_1")
service_2 = create_service(service_name="service_2")
create_ft_notification_status(
date(2018, 1, 1), NotificationType.SMS, service_1, count=4
)
create_ft_notification_status(
date(2018, 1, 2), NotificationType.SMS, service_1, count=10
)
create_ft_notification_status(
date(2018, 1, 2),
NotificationType.SMS,
service_1,
notification_status=NotificationStatus.CREATED,
)
create_ft_notification_status(date(2018, 1, 3), NotificationType.EMAIL, service_1)
create_template(service=service_1)
create_template(service=service_1, template_type=TemplateType.EMAIL)
# not the service being tested
create_template(service=service_2)
create_ft_notification_status(date(2018, 2, 2), NotificationType.SMS, service_1)
# loop messages for the month
for x in range(0, 14):
create_notification(
service_1.templates[0],
created_at=datetime(2018, 1, 1, 1, x, 0),
status=NotificationStatus.DELIVERED,
)
create_notification(
service_1.templates[0], created_at=datetime(2018, 1, 1, 1, 1, 0)
)
create_notification(
service_1.templates[1],
created_at=datetime(2018, 1, 1, 1, 1, 0),
status=NotificationStatus.DELIVERED,
)
create_notification(
service_1.templates[0],
created_at=datetime(2018, 2, 1, 1, 1, 0),
status=NotificationStatus.DELIVERED,
)
# not included - too early
create_ft_notification_status(date(2017, 12, 31), NotificationType.SMS, service_1)
# not included - too late
create_ft_notification_status(date(2017, 3, 1), NotificationType.SMS, service_1)
# not included - wrong service
create_ft_notification_status(date(2018, 1, 3), NotificationType.SMS, service_2)
# not included - test keys
create_ft_notification_status(
date(2018, 1, 3), NotificationType.SMS, service_1, key_type=KeyType.TEST
# not the right month
create_notification(
service_1.templates[0],
created_at=datetime(2018, 4, 1, 1, 1, 0),
status=NotificationStatus.DELIVERED,
)
# not the right service
create_notification(
service_2.templates[0],
created_at=datetime(2018, 2, 1, 1, 1, 0),
status=NotificationStatus.DELIVERED,
)
results = sorted(
+180
View File
@@ -1,6 +1,7 @@
import uuid
from datetime import datetime, timedelta
from unittest import mock
from unittest.mock import Mock
import pytest
import sqlalchemy
@@ -38,6 +39,7 @@ from app.dao.services_dao import (
delete_service_and_all_associated_db_objects,
get_live_services_with_organization,
get_services_by_partial_name,
get_specific_days_stats,
)
from app.dao.users_dao import create_user_code, save_model_user
from app.enums import (
@@ -48,6 +50,7 @@ from app.enums import (
OrganizationType,
PermissionType,
ServicePermissionType,
StatisticsType,
TemplateType,
)
from app.models import (
@@ -1580,3 +1583,180 @@ def test_get_live_services_with_organization(sample_organization):
(live_service.name, sample_organization.name),
(service_without_org.name, None),
]
_this_date = utc_now() - timedelta(days=4)
@pytest.mark.parametrize(
["data", "start_date", "days", "end_date", "expected", "is_error"],
[
[None, _this_date, None, None, None, True],
[None, _this_date, 4, _this_date - timedelta(4), None, True],
[
[
{"day": _this_date, "something": "else"},
{"day": _this_date, "something": "new"},
{"day": _this_date + timedelta(days=1), "something": "borrowed"},
{"day": _this_date + timedelta(days=2), "something": "old"},
{"day": _this_date + timedelta(days=4), "something": "blue"},
],
_this_date,
4,
None,
{
_this_date.date().strftime("%Y-%m-%d"): {
TemplateType.EMAIL: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 0,
},
TemplateType.SMS: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 2,
},
},
(_this_date.date() + timedelta(days=1)).strftime("%Y-%m-%d"): {
TemplateType.EMAIL: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 0,
},
TemplateType.SMS: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 1,
},
},
(_this_date.date() + timedelta(days=2)).strftime("%Y-%m-%d"): {
TemplateType.EMAIL: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 0,
},
TemplateType.SMS: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 1,
},
},
(_this_date.date() + timedelta(days=3)).strftime("%Y-%m-%d"): {
TemplateType.EMAIL: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 0,
},
TemplateType.SMS: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 0,
},
},
(_this_date.date() + timedelta(days=4)).strftime("%Y-%m-%d"): {
TemplateType.EMAIL: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 0,
},
TemplateType.SMS: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 1,
},
},
},
False,
],
[
[
{"day": _this_date, "something": "else"},
{"day": _this_date, "something": "new"},
{"day": _this_date + timedelta(days=1), "something": "borrowed"},
{"day": _this_date + timedelta(days=2), "something": "old"},
{"day": _this_date + timedelta(days=4), "something": "blue"},
],
_this_date,
None,
_this_date + timedelta(4),
{
_this_date.date().strftime("%Y-%m-%d"): {
TemplateType.EMAIL: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 0,
},
TemplateType.SMS: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 2,
},
},
(_this_date.date() + timedelta(days=1)).strftime("%Y-%m-%d"): {
TemplateType.EMAIL: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 0,
},
TemplateType.SMS: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 1,
},
},
(_this_date.date() + timedelta(days=2)).strftime("%Y-%m-%d"): {
TemplateType.EMAIL: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 0,
},
TemplateType.SMS: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 1,
},
},
(_this_date.date() + timedelta(days=3)).strftime("%Y-%m-%d"): {
TemplateType.EMAIL: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 0,
},
TemplateType.SMS: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 0,
},
},
(_this_date.date() + timedelta(days=4)).strftime("%Y-%m-%d"): {
TemplateType.EMAIL: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 0,
},
TemplateType.SMS: {
StatisticsType.DELIVERED: 0,
StatisticsType.FAILURE: 0,
StatisticsType.REQUESTED: 1,
},
},
},
False,
],
],
)
def test_get_specific_days(data, start_date, days, end_date, expected, is_error):
if is_error:
with pytest.raises(ValueError):
get_specific_days_stats(data, start_date, days, end_date)
else:
new_data = []
for line in data:
new_line = Mock()
new_line.day = line["day"]
new_line.notification_type = NotificationType.SMS
new_line.count = 1
new_line.something = line["something"]
new_data.append(new_line)
results = get_specific_days_stats(new_data, start_date, days, end_date)
assert results == expected
@@ -79,7 +79,7 @@ def test_should_reject_bad_phone_numbers(notify_api, sample_template, mocker):
assert json_resp["result"] == "error"
assert len(json_resp["message"].keys()) == 1
assert (
"Invalid phone number: The string supplied did not seem to be a phone number."
"Invalid phone number: Invalid phone number looks like invalid The string supplied did not seem to be a phone number." # noqa
in json_resp["message"]["to"]
)
assert response.status_code == 400
+16 -3
View File
@@ -301,12 +301,15 @@ def test_add_monthly_notification_status_stats():
data = create_empty_monthly_notification_status_stats_dict(2018)
# this data won't be affected
data["2018-05"][NotificationType.EMAIL][NotificationStatus.SENDING] = 32
data["2018-05"][NotificationType.EMAIL][StatisticsType.REQUESTED] = 32
# this data will get combined with the 8 from row_data
data["2018-05"][NotificationType.SMS][NotificationStatus.SENDING] = 16
data["2018-05"][NotificationType.SMS][StatisticsType.REQUESTED] = 16
add_monthly_notification_status_stats(data, rows)
# first 3 months are empty
assert data == {
"2018-01": {NotificationType.SMS: {}, NotificationType.EMAIL: {}},
"2018-02": {NotificationType.SMS: {}, NotificationType.EMAIL: {}},
@@ -315,12 +318,22 @@ def test_add_monthly_notification_status_stats():
NotificationType.SMS: {
NotificationStatus.SENDING: 1,
NotificationStatus.DELIVERED: 2,
StatisticsType.REQUESTED: 3,
},
NotificationType.EMAIL: {
NotificationStatus.SENDING: 4,
StatisticsType.REQUESTED: 4,
},
NotificationType.EMAIL: {NotificationStatus.SENDING: 4},
},
"2018-05": {
NotificationType.SMS: {NotificationStatus.SENDING: 24},
NotificationType.EMAIL: {NotificationStatus.SENDING: 32},
NotificationType.SMS: {
NotificationStatus.SENDING: 24,
StatisticsType.REQUESTED: 24,
},
NotificationType.EMAIL: {
NotificationStatus.SENDING: 32,
StatisticsType.REQUESTED: 32,
},
},
"2018-06": {NotificationType.SMS: {}, NotificationType.EMAIL: {}},
}
+83 -100
View File
@@ -234,17 +234,36 @@ def test_get_monthly_notification_stats_returns_stats(admin_request, sample_serv
sms_t2 = create_template(sample_service)
email_template = create_template(sample_service, template_type=TemplateType.EMAIL)
create_ft_notification_status(datetime(2016, 6, 1), template=sms_t1)
create_ft_notification_status(datetime(2016, 6, 2), template=sms_t1)
create_ft_notification_status(datetime(2016, 7, 1), template=sms_t1)
create_ft_notification_status(datetime(2016, 7, 1), template=sms_t2)
create_ft_notification_status(
datetime(2016, 7, 1),
template=sms_t1,
notification_status=NotificationStatus.CREATED,
create_notification(
sms_t1,
created_at=datetime(2016, 6, 1, 1, 1, 0),
status=NotificationStatus.DELIVERED,
)
create_notification(
sms_t1,
created_at=datetime(2016, 6, 2, 1, 1, 0),
status=NotificationStatus.DELIVERED,
)
create_notification(
sms_t1,
created_at=datetime(2016, 7, 1, 1, 1, 0),
status=NotificationStatus.DELIVERED,
)
create_notification(
sms_t2,
created_at=datetime(2016, 7, 1, 1, 1, 0),
status=NotificationStatus.DELIVERED,
)
create_notification(
sms_t1,
created_at=datetime(2016, 7, 1, 1, 1, 0),
status=NotificationStatus.CREATED,
)
create_notification(
email_template,
created_at=datetime(2016, 7, 1, 1, 1, 0),
status=NotificationStatus.DELIVERED,
)
create_ft_notification_status(datetime(2016, 7, 1), template=email_template)
response = admin_request.get(
"service.get_monthly_notification_stats",
@@ -256,7 +275,8 @@ def test_get_monthly_notification_stats_returns_stats(admin_request, sample_serv
assert response["data"]["2016-06"] == {
NotificationType.SMS: {
# it combines the two days
NotificationStatus.DELIVERED: 2
NotificationStatus.DELIVERED: 2,
StatisticsType.REQUESTED: 2,
},
NotificationType.EMAIL: {},
}
@@ -265,86 +285,43 @@ def test_get_monthly_notification_stats_returns_stats(admin_request, sample_serv
NotificationType.SMS: {
NotificationStatus.CREATED: 1,
NotificationStatus.DELIVERED: 2,
StatisticsType.REQUESTED: 3,
},
NotificationType.EMAIL: {StatisticsType.DELIVERED: 1},
}
@freeze_time("2016-06-05 12:00:00")
def test_get_monthly_notification_stats_combines_todays_data_and_historic_stats(
admin_request, sample_template
):
create_ft_notification_status(
datetime(2016, 5, 1, 12),
template=sample_template,
count=1,
)
create_ft_notification_status(
datetime(2016, 6, 1, 12),
template=sample_template,
notification_status=NotificationStatus.CREATED,
count=2,
) # noqa
create_notification(
sample_template,
created_at=datetime(2016, 6, 5, 12),
status=NotificationStatus.CREATED,
)
create_notification(
sample_template,
created_at=datetime(2016, 6, 5, 12),
status=NotificationStatus.DELIVERED,
)
# this doesn't get returned in the stats because it is old - it should be in ft_notification_status by now
create_notification(
sample_template,
created_at=datetime(2016, 6, 4, 12),
status=NotificationStatus.SENDING,
)
response = admin_request.get(
"service.get_monthly_notification_stats",
service_id=sample_template.service_id,
year=2016,
)
assert len(response["data"]) == 6 # January to June
assert response["data"]["2016-05"] == {
NotificationType.SMS: {NotificationStatus.DELIVERED: 1},
NotificationType.EMAIL: {},
}
assert response["data"]["2016-06"] == {
NotificationType.SMS: {
# combines the stats from the historic ft_notification_status and the current notifications
NotificationStatus.CREATED: 3,
NotificationStatus.DELIVERED: 1,
NotificationType.EMAIL: {
StatisticsType.DELIVERED: 1,
StatisticsType.REQUESTED: 1,
},
NotificationType.EMAIL: {},
}
def test_get_monthly_notification_stats_ignores_test_keys(
admin_request, sample_service
):
create_ft_notification_status(
datetime(2016, 6, 1),
service=sample_service,
create_template(service=sample_service)
create_notification(
sample_service.templates[0],
created_at=datetime(2016, 6, 1, 1, 1, 0),
key_type=KeyType.NORMAL,
count=1,
status=NotificationStatus.DELIVERED,
)
create_ft_notification_status(
datetime(2016, 6, 1),
service=sample_service,
create_notification(
sample_service.templates[0],
created_at=datetime(2016, 6, 2, 1, 1, 0),
key_type=KeyType.NORMAL,
status=NotificationStatus.DELIVERED,
)
create_notification(
sample_service.templates[0],
created_at=datetime(2016, 6, 1, 1, 1, 0),
key_type=KeyType.TEAM,
count=2,
status=NotificationStatus.DELIVERED,
)
create_ft_notification_status(
datetime(2016, 6, 1),
service=sample_service,
create_notification(
sample_service.templates[0],
created_at=datetime(2016, 6, 1, 1, 1, 0),
key_type=KeyType.TEST,
count=4,
status=NotificationStatus.DELIVERED,
)
response = admin_request.get(
@@ -355,26 +332,27 @@ def test_get_monthly_notification_stats_ignores_test_keys(
assert response["data"]["2016-06"][NotificationType.SMS] == {
NotificationStatus.DELIVERED: 3,
StatisticsType.REQUESTED: 3,
}
def test_get_monthly_notification_stats_checks_dates(admin_request, sample_service):
t = create_template(sample_service)
# create_ft_notification_status(datetime(2016, 3, 31), template=t, notification_status='created')
create_ft_notification_status(
datetime(2016, 4, 2),
template=t,
notification_status=NotificationStatus.SENDING,
create_notification(
t,
created_at=datetime(2016, 4, 2),
status=NotificationStatus.SENDING,
)
create_ft_notification_status(
datetime(2017, 3, 31),
template=t,
notification_status=NotificationStatus.DELIVERED,
create_notification(
t,
created_at=datetime(2017, 3, 31),
status=NotificationStatus.DELIVERED,
)
create_ft_notification_status(
datetime(2017, 4, 11),
template=t,
notification_status=NotificationStatus.PERMANENT_FAILURE,
create_notification(
t,
created_at=datetime(2017, 4, 11),
status=NotificationStatus.PERMANENT_FAILURE,
)
response = admin_request.get(
@@ -386,9 +364,11 @@ def test_get_monthly_notification_stats_checks_dates(admin_request, sample_servi
assert "2017-04" not in response["data"]
assert response["data"]["2016-04"][NotificationType.SMS] == {
NotificationStatus.SENDING: 1,
StatisticsType.REQUESTED: 1,
}
assert response["data"]["2016-04"][NotificationType.SMS] == {
NotificationStatus.SENDING: 1,
StatisticsType.REQUESTED: 1,
}
@@ -399,15 +379,15 @@ def test_get_monthly_notification_stats_only_gets_for_one_service(
templates = [create_template(services[0]), create_template(services[1])]
create_ft_notification_status(
datetime(2016, 6, 1),
template=templates[0],
notification_status=NotificationStatus.CREATED,
create_notification(
templates[0],
created_at=datetime(2016, 6, 1),
status=NotificationStatus.CREATED,
)
create_ft_notification_status(
datetime(2016, 6, 1),
template=templates[1],
notification_status=NotificationStatus.DELIVERED,
create_notification(
templates[1],
created_at=datetime(2016, 6, 1),
status=NotificationStatus.DELIVERED,
)
response = admin_request.get(
@@ -417,6 +397,9 @@ def test_get_monthly_notification_stats_only_gets_for_one_service(
)
assert response["data"]["2016-06"] == {
NotificationType.SMS: {NotificationStatus.CREATED: 1},
NotificationType.SMS: {
NotificationStatus.CREATED: 1,
StatisticsType.REQUESTED: 1,
},
NotificationType.EMAIL: {},
}
@@ -31,8 +31,8 @@ def test_create_invited_user(
extra_args,
expected_start_of_invite_url,
):
mocker.patch("app.service_invite.rest.redis_store.raw_set")
mocker.patch("app.service_invite.rest.redis_store.raw_get")
mocker.patch("app.service_invite.rest.redis_store.set")
mocker.patch("app.service_invite.rest.redis_store.get")
mocked = mocker.patch("app.celery.provider_tasks.deliver_email.apply_async")
email_address = "invited_user@service.gov.uk"
@@ -96,8 +96,8 @@ def test_create_invited_user_without_auth_type(
admin_request, sample_service, mocker, invitation_email_template
):
mocker.patch("app.service_invite.rest.redis_store.raw_set")
mocker.patch("app.service_invite.rest.redis_store.raw_get")
mocker.patch("app.service_invite.rest.redis_store.set")
mocker.patch("app.service_invite.rest.redis_store.get")
mocker.patch("app.celery.provider_tasks.deliver_email.apply_async")
email_address = "invited_user@service.gov.uk"
invite_from = sample_service.users[0]
@@ -220,8 +220,8 @@ def test_resend_expired_invite(
mocker,
):
mocker.patch("app.service_invite.rest.redis_store.raw_set")
mocker.patch("app.service_invite.rest.redis_store.raw_get")
mocker.patch("app.service_invite.rest.redis_store.set")
mocker.patch("app.service_invite.rest.redis_store.get")
url = f"/service/{sample_expired_user.service_id}/invite/{sample_expired_user.id}/resend"
mock_send = mocker.patch("app.service_invite.rest.send_notification_to_queue")
mock_persist = mocker.patch("app.service_invite.rest.persist_notification")
-11
View File
@@ -9,7 +9,6 @@ from app.utils import (
get_midnight_in_utc,
get_public_notify_type_text,
midnight_n_days_ago,
scrub,
)
@@ -91,16 +90,6 @@ def test_get_public_notify_type_text():
)
def test_scrub():
result = scrub(
"This is a message with 17775554324, and also 18884449323 and also 17775554324"
)
assert (
result
== "This is a message with 1XXXXX54324, and also 1XXXXX49323 and also 1XXXXX54324"
)
# This method is used for simulating bulk sends. We use localstack and run on a developer's machine to do the
# simulation. Please see docs->bulk_testing.md for instructions.
# def test_generate_csv_for_bulk_testing():
+2 -2
View File
@@ -226,7 +226,7 @@ def test_cannot_create_user_with_empty_strings(admin_request, notify_db_session)
assert resp["message"] == {
"email_address": ["Not a valid email address"],
"mobile_number": [
"Invalid phone number: The string supplied did not seem to be a phone number."
"Invalid phone number: Invalid phone number looks like The string supplied did not seem to be a phone number." # noqa
],
"name": ["Invalid name"],
}
@@ -949,7 +949,7 @@ def test_cannot_update_user_with_mobile_number_as_empty_string(
_expected_status=400,
)
assert resp["message"]["mobile_number"] == [
"Invalid phone number: The string supplied did not seem to be a phone number."
"Invalid phone number: Invalid phone number looks like The string supplied did not seem to be a phone number." # noqa
]
+10 -10
View File
@@ -201,10 +201,10 @@ def test_send_user_sms_code(client, sample_user, sms_code_template, mocker):
"""
notify_service = dao_fetch_service_by_id(current_app.config["NOTIFY_SERVICE_ID"])
mock_redis_get = mocker.patch("app.user.rest.redis_store.raw_get")
mock_redis_get = mocker.patch("app.user.rest.redis_store.get")
mock_redis_get.return_value = "foo"
mocker.patch("app.user.rest.redis_store.raw_set")
mocker.patch("app.user.rest.redis_store.set")
auth_header = create_admin_authorization_header()
mocked = mocker.patch("app.user.rest.create_secret_code", return_value="11111")
mocker.patch("app.celery.provider_tasks.deliver_sms.apply_async")
@@ -242,10 +242,10 @@ def test_send_user_code_for_sms_with_optional_to_field(
Tests POST endpoint /user/<user_id>/sms-code with optional to field
"""
mock_redis_get = mocker.patch("app.user.rest.redis_store.raw_get")
mock_redis_get = mocker.patch("app.user.rest.redis_store.get")
mock_redis_get.return_value = "foo"
mocker.patch("app.user.rest.redis_store.raw_set")
mocker.patch("app.user.rest.redis_store.set")
to_number = "+14254147755"
mocked = mocker.patch("app.user.rest.create_secret_code", return_value="11111")
mocker.patch("app.celery.provider_tasks.deliver_sms.apply_async")
@@ -469,10 +469,10 @@ def test_send_user_email_code(
deliver_email = mocker.patch("app.celery.provider_tasks.deliver_email.apply_async")
sample_user.auth_type = auth_type
mock_redis_get = mocker.patch("app.user.rest.redis_store.raw_get")
mock_redis_get = mocker.patch("app.user.rest.redis_store.get")
mock_redis_get.return_value = "foo"
mocker.patch("app.user.rest.redis_store.raw_set")
mocker.patch("app.user.rest.redis_store.set")
admin_request.post(
"user.send_user_2fa_code",
@@ -497,10 +497,10 @@ def test_send_user_email_code_with_urlencoded_next_param(
):
mocker.patch("app.celery.provider_tasks.deliver_email.apply_async")
mock_redis_get = mocker.patch("app.celery.scheduled_tasks.redis_store.raw_get")
mock_redis_get = mocker.patch("app.celery.scheduled_tasks.redis_store.get")
mock_redis_get.return_value = "foo"
mocker.patch("app.celery.scheduled_tasks.redis_store.raw_set")
mocker.patch("app.celery.scheduled_tasks.redis_store.set")
data = {"to": None, "next": "/services"}
admin_request.post(
@@ -581,10 +581,10 @@ def test_user_verify_email_code_fails_if_code_already_used(
def test_send_user_2fa_code_sends_from_number_for_international_numbers(
client, sample_user, mocker, sms_code_template
):
mock_redis_get = mocker.patch("app.user.rest.redis_store.raw_get")
mock_redis_get = mocker.patch("app.user.rest.redis_store.get")
mock_redis_get.return_value = "foo"
mocker.patch("app.user.rest.redis_store.raw_set")
mocker.patch("app.user.rest.redis_store.set")
sample_user.mobile_number = "+601117224412"
auth_header = create_admin_authorization_header()
@@ -1,5 +1,5 @@
import uuid
from unittest.mock import Mock, call
from unittest.mock import Mock
import pytest
from freezegun import freeze_time
@@ -59,30 +59,6 @@ def failing_redis_client(mocked_redis_client, delete_mock):
return mocked_redis_client
def test_should_not_raise_exception_if_raise_set_to_false(
app, caplog, failing_redis_client, mocker
):
mock_logger = mocker.patch("flask.Flask.logger")
assert failing_redis_client.get("get_key") is None
assert failing_redis_client.set("set_key", "set_value") is None
assert failing_redis_client.incr("incr_key") is None
assert failing_redis_client.exceeded_rate_limit("rate_limit_key", 100, 100) is False
assert failing_redis_client.delete("delete_key") is None
assert failing_redis_client.delete("a", "b", "c") is None
assert failing_redis_client.delete_by_pattern("pattern") == 0
assert mock_logger.mock_calls == [
call.exception("Redis error performing get on get_key"),
call.exception("Redis error performing set on set_key"),
call.exception("Redis error performing incr on incr_key"),
call.exception("Redis error performing rate-limit-pipeline on rate_limit_key"),
call.exception("Redis error performing delete on delete_key"),
call.exception("Redis error performing delete on a, b, c"),
call.exception("Redis error performing delete-by-pattern on pattern"),
]
def test_should_raise_exception_if_raise_set_to_true(
app,
failing_redis_client,
+15
View File
@@ -49,3 +49,18 @@ def test_base_json_formatter_contains_service_id():
== "message to log"
)
assert service_id_filter.filter(record).service_id == "notify-admin"
def test_pii_filter():
record = builtin_logging.LogRecord(
name="log thing",
level="info",
pathname="path",
lineno=123,
msg="phone1: 1555555555, phone2: 1555555554, email1: fake@fake.gov, email2: fake@fake2.fake.gov",
exc_info=None,
args=None,
)
pii_filter = logging.PIIFilter()
clean_msg = "phone1: 1XXXXXXXXXX, phone2: 1XXXXXXXXXX, email1: XXXXX@XXXXXXX, email2: XXXXX@XXXXXXX"
assert pii_filter.filter(record).msg == clean_msg
@@ -9,6 +9,7 @@ from notifications_utils.recipients import (
get_international_phone_info,
international_phone_info,
is_us_phone_number,
show_mangled_number_clues,
try_validate_and_format_phone_number,
validate_and_format_phone_number,
validate_email_address,
@@ -324,6 +325,11 @@ def test_phone_number_rejects_invalid_international_values(phone_number, error_m
assert error_message == str(e.value)
def test_show_mangled_number_clues():
x = show_mangled_number_clues("848!!-202?-2020$$")
assert x == "XXX!!-XXX?-XXXX$$"
@pytest.mark.parametrize("email_address", valid_email_addresses)
def test_validate_email_address_accepts_valid(email_address):
try: