From 52f8bd23dee612f020f2ccd346cebb165f82ccd1 Mon Sep 17 00:00:00 2001 From: Beverly Nguyen Date: Wed, 25 Jun 2025 11:53:41 -0700 Subject: [PATCH 1/6] Updated the job listing query to sort by the most recent activity, so that all jobs are listed with the latest activity at the top. Previously, pending jobs (with no processing_started) could be grouped separately due to how SQL sorted NULL values. --- app/dao/jobs_dao.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/dao/jobs_dao.py b/app/dao/jobs_dao.py index 171522d6c..e8e32f633 100644 --- a/app/dao/jobs_dao.py +++ b/app/dao/jobs_dao.py @@ -75,7 +75,7 @@ def dao_get_jobs_by_service_id( stmt = ( select(Job) .where(*query_filter) - .order_by(Job.processing_started.desc(), Job.created_at.desc()) + .order_by(func.coalesce(Job.processing_started, Job.created_at).desc()) .limit(page_size) .offset(offset) ) @@ -157,17 +157,17 @@ def dao_create_job(job): orig_time = job.created_at now_time = utc_now() diff_time = now_time - orig_time - current_app.logger.warning( + current_app.logger.info( f"#notify-debug-admin-1859 dao_create_job orig created at {orig_time} and now {now_time}" ) if diff_time.total_seconds() > 300: # It should be only a few seconds diff at most - current_app.logger.warning( + current_app.logger.error( "#notify-debug-admin-1859 Something is wrong with job.created_at!" ) if os.getenv("NOTIFY_ENVIRONMENT") not in ["test"]: job.created_at = now_time dao_update_job(job) - current_app.logger.warning( + current_app.logger.error( f"#notify-debug-admin-1859 Job created_at reset to {job.created_at}" ) From 3738934374d0418899e95503e6be75cfaf8b2f96 Mon Sep 17 00:00:00 2001 From: Beverly Nguyen Date: Wed, 25 Jun 2025 11:56:27 -0700 Subject: [PATCH 2/6] clean up --- app/dao/jobs_dao.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/dao/jobs_dao.py b/app/dao/jobs_dao.py index e8e32f633..b35ecf5a6 100644 --- a/app/dao/jobs_dao.py +++ b/app/dao/jobs_dao.py @@ -157,17 +157,17 @@ def dao_create_job(job): orig_time = job.created_at now_time = utc_now() diff_time = now_time - orig_time - current_app.logger.info( + current_app.logger.warning( f"#notify-debug-admin-1859 dao_create_job orig created at {orig_time} and now {now_time}" ) if diff_time.total_seconds() > 300: # It should be only a few seconds diff at most - current_app.logger.error( + current_app.logger.warning( "#notify-debug-admin-1859 Something is wrong with job.created_at!" ) if os.getenv("NOTIFY_ENVIRONMENT") not in ["test"]: job.created_at = now_time dao_update_job(job) - current_app.logger.error( + current_app.logger.warning( f"#notify-debug-admin-1859 Job created_at reset to {job.created_at}" ) From 58a8b51f59736ad4c662ebdfbe3594dd884b397a Mon Sep 17 00:00:00 2001 From: Kenneth Kehl <@kkehl@flexion.us> Date: Thu, 26 Jun 2025 10:35:46 -0700 Subject: [PATCH 3/6] more input checking --- app/job/rest.py | 54 ++------------------------------- app/notifications/rest.py | 2 ++ app/organization/invite_rest.py | 8 +++++ app/organization/rest.py | 9 ++++++ app/provider_details/rest.py | 4 +++ app/service/rest.py | 54 ++++++++++++++++++++++++++++++++- app/service_invite/rest.py | 8 ++++- app/template/rest.py | 9 +++++- app/template_folder/rest.py | 7 +++++ app/template_statistics/rest.py | 4 ++- app/upload/rest.py | 3 +- app/user/rest.py | 23 +++++++++++++- app/utils.py | 52 ++++++++++++++++++++++++++++++- app/webauthn/rest.py | 4 +++ tests/app/job/test_rest.py | 27 ----------------- tests/app/test_utils.py | 32 +++++++++++++++++++ 16 files changed, 214 insertions(+), 86 deletions(-) diff --git a/app/job/rest.py b/app/job/rest.py index 831c3f2f4..6522ce93e 100644 --- a/app/job/rest.py +++ b/app/job/rest.py @@ -1,8 +1,7 @@ -import re from zoneinfo import ZoneInfo import dateutil -from flask import Blueprint, abort, current_app, jsonify, request +from flask import Blueprint, current_app, jsonify, request from app import db from app.aws.s3 import ( @@ -37,7 +36,7 @@ from app.schemas import ( notification_with_template_schema, notifications_filter_schema, ) -from app.utils import midnight_n_days_ago, pagination_links +from app.utils import check_suspicious_id, midnight_n_days_ago, pagination_links job_blueprint = Blueprint("job", __name__, url_prefix="/service//job") @@ -45,55 +44,6 @@ job_blueprint = Blueprint("job", __name__, url_prefix="/service/", methods=["GET"]) def get_job_by_service_and_job_id(service_id, job_id): check_suspicious_id(service_id, job_id) diff --git a/app/notifications/rest.py b/app/notifications/rest.py index 8e1f0c58a..55553b4bf 100644 --- a/app/notifications/rest.py +++ b/app/notifications/rest.py @@ -23,6 +23,7 @@ from app.schemas import ( ) from app.service.utils import service_allowed_to_send_to from app.utils import ( + check_suspicious_id, get_public_notify_type_text, get_template_instance, pagination_links, @@ -36,6 +37,7 @@ register_errors(notifications) @notifications.route("/notifications/", methods=["GET"]) def get_notification_by_id(notification_id): + check_suspicious_id(notification_id) notification = notifications_dao.get_notification_with_personalisation( str(authenticated_service.id), notification_id, key_type=None ) diff --git a/app/organization/invite_rest.py b/app/organization/invite_rest.py index caa803485..2e6a9ba2c 100644 --- a/app/organization/invite_rest.py +++ b/app/organization/invite_rest.py @@ -27,6 +27,7 @@ from app.organization.organization_schema import ( post_update_invited_org_user_status_schema, ) from app.schema_validation import validate +from app.utils import check_suspicious_id from notifications_utils.url_safe_token import check_token, generate_token organization_invite_blueprint = Blueprint("organization_invite", __name__) @@ -38,6 +39,7 @@ register_errors(organization_invite_blueprint) "/organization//invite", methods=["POST"] ) def invite_user_to_org(organization_id): + check_suspicious_id(organization_id) data = request.get_json() validate(data, post_create_invited_org_user_status_schema) @@ -98,6 +100,8 @@ def invite_user_to_org(organization_id): "/organization//invite", methods=["GET"] ) def get_invited_org_users_by_organization(organization_id): + + check_suspicious_id(organization_id) invited_org_users = get_invited_org_users_for_organization(organization_id) return jsonify(data=[x.serialize() for x in invited_org_users]), 200 @@ -106,6 +110,7 @@ def get_invited_org_users_by_organization(organization_id): "/organization//invite/", methods=["GET"] ) def get_invited_org_user_by_organization(organization_id, invited_org_user_id): + check_suspicious_id(organization_id, invited_org_user_id) invited_org_user = dao_get_invited_org_user(organization_id, invited_org_user_id) return jsonify(data=invited_org_user.serialize()), 200 @@ -115,6 +120,7 @@ def get_invited_org_user_by_organization(organization_id, invited_org_user_id): methods=["POST"], ) def update_org_invite_status(organization_id, invited_org_user_id): + check_suspicious_id(organization_id, invited_org_user_id) fetched = dao_get_invited_org_user( organization_id=organization_id, invited_org_user_id=invited_org_user_id ) @@ -129,6 +135,7 @@ def update_org_invite_status(organization_id, invited_org_user_id): def invited_org_user_url(invited_org_user_id, invite_link_host=None): + token = generate_token( str(invited_org_user_id), current_app.config["SECRET_KEY"], @@ -145,6 +152,7 @@ def invited_org_user_url(invited_org_user_id, invite_link_host=None): "/invite/organization/", methods=["GET"] ) def get_invited_org_user(invited_org_user_id): + check_suspicious_id(invited_org_user_id) invited_user = get_invited_org_user_by_id(invited_org_user_id) return jsonify(data=invited_user.serialize()), 200 diff --git a/app/organization/rest.py b/app/organization/rest.py index f3d887511..df20d3cf3 100644 --- a/app/organization/rest.py +++ b/app/organization/rest.py @@ -36,6 +36,7 @@ from app.organization.organization_schema import ( post_update_organization_schema, ) from app.schema_validation import validate +from app.utils import check_suspicious_id organization_blueprint = Blueprint("organization", __name__) register_errors(organization_blueprint) @@ -64,6 +65,7 @@ def get_organizations(): @organization_blueprint.route("/", methods=["GET"]) def get_organization_by_id(organization_id): + check_suspicious_id(organization_id) organization = dao_get_organization_by_id(organization_id) return jsonify(organization.serialize()) @@ -97,6 +99,7 @@ def create_organization(): @organization_blueprint.route("/", methods=["POST"]) def update_organization(organization_id): + check_suspicious_id(organization_id) data = request.get_json() validate(data, post_update_organization_schema) @@ -115,6 +118,7 @@ def update_organization(organization_id): @organization_blueprint.route("//service", methods=["POST"]) def link_service_to_organization(organization_id): + check_suspicious_id(organization_id) data = request.get_json() validate(data, post_link_service_to_organization_schema) service = dao_fetch_service_by_id(data["service_id"]) @@ -129,6 +133,7 @@ def link_service_to_organization(organization_id): @organization_blueprint.route("//services", methods=["GET"]) def get_organization_services(organization_id): + check_suspicious_id(organization_id) services = dao_get_organization_services(organization_id) sorted_services = sorted(services, key=lambda s: (-s.active, s.name)) return jsonify([s.serialize_for_org_dashboard() for s in sorted_services]) @@ -138,6 +143,7 @@ def get_organization_services(organization_id): "//services-with-usage", methods=["GET"] ) def get_organization_services_usage(organization_id): + check_suspicious_id(organization_id) try: year = int(request.args.get("year", "none")) except ValueError: @@ -154,6 +160,7 @@ def get_organization_services_usage(organization_id): "//users/", methods=["POST"] ) def add_user_to_organization(organization_id, user_id): + check_suspicious_id(organization_id, user_id) new_org_user = dao_add_user_to_organization(organization_id, user_id) return jsonify(data=new_org_user.serialize()) @@ -162,6 +169,7 @@ def add_user_to_organization(organization_id, user_id): "//users/", methods=["DELETE"] ) def remove_user_from_organization(organization_id, user_id): + check_suspicious_id(organization_id, user_id) organization = dao_get_organization_by_id(organization_id) user = get_user_by_id(user_id=user_id) @@ -176,6 +184,7 @@ def remove_user_from_organization(organization_id, user_id): @organization_blueprint.route("//users", methods=["GET"]) def get_organization_users(organization_id): + check_suspicious_id(organization_id) org_users = dao_get_users_for_organization(organization_id) return jsonify(data=[x.serialize() for x in org_users]) diff --git a/app/provider_details/rest.py b/app/provider_details/rest.py index 3a7e62332..db8d2b8db 100644 --- a/app/provider_details/rest.py +++ b/app/provider_details/rest.py @@ -9,6 +9,7 @@ from app.dao.provider_details_dao import ( from app.dao.users_dao import get_user_by_id from app.errors import InvalidRequest, register_errors from app.schemas import provider_details_history_schema, provider_details_schema +from app.utils import check_suspicious_id provider_details = Blueprint("provider_details", __name__) register_errors(provider_details) @@ -38,12 +39,14 @@ def get_providers(): @provider_details.route("/", methods=["GET"]) def get_provider_by_id(provider_details_id): + check_suspicious_id(provider_details_id) data = provider_details_schema.dump(get_provider_details_by_id(provider_details_id)) return jsonify(provider_details=data) @provider_details.route("//versions", methods=["GET"]) def get_provider_versions(provider_details_id): + check_suspicious_id(provider_details_id) versions = dao_get_provider_versions(provider_details_id) data = provider_details_history_schema.dump(versions, many=True) return jsonify(data=data) @@ -51,6 +54,7 @@ def get_provider_versions(provider_details_id): @provider_details.route("/", methods=["POST"]) def update_provider_details(provider_details_id): + check_suspicious_id(provider_details_id) valid_keys = {"priority", "created_by", "active"} req_json = request.get_json() diff --git a/app/service/rest.py b/app/service/rest.py index 49052fead..b204a940e 100644 --- a/app/service/rest.py +++ b/app/service/rest.py @@ -116,7 +116,7 @@ from app.service.service_senders_schema import ( ) from app.service.utils import get_guest_list_objects from app.user.users_schema import post_set_permissions_schema -from app.utils import get_prev_next_pagination_links, utc_now +from app.utils import check_suspicious_id, get_prev_next_pagination_links, utc_now service_blueprint = Blueprint("service", __name__) @@ -202,6 +202,7 @@ def get_live_services_data(): @service_blueprint.route("/", methods=["GET"]) def get_service_by_id(service_id): + check_suspicious_id(service_id) if request.args.get("detailed") == "True": data = get_detailed_service( service_id, today_only=request.args.get("today_only") == "True" @@ -217,6 +218,7 @@ def get_service_by_id(service_id): @service_blueprint.route("//statistics") def get_service_notification_statistics(service_id): + check_suspicious_id(service_id) return jsonify( data=get_service_statistics( service_id, @@ -228,12 +230,14 @@ def get_service_notification_statistics(service_id): @service_blueprint.route("//statistics//") def get_service_notification_statistics_by_day(service_id, start, days): + check_suspicious_id(service_id) 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): + check_suspicious_id(service_id) # Calculate start and end date range end_date = datetime.strptime(start, "%Y-%m-%d") start_date = end_date - timedelta(days=days - 1) @@ -264,6 +268,7 @@ def get_service_statistics_for_specific_days(service_id, start, days=1): def get_service_notification_statistics_by_day_by_user( service_id, user_id, start, days ): + check_suspicious_id(service_id, user_id) return jsonify( data=get_service_statistics_for_specific_days_by_user( service_id, user_id, start, int(days) @@ -323,6 +328,7 @@ def create_service(): @service_blueprint.route("/", methods=["POST"]) def update_service(service_id): + check_suspicious_id(service_id) req_json = request.get_json() fetched_service = dao_fetch_service_by_id(service_id) service_going_live = fetched_service.restricted and not req_json.get( @@ -364,6 +370,8 @@ def update_service(service_id): @service_blueprint.route("//api-key", methods=["POST"]) def create_api_key(service_id=None): + if service_id: + check_suspicious_id(service_id) fetched_service = dao_fetch_service_by_id(service_id=service_id) valid_api_key = api_key_schema.load(request.get_json(), session=db.session) valid_api_key.service = fetched_service @@ -376,6 +384,7 @@ def create_api_key(service_id=None): "//api-key/revoke/", methods=["POST"] ) def revoke_api_key(service_id, api_key_id): + check_suspicious_id(service_id, api_key_id) expire_api_key(service_id=service_id, api_key_id=api_key_id) return jsonify(), 202 @@ -383,6 +392,10 @@ def revoke_api_key(service_id, api_key_id): @service_blueprint.route("//api-keys", methods=["GET"]) @service_blueprint.route("//api-keys/", methods=["GET"]) def get_api_keys(service_id, key_id=None): + if key_id: + check_suspicious_id(service_id, key_id) + else: + check_suspicious_id(service_id) dao_fetch_service_by_id(service_id=service_id) try: @@ -399,12 +412,14 @@ def get_api_keys(service_id, key_id=None): @service_blueprint.route("//users", methods=["GET"]) def get_users_for_service(service_id): + check_suspicious_id(service_id) fetched = dao_fetch_service_by_id(service_id) return jsonify(data=[x.serialize() for x in fetched.users]) @service_blueprint.route("//users/", methods=["POST"]) def add_user_to_service(service_id, user_id): + check_suspicious_id(service_id, user_id) service = dao_fetch_service_by_id(service_id) user = get_user_by_id(user_id=user_id) if user in service.users: @@ -428,6 +443,7 @@ def add_user_to_service(service_id, user_id): @service_blueprint.route("//users/", methods=["DELETE"]) def remove_user_from_service(service_id, user_id): + check_suspicious_id(service_id, user_id) service = dao_fetch_service_by_id(service_id) user = get_user_by_id(user_id=user_id) if user not in service.users: @@ -447,6 +463,7 @@ def remove_user_from_service(service_id, user_id): # tables. This is so product owner can pass stories as done @service_blueprint.route("//history", methods=["GET"]) def get_service_history(service_id): + check_suspicious_id(service_id) from app.models import ApiKey, Service, TemplateHistory from app.schemas import ( api_key_history_schema, @@ -496,6 +513,7 @@ def get_service_history(service_id): @service_blueprint.route("//notifications", methods=["GET", "POST"]) def get_all_notifications_for_service(service_id): + check_suspicious_id(service_id) current_app.logger.debug("enter get_all_notifications_for_service") if request.method == "GET": data = notifications_filter_schema.load(request.args) @@ -625,6 +643,7 @@ def get_all_notifications_for_service(service_id): "//notifications/", methods=["GET"] ) def get_notification_for_service(service_id, notification_id): + check_suspicious_id(service_id, notification_id) notification = notifications_dao.get_notification_with_personalisation( service_id, notification_id, @@ -640,6 +659,7 @@ def get_notification_for_service(service_id, notification_id): @service_blueprint.route("//notifications/monthly", methods=["GET"]) def get_monthly_notification_stats(service_id): + check_suspicious_id(service_id) # check service_id validity dao_fetch_service_by_id(service_id) @@ -672,6 +692,7 @@ def get_monthly_notification_stats(service_id): "//notifications//monthly", methods=["GET"] ) def get_monthly_notification_stats_by_user(service_id, user_id): + check_suspicious_id(service_id, user_id) # check service_id validity dao_fetch_service_by_id(service_id) # user = get_user_by_id(user_id=user_id) @@ -705,6 +726,7 @@ def get_monthly_notification_stats_by_user(service_id, user_id): "//notifications//month", methods=["GET"] ) def get_single_month_notification_stats_by_user(service_id, user_id): + check_suspicious_id(service_id, user_id) # check service_id validity dao_fetch_service_by_id(service_id) @@ -734,6 +756,7 @@ def get_single_month_notification_stats_by_user(service_id, user_id): @service_blueprint.route("//notifications/month", methods=["GET"]) def get_single_month_notification_stats_for_service(service_id): + check_suspicious_id(service_id) # check service_id validity dao_fetch_service_by_id(service_id) @@ -757,6 +780,7 @@ def get_single_month_notification_stats_for_service(service_id): def get_detailed_service(service_id, today_only=False): + check_suspicious_id(service_id) service = dao_fetch_service_by_id(service_id) service.statistics = get_service_statistics(service_id, today_only) @@ -764,6 +788,7 @@ def get_detailed_service(service_id, today_only=False): def get_service_statistics(service_id, today_only, limit_days=7): + check_suspicious_id(service_id) # today_only flag is used by the send page to work out if the service will exceed their daily usage by sending a job if today_only: stats = dao_fetch_todays_stats_for_service(service_id) @@ -810,6 +835,7 @@ def get_detailed_services( @service_blueprint.route("//guest-list", methods=["GET"]) def get_guest_list(service_id): + check_suspicious_id(service_id) from app.enums import RecipientType service = dao_fetch_service_by_id(service_id) @@ -834,6 +860,7 @@ def get_guest_list(service_id): @service_blueprint.route("//guest-list", methods=["PUT"]) def update_guest_list(service_id): + check_suspicious_id(service_id) # doesn't commit so if there are any errors, we preserve old values in db dao_remove_service_guest_list(service_id) try: @@ -850,6 +877,7 @@ def update_guest_list(service_id): @service_blueprint.route("//archive", methods=["POST"]) def archive_service(service_id): + check_suspicious_id(service_id) """ When a service is archived the service is made inactive, templates are archived and api keys are revoked. There is no coming back from this operation. @@ -866,6 +894,7 @@ def archive_service(service_id): @service_blueprint.route("//suspend", methods=["POST"]) def suspend_service(service_id): + check_suspicious_id(service_id) """ Suspending a service will mark the service as inactive and revoke API keys. :param service_id: @@ -881,6 +910,7 @@ def suspend_service(service_id): @service_blueprint.route("//resume", methods=["POST"]) def resume_service(service_id): + check_suspicious_id(service_id) """ Resuming a service that has been suspended will mark the service as active. The service will need to re-create API keys @@ -899,6 +929,7 @@ def resume_service(service_id): "//notifications/templates_usage/monthly", methods=["GET"] ) def get_monthly_template_usage(service_id): + check_suspicious_id(service_id) try: start_date, end_date = get_calendar_year(int(request.args.get("year", "NaN"))) data = fetch_monthly_template_usage_for_service( @@ -924,12 +955,14 @@ def get_monthly_template_usage(service_id): @service_blueprint.route("//send-notification", methods=["POST"]) def create_one_off_notification(service_id): + check_suspicious_id(service_id) resp = send_one_off_notification(service_id, request.get_json()) return jsonify(resp), 201 @service_blueprint.route("//email-reply-to", methods=["GET"]) def get_email_reply_to_addresses(service_id): + check_suspicious_id(service_id) result = dao_get_reply_to_by_service_id(service_id) return jsonify([i.serialize() for i in result]), 200 @@ -938,12 +971,14 @@ def get_email_reply_to_addresses(service_id): "//email-reply-to/", methods=["GET"] ) def get_email_reply_to_address(service_id, reply_to_id): + check_suspicious_id(service_id, reply_to_id) result = dao_get_reply_to_by_id(service_id=service_id, reply_to_id=reply_to_id) return jsonify(result.serialize()), 200 @service_blueprint.route("//email-reply-to/verify", methods=["POST"]) def verify_reply_to_email_address(service_id): + check_suspicious_id(service_id) email_address = email_data_request_schema.load(request.get_json()) check_if_reply_to_address_already_in_use(service_id, email_address["email"]) @@ -970,6 +1005,7 @@ def verify_reply_to_email_address(service_id): @service_blueprint.route("//email-reply-to", methods=["POST"]) def add_service_reply_to_email_address(service_id): + check_suspicious_id(service_id) # validate the service exists, throws ResultNotFound exception. dao_fetch_service_by_id(service_id) form = validate(request.get_json(), add_service_email_reply_to_request) @@ -986,6 +1022,7 @@ def add_service_reply_to_email_address(service_id): "//email-reply-to/", methods=["POST"] ) def update_service_reply_to_email_address(service_id, reply_to_email_id): + check_suspicious_id(service_id, reply_to_email_id) # validate the service exists, throws ResultNotFound exception. dao_fetch_service_by_id(service_id) form = validate(request.get_json(), add_service_email_reply_to_request) @@ -1003,6 +1040,7 @@ def update_service_reply_to_email_address(service_id, reply_to_email_id): methods=["POST"], ) def delete_service_reply_to_email_address(service_id, reply_to_email_id): + check_suspicious_id(service_id, reply_to_email_id) archived_reply_to = archive_reply_to_email_address(service_id, reply_to_email_id) return jsonify(data=archived_reply_to.serialize()), 200 @@ -1010,6 +1048,7 @@ def delete_service_reply_to_email_address(service_id, reply_to_email_id): @service_blueprint.route("//sms-sender", methods=["POST"]) def add_service_sms_sender(service_id): + check_suspicious_id(service_id) dao_fetch_service_by_id(service_id) form = validate(request.get_json(), add_service_sms_sender_request) inbound_number_id = form.get("inbound_number_id", None) @@ -1046,6 +1085,7 @@ def add_service_sms_sender(service_id): "//sms-sender/", methods=["POST"] ) def update_service_sms_sender(service_id, sms_sender_id): + check_suspicious_id(service_id, sms_sender_id) form = validate(request.get_json(), add_service_sms_sender_request) sms_sender_to_update = dao_get_service_sms_senders_by_id( @@ -1073,6 +1113,7 @@ def update_service_sms_sender(service_id, sms_sender_id): "//sms-sender//archive", methods=["POST"] ) def delete_service_sms_sender(service_id, sms_sender_id): + check_suspicious_id(service_id, sms_sender_id) sms_sender = archive_sms_sender(service_id, sms_sender_id) return jsonify(data=sms_sender.serialize()), 200 @@ -1082,6 +1123,8 @@ def delete_service_sms_sender(service_id, sms_sender_id): "//sms-sender/", methods=["GET"] ) def get_service_sms_sender_by_id(service_id, sms_sender_id): + check_suspicious_id(service_id, sms_sender_id) + sms_sender = dao_get_service_sms_senders_by_id( service_id=service_id, service_sms_sender_id=sms_sender_id ) @@ -1090,18 +1133,22 @@ def get_service_sms_sender_by_id(service_id, sms_sender_id): @service_blueprint.route("//sms-sender", methods=["GET"]) def get_service_sms_senders_for_service(service_id): + check_suspicious_id(service_id) + sms_senders = dao_get_sms_senders_by_service_id(service_id=service_id) return jsonify([sms_sender.serialize() for sms_sender in sms_senders]), 200 @service_blueprint.route("//organization", methods=["GET"]) def get_organization_for_service(service_id): + check_suspicious_id(service_id) organization = dao_get_organization_by_service_id(service_id=service_id) return jsonify(organization.serialize() if organization else {}), 200 @service_blueprint.route("//data-retention", methods=["GET"]) def get_data_retention_for_service(service_id): + check_suspicious_id(service_id) data_retention_list = fetch_service_data_retention(service_id) return ( jsonify([data_retention.serialize() for data_retention in data_retention_list]), @@ -1114,6 +1161,7 @@ def get_data_retention_for_service(service_id): methods=["GET"], ) def get_data_retention_for_service_notification_type(service_id, notification_type): + check_suspicious_id(service_id) data_retention = fetch_service_data_retention_by_notification_type( service_id, notification_type ) @@ -1124,12 +1172,14 @@ def get_data_retention_for_service_notification_type(service_id, notification_ty "//data-retention/", methods=["GET"] ) def get_data_retention_for_service_by_id(service_id, data_retention_id): + check_suspicious_id(service_id, data_retention_id) data_retention = fetch_service_data_retention_by_id(service_id, data_retention_id) return jsonify(data_retention.serialize() if data_retention else {}), 200 @service_blueprint.route("//data-retention", methods=["POST"]) def create_service_data_retention(service_id): + check_suspicious_id(service_id) form = validate(request.get_json(), add_service_data_retention_request) try: new_data_retention = insert_service_data_retention( @@ -1152,6 +1202,7 @@ def create_service_data_retention(service_id): "//data-retention/", methods=["POST"] ) def modify_service_data_retention(service_id, data_retention_id): + check_suspicious_id(service_id, data_retention_id) form = validate(request.get_json(), update_service_data_retention_request) update_count = update_service_data_retention( @@ -1252,5 +1303,6 @@ def check_if_reply_to_address_already_in_use(service_id, email_address): @service_blueprint.route("//notification-count", methods=["GET"]) def get_notification_count_for_service_id(service_id): + check_suspicious_id(service_id) count = dao_get_notification_count_for_service(service_id=service_id) return jsonify(count=count), 200 diff --git a/app/service_invite/rest.py b/app/service_invite/rest.py index 9dc862ae1..0e36c00f1 100644 --- a/app/service_invite/rest.py +++ b/app/service_invite/rest.py @@ -25,7 +25,7 @@ from app.notifications.process_notifications import ( send_notification_to_queue, ) from app.schemas import InvitedUserSchema -from app.utils import utc_now +from app.utils import check_suspicious_id, utc_now from notifications_utils.url_safe_token import check_token, generate_token service_invite = Blueprint("service_invite", __name__) @@ -121,6 +121,7 @@ def create_invited_user(service_id): @service_invite.route("/service//invite/expired", methods=["GET"]) def get_expired_invited_users_by_service(service_id): + check_suspicious_id(service_id) expired_invited_users = get_expired_invited_users_for_service(service_id) return ( jsonify( @@ -134,6 +135,7 @@ def get_expired_invited_users_by_service(service_id): @service_invite.route("/service//invite", methods=["GET"]) def get_invited_users_by_service(service_id): + check_suspicious_id(service_id) invited_users = get_invited_users_for_service(service_id) return ( jsonify( @@ -145,6 +147,7 @@ def get_invited_users_by_service(service_id): @service_invite.route("/service//invite/", methods=["GET"]) def get_invited_user_by_service(service_id, invited_user_id): + check_suspicious_id(service_id, invited_user_id) invited_user = get_invited_user_by_service_and_id(service_id, invited_user_id) return jsonify(data=InvitedUserSchema(session=db.session).dump(invited_user)), 200 @@ -153,6 +156,7 @@ def get_invited_user_by_service(service_id, invited_user_id): "/service//invite/", methods=["POST"] ) def update_invited_user(service_id, invited_user_id): + check_suspicious_id(service_id, invited_user_id) fetched = get_invited_user_by_service_and_id( service_id=service_id, invited_user_id=invited_user_id ) @@ -168,6 +172,7 @@ def update_invited_user(service_id, invited_user_id): "/service//invite//resend", methods=["POST"] ) def resend_service_invite(service_id, invited_user_id): + check_suspicious_id(service_id, invited_user_id) """Resend an expired invite. This resets the invited user's created date and status to make it a new invite, and @@ -228,6 +233,7 @@ def invited_user_url(invited_user_id, invite_link_host=None): @service_invite.route("/invite/service/", methods=["GET"]) def get_invited_user(invited_user_id): + check_suspicious_id(invited_user_id) invited_user = get_invited_user_by_id(invited_user_id) return jsonify(data=InvitedUserSchema(session=db.session).dump(invited_user)), 200 diff --git a/app/template/rest.py b/app/template/rest.py index c198bc618..65faecef9 100644 --- a/app/template/rest.py +++ b/app/template/rest.py @@ -26,7 +26,7 @@ from app.template.template_schemas import ( post_create_template_schema, post_update_template_schema, ) -from app.utils import get_public_notify_type_text +from app.utils import check_suspicious_id, get_public_notify_type_text from notifications_utils import SMS_CHAR_COUNT_LIMIT from notifications_utils.template import SMSMessageTemplate @@ -61,6 +61,7 @@ def validate_parent_folder(template_json): @template_blueprint.route("", methods=["POST"]) def create_template(service_id): + check_suspicious_id(service_id) fetched_service = dao_fetch_service_by_id(service_id=service_id) # permissions needs to be placed here otherwise marshmallow will interfere with versioning permissions = [p.permission for p in fetched_service.permissions] @@ -96,6 +97,7 @@ def create_template(service_id): @template_blueprint.route("/", methods=["POST"]) def update_template(service_id, template_id): + check_suspicious_id(service_id, template_id) fetched_template = dao_get_template_by_id_and_service_id( template_id=template_id, service_id=service_id ) @@ -146,6 +148,7 @@ def update_template(service_id, template_id): @template_blueprint.route("", methods=["GET"]) def get_all_templates_for_service(service_id): + check_suspicious_id(service_id) templates = dao_get_all_templates_for_service(service_id=service_id) if str(request.args.get("detailed", True)) == "True": data = template_schema.dump(templates, many=True) @@ -156,6 +159,7 @@ def get_all_templates_for_service(service_id): @template_blueprint.route("/", methods=["GET"]) def get_template_by_id_and_service_id(service_id, template_id): + check_suspicious_id(service_id, template_id) fetched_template = dao_get_template_by_id_and_service_id( template_id=template_id, service_id=service_id ) @@ -165,6 +169,7 @@ def get_template_by_id_and_service_id(service_id, template_id): @template_blueprint.route("//preview", methods=["GET"]) def preview_template_by_id_and_service_id(service_id, template_id): + check_suspicious_id(service_id, template_id) fetched_template = dao_get_template_by_id_and_service_id( template_id=template_id, service_id=service_id ) @@ -193,6 +198,7 @@ def preview_template_by_id_and_service_id(service_id, template_id): @template_blueprint.route("//version/") def get_template_version(service_id, template_id, version): + check_suspicious_id(service_id, template_id) data = template_history_schema.dump( dao_get_template_by_id_and_service_id( template_id=template_id, service_id=service_id, version=version @@ -203,6 +209,7 @@ def get_template_version(service_id, template_id, version): @template_blueprint.route("//versions") def get_template_versions(service_id, template_id): + check_suspicious_id(service_id, template_id) data = template_history_schema.dump( dao_get_template_versions(service_id=service_id, template_id=template_id), many=True, diff --git a/app/template_folder/rest.py b/app/template_folder/rest.py index 4f2073712..846cb81ab 100644 --- a/app/template_folder/rest.py +++ b/app/template_folder/rest.py @@ -20,6 +20,7 @@ from app.template_folder.template_folder_schema import ( post_move_template_folder_schema, post_update_template_folder_schema, ) +from app.utils import check_suspicious_id template_folder_blueprint = Blueprint( "template_folder", __name__, url_prefix="/service//template-folder" @@ -37,6 +38,7 @@ def handle_integrity_error(exc): @template_folder_blueprint.route("", methods=["GET"]) def get_template_folders_for_service(service_id): + check_suspicious_id(service_id) service = dao_fetch_service_by_id(service_id) template_folders = [o.serialize() for o in service.all_template_folders] @@ -45,6 +47,7 @@ def get_template_folders_for_service(service_id): @template_folder_blueprint.route("", methods=["POST"]) def create_template_folder(service_id): + check_suspicious_id(service_id) data = request.get_json() validate(data, post_create_template_folder_schema) @@ -72,6 +75,7 @@ def create_template_folder(service_id): @template_folder_blueprint.route("/", methods=["POST"]) def update_template_folder(service_id, template_folder_id): + check_suspicious_id(service_id, template_folder_id) data = request.get_json() validate(data, post_update_template_folder_schema) @@ -93,6 +97,7 @@ def update_template_folder(service_id, template_folder_id): @template_folder_blueprint.route("/", methods=["DELETE"]) def delete_template_folder(service_id, template_folder_id): + check_suspicious_id(service_id, template_folder_id) template_folder = dao_get_template_folder_by_id_and_service_id( template_folder_id, service_id ) @@ -112,6 +117,8 @@ def delete_template_folder(service_id, template_folder_id): ) @autocommit def move_to_template_folder(service_id, target_template_folder_id=None): + check_suspicious_id(service_id, target_template_folder_id) + data = request.get_json() validate(data, post_move_template_folder_schema) diff --git a/app/template_statistics/rest.py b/app/template_statistics/rest.py index cf4482caa..314373e73 100644 --- a/app/template_statistics/rest.py +++ b/app/template_statistics/rest.py @@ -6,7 +6,7 @@ from app.dao.fact_notification_status_dao import ( from app.dao.notifications_dao import dao_get_last_date_template_was_used from app.dao.templates_dao import dao_get_template_by_id_and_service_id from app.errors import InvalidRequest, register_errors -from app.utils import DATETIME_FORMAT +from app.utils import DATETIME_FORMAT, check_suspicious_id template_statistics = Blueprint( "template_statistics", @@ -19,6 +19,7 @@ register_errors(template_statistics) @template_statistics.route("") def get_template_statistics_for_service_by_day(service_id): + check_suspicious_id(service_id) whole_days = request.args.get("whole_days", request.args.get("limit_days", "")) try: whole_days = int(whole_days) @@ -56,6 +57,7 @@ def get_template_statistics_for_service_by_day(service_id): @template_statistics.route("/last-used/") def get_last_used_datetime_for_template(service_id, template_id): + check_suspicious_id(service_id, template_id) # Check the template and service exist dao_get_template_by_id_and_service_id(template_id, service_id) diff --git a/app/upload/rest.py b/app/upload/rest.py index 7ac3f07ac..a11b0bf0a 100644 --- a/app/upload/rest.py +++ b/app/upload/rest.py @@ -4,7 +4,7 @@ from app.dao.fact_notification_status_dao import fetch_notification_statuses_for from app.dao.jobs_dao import dao_get_notification_outcomes_for_job from app.dao.uploads_dao import dao_get_uploads_by_service_id from app.errors import register_errors -from app.utils import midnight_n_days_ago, pagination_links +from app.utils import check_suspicious_id, midnight_n_days_ago, pagination_links upload_blueprint = Blueprint( "upload", __name__, url_prefix="/service//upload" @@ -15,6 +15,7 @@ register_errors(upload_blueprint) @upload_blueprint.route("", methods=["GET"]) def get_uploads_by_service(service_id): + check_suspicious_id(service_id) return jsonify( **get_paginated_uploads( service_id, diff --git a/app/user/rest.py b/app/user/rest.py index da86521ff..a1eb93eaa 100644 --- a/app/user/rest.py +++ b/app/user/rest.py @@ -53,7 +53,13 @@ from app.user.users_schema import ( post_verify_code_schema, post_verify_webauthn_schema, ) -from app.utils import debug_not_production, hilite, url_with_token, utc_now +from app.utils import ( + check_suspicious_id, + debug_not_production, + hilite, + url_with_token, + utc_now, +) from notifications_utils.recipients import is_us_phone_number, use_numeric_sender user_blueprint = Blueprint("user", __name__) @@ -95,6 +101,7 @@ def create_user(): @user_blueprint.route("/", methods=["POST"]) def update_user_attribute(user_id): + check_suspicious_id(user_id) user_to_update = get_user_by_id(user_id=user_id) req_json = request.get_json() if "updated_by" in req_json: @@ -159,6 +166,7 @@ def get_sms_reply_to_for_notify_service(recipient, template): @user_blueprint.route("//archive", methods=["POST"]) def archive_user(user_id): + check_suspicious_id(user_id) user = get_user_by_id(user_id) dao_archive_user(user) @@ -167,6 +175,7 @@ def archive_user(user_id): @user_blueprint.route("//activate", methods=["POST"]) def activate_user(user_id): + check_suspicious_id(user_id) user = get_user_by_id(user_id=user_id) if user.state == "active": raise InvalidRequest("User already active", status_code=400) @@ -178,6 +187,7 @@ def activate_user(user_id): @user_blueprint.route("//deactivate", methods=["POST"]) def deactivate_user(user_id): + check_suspicious_id(user_id) user = get_user_by_id(user_id=user_id) if user.state == "pending": raise InvalidRequest("User already inactive", status_code=400) @@ -189,6 +199,7 @@ def deactivate_user(user_id): @user_blueprint.route("//reset-failed-login-count", methods=["POST"]) def user_reset_failed_login_count(user_id): + check_suspicious_id(user_id) user_to_update = get_user_by_id(user_id=user_id) reset_failed_login_count(user_to_update) return jsonify(data=user_to_update.serialize()), 200 @@ -196,6 +207,7 @@ def user_reset_failed_login_count(user_id): @user_blueprint.route("//verify/password", methods=["POST"]) def verify_user_password(user_id): + check_suspicious_id(user_id) user_to_verify = get_user_by_id(user_id=user_id) try: @@ -217,6 +229,7 @@ def verify_user_password(user_id): @user_blueprint.route("//verify/code", methods=["POST"]) def verify_user_code(user_id): + check_suspicious_id(user_id) data = request.get_json() validate(data, post_verify_code_schema) @@ -251,6 +264,7 @@ def verify_user_code(user_id): @user_blueprint.route("//complete/webauthn-login", methods=["POST"]) @user_blueprint.route("//verify/webauthn-login", methods=["POST"]) def complete_login_after_webauthn_authentication_attempt(user_id): + check_suspicious_id(user_id) """ complete login after a webauthn authentication. There's nothing webauthn specific in this code but the sms/email flows do this as part of `verify_user_code` above and this is the equivalent spot in the @@ -283,6 +297,7 @@ def complete_login_after_webauthn_authentication_attempt(user_id): @user_blueprint.route("//-code", methods=["POST"]) def send_user_2fa_code(user_id, code_type): + check_suspicious_id(user_id) user_to_send_to = get_user_by_id(user_id=user_id) if count_user_verify_codes(user_to_send_to) >= current_app.config.get( @@ -386,6 +401,7 @@ def create_2fa_code( @user_blueprint.route("//change-email-verification", methods=["POST"]) def send_user_confirm_new_email(user_id): + check_suspicious_id(user_id) user_to_send_to = get_user_by_id(user_id=user_id) email = email_data_request_schema.load(request.get_json()) @@ -425,6 +441,7 @@ def send_user_confirm_new_email(user_id): @user_blueprint.route("//email-verification", methods=["POST"]) def send_new_user_email_verification(user_id): + check_suspicious_id(user_id) current_app.logger.info("Sending email verification for user {}".format(user_id)) request_json = request.get_json() @@ -479,6 +496,7 @@ def send_new_user_email_verification(user_id): @user_blueprint.route("//email-already-registered", methods=["POST"]) def send_already_registered_email(user_id): + check_suspicious_id(user_id) current_app.logger.info("Email already registered for user {}".format(user_id)) to = email_data_request_schema.load(request.get_json()) @@ -528,6 +546,7 @@ def send_already_registered_email(user_id): @user_blueprint.route("/", methods=["GET"]) @user_blueprint.route("", methods=["GET"]) def get_user(user_id=None): + check_suspicious_id(user_id) users = get_user_by_id(user_id=user_id) result = ( [x.serialize() for x in users] if isinstance(users, list) else users.serialize() @@ -539,6 +558,7 @@ def get_user(user_id=None): "//service//permission", methods=["POST"] ) def set_permissions(user_id, service_id): + check_suspicious_id(user_id, service_id) # TODO fix security hole, how do we verify that the user # who is making this request has permission to make the request. service_user = dao_get_service_user(user_id, service_id) @@ -651,6 +671,7 @@ def report_all_users(): @user_blueprint.route("//organizations-and-services", methods=["GET"]) def get_organizations_and_services_for_user(user_id): + check_suspicious_id(user_id) user = get_user_and_accounts(user_id) data = get_orgs_and_services(user) return jsonify(data) diff --git a/app/utils.py b/app/utils.py index 07c2571b1..479ef6503 100644 --- a/app/utils.py +++ b/app/utils.py @@ -1,7 +1,8 @@ import os +import re from datetime import datetime, timedelta, timezone -from flask import current_app, url_for +from flask import abort, current_app, url_for from sqlalchemy import func from notifications_utils.template import HTMLEmailTemplate, SMSMessageTemplate @@ -158,3 +159,52 @@ def emit_job_update_summary(job): }, room=f"job-{job.id}", ) + + +def is_suspicious_input(input_str): + if not isinstance(input_str, str): + return False + + pattern = re.compile( + r""" + (?i) # case insensite + \b # word boundary + ( # start of group for SQL keywords + OR # match SQL keyword OR + |AND + |UNION + |SELECT + |DROP + |INSERT + |UPDATE + |DELETE + |EXEC + |TRUNCATE + |CREATE + |ALTER + |-- # match SQL single-line comment + |/\* # match SQL multi-line comment + |\bpg_sleep\b # Match PostgreSQL 'pg_sleep' function + + |\bsleep\b # Match SQL Server 'sleep' function + ) # End SQL keywords and function group + | # OR operator to include an alternate pattern + [';]{2,} # Match two or more consecutive single quotes or semi-colons + """, + re.VERBOSE, + ) + return bool(re.search(pattern, input_str)) + + +def is_valid_id(id): + if not isinstance(id, str): + return True + return bool(re.match(r"^[a-zA-Z0-9_-]{1,50}$", id)) + + +def check_suspicious_id(*args): + for id in args: + if not is_valid_id(id): + abort(403) + if is_suspicious_input(id): + abort(403) diff --git a/app/webauthn/rest.py b/app/webauthn/rest.py index 1dba333e7..e5827b65a 100644 --- a/app/webauthn/rest.py +++ b/app/webauthn/rest.py @@ -9,6 +9,7 @@ from app.dao.webauthn_credential_dao import ( ) from app.errors import InvalidRequest, register_errors from app.schema_validation import validate +from app.utils import check_suspicious_id from app.webauthn.webauthn_schema import ( post_create_webauthn_credential_schema, post_update_webauthn_credential_schema, @@ -28,6 +29,7 @@ def get_webauthn_credentials(user_id): @webauthn_blueprint.route("", methods=["POST"]) def create_webauthn_credential(user_id): + check_suspicious_id(user_id) data = request.get_json() validate(data, post_create_webauthn_credential_schema) webauthn_credential = dao_create_webauthn_credential( @@ -42,6 +44,7 @@ def create_webauthn_credential(user_id): @webauthn_blueprint.route("/", methods=["POST"]) def update_webauthn_credential(user_id, webauthn_credential_id): + check_suspicious_id(user_id, webauthn_credential_id) data = request.get_json() validate(data, post_update_webauthn_credential_schema) @@ -56,6 +59,7 @@ def update_webauthn_credential(user_id, webauthn_credential_id): @webauthn_blueprint.route("/", methods=["DELETE"]) def delete_webauthn_credential(user_id, webauthn_credential_id): + check_suspicious_id(user_id, webauthn_credential_id) webauthn_credential = dao_get_webauthn_credential_by_user_and_id( user_id, webauthn_credential_id ) diff --git a/tests/app/job/test_rest.py b/tests/app/job/test_rest.py index dbba6d729..f65ce2458 100644 --- a/tests/app/job/test_rest.py +++ b/tests/app/job/test_rest.py @@ -5,7 +5,6 @@ from unittest.mock import ANY from zoneinfo import ZoneInfo import pytest -import werkzeug from freezegun import freeze_time import app.celery.tasks @@ -17,7 +16,6 @@ from app.enums import ( NotificationType, TemplateType, ) -from app.job.rest import check_suspicious_id, is_suspicious_input, is_valid_id from app.utils import utc_now from tests import create_admin_authorization_header from tests.app.db import ( @@ -588,31 +586,6 @@ def test_get_all_notifications_for_job_returns_correct_format( assert resp["notifications"][0]["status"] == sample_notification_with_job.status -def test_is_valid_id(sample_job): - returnVal = is_valid_id(sample_job.service_id) - assert returnVal is True - - returnVal = is_valid_id("abc pgsleep(1)") - assert returnVal is False - - -def test_check_suspicious_id(sample_job): - # This should be good - check_suspicious_id(sample_job.id, sample_job.service_id) - - # This should be bad - with pytest.raises(werkzeug.exceptions.Forbidden): - check_suspicious_id(sample_job.id, "what is this???") - - -def test_is_suspicious_input(sample_job): - returnVal = is_suspicious_input(sample_job.id) - assert returnVal is False - - returnVal = is_suspicious_input("1 OR pg_sleep(1)") - assert returnVal is True - - def test_get_notification_count_for_job_id(admin_request, mocker, sample_job): mock_dao = mocker.patch( "app.job.rest.dao_get_notification_count_for_job_id", return_value=3 diff --git a/tests/app/test_utils.py b/tests/app/test_utils.py index 024c024a8..9969f1b56 100644 --- a/tests/app/test_utils.py +++ b/tests/app/test_utils.py @@ -1,13 +1,17 @@ from datetime import date, datetime import pytest +import werkzeug from freezegun import freeze_time from app.enums import ServicePermissionType, TemplateType from app.utils import ( + check_suspicious_id, get_midnight_in_utc, get_public_notify_type_text, get_template_instance, + is_suspicious_input, + is_valid_id, midnight_n_days_ago, ) from notifications_utils.template import HTMLEmailTemplate, SMSMessageTemplate @@ -141,3 +145,31 @@ def test_get_template_instance_comprehensive(template_type, values): assert isinstance(result, SMSMessageTemplate) else: assert isinstance(result, HTMLEmailTemplate) + + +def test_is_valid_id(sample_job): + returnVal = is_valid_id(sample_job.service_id) + assert returnVal is True + + returnVal = is_valid_id("abc pgsleep(1)") + assert returnVal is False + + +def test_check_suspicious_id(sample_job): + # This should be good + check_suspicious_id(sample_job.id, sample_job.service_id) + + # This should be bad + with pytest.raises(werkzeug.exceptions.Forbidden): + check_suspicious_id(sample_job.id, "what is this???") + + # This should be good + check_suspicious_id(sample_job.id, None) + + +def test_is_suspicious_input(sample_job): + returnVal = is_suspicious_input(sample_job.id) + assert returnVal is False + + returnVal = is_suspicious_input("1 OR pg_sleep(1)") + assert returnVal is True From e41956f55682571ea780c970687b8a1841734f22 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Jun 2025 21:28:09 +0000 Subject: [PATCH 4/6] Bump lxml from 5.4.0 to 6.0.0 Bumps [lxml](https://github.com/lxml/lxml) from 5.4.0 to 6.0.0. - [Release notes](https://github.com/lxml/lxml/releases) - [Changelog](https://github.com/lxml/lxml/blob/master/CHANGES.txt) - [Commits](https://github.com/lxml/lxml/compare/lxml-5.4.0...lxml-6.0.0) --- updated-dependencies: - dependency-name: lxml dependency-version: 6.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- poetry.lock | 214 ++++++++++++++++++------------------------------- pyproject.toml | 2 +- 2 files changed, 79 insertions(+), 137 deletions(-) diff --git a/poetry.lock b/poetry.lock index e020621e8..8bdcef19f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2333,144 +2333,87 @@ testing = ["black", "isort", "pytest (>=6,!=7.0.0)", "pytest-xdist (>=2)", "twin [[package]] name = "lxml" -version = "5.4.0" +version = "6.0.0" description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API." optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" groups = ["main"] files = [ - {file = "lxml-5.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c"}, - {file = "lxml-5.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c"}, - {file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b"}, - {file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b"}, - {file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563"}, - {file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5"}, - {file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776"}, - {file = "lxml-5.4.0-cp310-cp310-win32.whl", hash = "sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7"}, - {file = "lxml-5.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250"}, - {file = "lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9"}, - {file = "lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8"}, - {file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86"}, - {file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056"}, - {file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7"}, - {file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd"}, - {file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751"}, - {file = "lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4"}, - {file = "lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539"}, - {file = "lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4"}, - {file = "lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7"}, - {file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079"}, - {file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20"}, - {file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8"}, - {file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f"}, - {file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc"}, - {file = "lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f"}, - {file = "lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2"}, - {file = "lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0"}, - {file = "lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8"}, - {file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982"}, - {file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61"}, - {file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54"}, - {file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b"}, - {file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a"}, - {file = "lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82"}, - {file = "lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f"}, - {file = "lxml-5.4.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7be701c24e7f843e6788353c055d806e8bd8466b52907bafe5d13ec6a6dbaecd"}, - {file = "lxml-5.4.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb54f7c6bafaa808f27166569b1511fc42701a7713858dddc08afdde9746849e"}, - {file = "lxml-5.4.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97dac543661e84a284502e0cf8a67b5c711b0ad5fb661d1bd505c02f8cf716d7"}, - {file = "lxml-5.4.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:c70e93fba207106cb16bf852e421c37bbded92acd5964390aad07cb50d60f5cf"}, - {file = "lxml-5.4.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9c886b481aefdf818ad44846145f6eaf373a20d200b5ce1a5c8e1bc2d8745410"}, - {file = "lxml-5.4.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:fa0e294046de09acd6146be0ed6727d1f42ded4ce3ea1e9a19c11b6774eea27c"}, - {file = "lxml-5.4.0-cp36-cp36m-win32.whl", hash = "sha256:61c7bbf432f09ee44b1ccaa24896d21075e533cd01477966a5ff5a71d88b2f56"}, - {file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"}, - {file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"}, - {file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"}, - {file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"}, - {file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"}, - {file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"}, - {file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"}, - {file = "lxml-5.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eaf24066ad0b30917186420d51e2e3edf4b0e2ea68d8cd885b14dc8afdcf6556"}, - {file = "lxml-5.4.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b31a3a77501d86d8ade128abb01082724c0dfd9524f542f2f07d693c9f1175f"}, - {file = "lxml-5.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e108352e203c7afd0eb91d782582f00a0b16a948d204d4dec8565024fafeea5"}, - {file = "lxml-5.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a11a96c3b3f7551c8a8109aa65e8594e551d5a84c76bf950da33d0fb6dfafab7"}, - {file = "lxml-5.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:ca755eebf0d9e62d6cb013f1261e510317a41bf4650f22963474a663fdfe02aa"}, - {file = "lxml-5.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:4cd915c0fb1bed47b5e6d6edd424ac25856252f09120e3e8ba5154b6b921860e"}, - {file = "lxml-5.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:226046e386556a45ebc787871d6d2467b32c37ce76c2680f5c608e25823ffc84"}, - {file = "lxml-5.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b108134b9667bcd71236c5a02aad5ddd073e372fb5d48ea74853e009fe38acb6"}, - {file = "lxml-5.4.0-cp38-cp38-win32.whl", hash = "sha256:1320091caa89805df7dcb9e908add28166113dcd062590668514dbd510798c88"}, - {file = "lxml-5.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:073eb6dcdf1f587d9b88c8c93528b57eccda40209cf9be549d469b942b41d70b"}, - {file = "lxml-5.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bda3ea44c39eb74e2488297bb39d47186ed01342f0022c8ff407c250ac3f498e"}, - {file = "lxml-5.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ceaf423b50ecfc23ca00b7f50b64baba85fb3fb91c53e2c9d00bc86150c7e40"}, - {file = "lxml-5.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664cdc733bc87449fe781dbb1f309090966c11cc0c0cd7b84af956a02a8a4729"}, - {file = "lxml-5.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67ed8a40665b84d161bae3181aa2763beea3747f748bca5874b4af4d75998f87"}, - {file = "lxml-5.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4a3bd174cc9cdaa1afbc4620c049038b441d6ba07629d89a83b408e54c35cd"}, - {file = "lxml-5.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:b0989737a3ba6cf2a16efb857fb0dfa20bc5c542737fddb6d893fde48be45433"}, - {file = "lxml-5.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:dc0af80267edc68adf85f2a5d9be1cdf062f973db6790c1d065e45025fa26140"}, - {file = "lxml-5.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:639978bccb04c42677db43c79bdaa23785dc7f9b83bfd87570da8207872f1ce5"}, - {file = "lxml-5.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a99d86351f9c15e4a901fc56404b485b1462039db59288b203f8c629260a142"}, - {file = "lxml-5.4.0-cp39-cp39-win32.whl", hash = "sha256:3e6d5557989cdc3ebb5302bbdc42b439733a841891762ded9514e74f60319ad6"}, - {file = "lxml-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8c9b7f16b63e65bbba889acb436a1034a82d34fa09752d754f88d708eca80e1"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571"}, - {file = "lxml-5.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5f11a1526ebd0dee85e7b1e39e39a0cc0d9d03fb527f56d8457f6df48a10dc0c"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48b4afaf38bf79109bb060d9016fad014a9a48fb244e11b94f74ae366a64d252"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de6f6bb8a7840c7bf216fb83eec4e2f79f7325eca8858167b68708b929ab2172"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5cca36a194a4eb4e2ed6be36923d3cffd03dcdf477515dea687185506583d4c9"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b7c86884ad23d61b025989d99bfdd92a7351de956e01c61307cb87035960bcb1"}, - {file = "lxml-5.4.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:53d9469ab5460402c19553b56c3648746774ecd0681b1b27ea74d5d8a3ef5590"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:56dbdbab0551532bb26c19c914848d7251d73edb507c3079d6805fa8bba5b706"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14479c2ad1cb08b62bb941ba8e0e05938524ee3c3114644df905d2331c76cd57"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32697d2ea994e0db19c1df9e40275ffe84973e4232b5c274f47e7c1ec9763cdd"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:24f6df5f24fc3385f622c0c9d63fe34604893bc1a5bdbb2dbf5870f85f9a404a"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:151d6c40bc9db11e960619d2bf2ec5829f0aaffb10b41dcf6ad2ce0f3c0b2325"}, - {file = "lxml-5.4.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4025bf2884ac4370a3243c5aa8d66d3cb9e15d3ddd0af2d796eccc5f0244390e"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9459e6892f59ecea2e2584ee1058f5d8f629446eab52ba2305ae13a32a059530"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47fb24cc0f052f0576ea382872b3fc7e1f7e3028e53299ea751839418ade92a6"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50441c9de951a153c698b9b99992e806b71c1f36d14b154592580ff4a9d0d877"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ab339536aa798b1e17750733663d272038bf28069761d5be57cb4a9b0137b4f8"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9776af1aad5a4b4a1317242ee2bea51da54b2a7b7b48674be736d463c999f37d"}, - {file = "lxml-5.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:63e7968ff83da2eb6fdda967483a7a023aa497d85ad8f05c3ad9b1f2e8c84987"}, - {file = "lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd"}, + {file = "lxml-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:35bc626eec405f745199200ccb5c6b36f202675d204aa29bb52e27ba2b71dea8"}, + {file = "lxml-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:246b40f8a4aec341cbbf52617cad8ab7c888d944bfe12a6abd2b1f6cfb6f6082"}, + {file = "lxml-6.0.0-cp310-cp310-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:2793a627e95d119e9f1e19720730472f5543a6d84c50ea33313ce328d870f2dd"}, + {file = "lxml-6.0.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2030956cf4886b10be9a0285c6802e078ec2391e1dd7ff3eb509c2c95a69b76"}, + {file = "lxml-6.0.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d23854ecf381ab1facc8f353dcd9adeddef3652268ee75297c1164c987c11dc"}, + {file = "lxml-6.0.0-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:43fe5af2d590bf4691531b1d9a2495d7aab2090547eaacd224a3afec95706d76"}, + {file = "lxml-6.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74e748012f8c19b47f7d6321ac929a9a94ee92ef12bc4298c47e8b7219b26541"}, + {file = "lxml-6.0.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:43cfbb7db02b30ad3926e8fceaef260ba2fb7df787e38fa2df890c1ca7966c3b"}, + {file = "lxml-6.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34190a1ec4f1e84af256495436b2d196529c3f2094f0af80202947567fdbf2e7"}, + {file = "lxml-6.0.0-cp310-cp310-win32.whl", hash = "sha256:5967fe415b1920a3877a4195e9a2b779249630ee49ece22021c690320ff07452"}, + {file = "lxml-6.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:f3389924581d9a770c6caa4df4e74b606180869043b9073e2cec324bad6e306e"}, + {file = "lxml-6.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:522fe7abb41309e9543b0d9b8b434f2b630c5fdaf6482bee642b34c8c70079c8"}, + {file = "lxml-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4ee56288d0df919e4aac43b539dd0e34bb55d6a12a6562038e8d6f3ed07f9e36"}, + {file = "lxml-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8dd6dd0e9c1992613ccda2bcb74fc9d49159dbe0f0ca4753f37527749885c25"}, + {file = "lxml-6.0.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:d7ae472f74afcc47320238b5dbfd363aba111a525943c8a34a1b657c6be934c3"}, + {file = "lxml-6.0.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f720a14aa102a38907c6d5030e3d66b3b680c3e6f6bc95473931ea3c00c59967"}, + {file = "lxml-6.0.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c2a5e8d207311a0170aca0eb6b160af91adc29ec121832e4ac151a57743a1e1e"}, + {file = "lxml-6.0.0-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:2dd1cc3ea7e60bfb31ff32cafe07e24839df573a5e7c2d33304082a5019bcd58"}, + {file = "lxml-6.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2cfcf84f1defed7e5798ef4f88aa25fcc52d279be731ce904789aa7ccfb7e8d2"}, + {file = "lxml-6.0.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a52a4704811e2623b0324a18d41ad4b9fabf43ce5ff99b14e40a520e2190c851"}, + {file = "lxml-6.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c16304bba98f48a28ae10e32a8e75c349dd742c45156f297e16eeb1ba9287a1f"}, + {file = "lxml-6.0.0-cp311-cp311-win32.whl", hash = "sha256:f8d19565ae3eb956d84da3ef367aa7def14a2735d05bd275cd54c0301f0d0d6c"}, + {file = "lxml-6.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:b2d71cdefda9424adff9a3607ba5bbfc60ee972d73c21c7e3c19e71037574816"}, + {file = "lxml-6.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:8a2e76efbf8772add72d002d67a4c3d0958638696f541734304c7f28217a9cab"}, + {file = "lxml-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78718d8454a6e928470d511bf8ac93f469283a45c354995f7d19e77292f26108"}, + {file = "lxml-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:84ef591495ffd3f9dcabffd6391db7bb70d7230b5c35ef5148354a134f56f2be"}, + {file = "lxml-6.0.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:2930aa001a3776c3e2601cb8e0a15d21b8270528d89cc308be4843ade546b9ab"}, + {file = "lxml-6.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:390240baeb9f415a82eefc2e13285016f9c8b5ad71ec80574ae8fa9605093cd7"}, + {file = "lxml-6.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca50bd612438258a91b5b3788c6621c1f05c8c478e7951899f492be42defc0da"}, + {file = "lxml-6.0.0-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:c24b8efd9c0f62bad0439283c2c795ef916c5a6b75f03c17799775c7ae3c0c9e"}, + {file = "lxml-6.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afd27d8629ae94c5d863e32ab0e1d5590371d296b87dae0a751fb22bf3685741"}, + {file = "lxml-6.0.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:54c4855eabd9fc29707d30141be99e5cd1102e7d2258d2892314cf4c110726c3"}, + {file = "lxml-6.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:36531f81c8214e293097cd2b7873f178997dae33d3667caaae8bdfb9666b76c0"}, + {file = "lxml-6.0.0-cp312-cp312-win32.whl", hash = "sha256:690b20e3388a7ec98e899fd54c924e50ba6693874aa65ef9cb53de7f7de9d64a"}, + {file = "lxml-6.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:310b719b695b3dd442cdfbbe64936b2f2e231bb91d998e99e6f0daf991a3eba3"}, + {file = "lxml-6.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:8cb26f51c82d77483cdcd2b4a53cda55bbee29b3c2f3ddeb47182a2a9064e4eb"}, + {file = "lxml-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6da7cd4f405fd7db56e51e96bff0865b9853ae70df0e6720624049da76bde2da"}, + {file = "lxml-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b34339898bb556a2351a1830f88f751679f343eabf9cf05841c95b165152c9e7"}, + {file = "lxml-6.0.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:51a5e4c61a4541bd1cd3ba74766d0c9b6c12d6a1a4964ef60026832aac8e79b3"}, + {file = "lxml-6.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f4b481b6cc3a897adb4279216695150bbe7a44c03daba3c894f49d2037e0a24"}, + {file = "lxml-6.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ae06fbab4f1bb7db4f7c8ca9897dc8db4447d1a2b9bee78474ad403437bcc29"}, + {file = "lxml-6.0.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:1fa377b827ca2023244a06554c6e7dc6828a10aaf74ca41965c5d8a4925aebb4"}, + {file = "lxml-6.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1676b56d48048a62ef77a250428d1f31f610763636e0784ba67a9740823988ca"}, + {file = "lxml-6.0.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0e32698462aacc5c1cf6bdfebc9c781821b7e74c79f13e5ffc8bfe27c42b1abf"}, + {file = "lxml-6.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7488a43033c958637b1a08cddc9188eb06d3ad36582cebc7d4815980b47e27ef"}, + {file = "lxml-6.0.0-cp313-cp313-win32.whl", hash = "sha256:5fcd7d3b1d8ecb91445bd71b9c88bdbeae528fefee4f379895becfc72298d181"}, + {file = "lxml-6.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:2f34687222b78fff795feeb799a7d44eca2477c3d9d3a46ce17d51a4f383e32e"}, + {file = "lxml-6.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:21db1ec5525780fd07251636eb5f7acb84003e9382c72c18c542a87c416ade03"}, + {file = "lxml-6.0.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4eb114a0754fd00075c12648d991ec7a4357f9cb873042cc9a77bf3a7e30c9db"}, + {file = "lxml-6.0.0-cp38-cp38-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:7da298e1659e45d151b4028ad5c7974917e108afb48731f4ed785d02b6818994"}, + {file = "lxml-6.0.0-cp38-cp38-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63b634facdfbad421d4b61c90735688465d4ab3a8853ac22c76ccac2baf98d97"}, + {file = "lxml-6.0.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:e380e85b93f148ad28ac15f8117e2fd8e5437aa7732d65e260134f83ce67911b"}, + {file = "lxml-6.0.0-cp38-cp38-win32.whl", hash = "sha256:185efc2fed89cdd97552585c624d3c908f0464090f4b91f7d92f8ed2f3b18f54"}, + {file = "lxml-6.0.0-cp38-cp38-win_amd64.whl", hash = "sha256:f97487996a39cb18278ca33f7be98198f278d0bc3c5d0fd4d7b3d63646ca3c8a"}, + {file = "lxml-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85b14a4689d5cff426c12eefe750738648706ea2753b20c2f973b2a000d3d261"}, + {file = "lxml-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f64ccf593916e93b8d36ed55401bb7fe9c7d5de3180ce2e10b08f82a8f397316"}, + {file = "lxml-6.0.0-cp39-cp39-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:b372d10d17a701b0945f67be58fae4664fd056b85e0ff0fbc1e6c951cdbc0512"}, + {file = "lxml-6.0.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:048a930eb4572829604982e39a0c7289ab5dc8abc7fc9f5aabd6fbc08c154e93"}, + {file = "lxml-6.0.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0b5fa5eda84057a4f1bbb4bb77a8c28ff20ae7ce211588d698ae453e13c6281"}, + {file = "lxml-6.0.0-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:c352fc8f36f7e9727db17adbf93f82499457b3d7e5511368569b4c5bd155a922"}, + {file = "lxml-6.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:8db5dc617cb937ae17ff3403c3a70a7de9df4852a046f93e71edaec678f721d0"}, + {file = "lxml-6.0.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:2181e4b1d07dde53986023482673c0f1fba5178ef800f9ab95ad791e8bdded6a"}, + {file = "lxml-6.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b3c98d5b24c6095e89e03d65d5c574705be3d49c0d8ca10c17a8a4b5201b72f5"}, + {file = "lxml-6.0.0-cp39-cp39-win32.whl", hash = "sha256:04d67ceee6db4bcb92987ccb16e53bef6b42ced872509f333c04fb58a3315256"}, + {file = "lxml-6.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:e0b1520ef900e9ef62e392dd3d7ae4f5fa224d1dd62897a792cf353eb20b6cae"}, + {file = "lxml-6.0.0-cp39-cp39-win_arm64.whl", hash = "sha256:e35e8aaaf3981489f42884b59726693de32dabfc438ac10ef4eb3409961fd402"}, + {file = "lxml-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:dbdd7679a6f4f08152818043dbb39491d1af3332128b3752c3ec5cebc0011a72"}, + {file = "lxml-6.0.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ab542c91f5a47aaa58abdd8ea84b498e8e49fe4b883d67800017757a3eb78e8"}, + {file = "lxml-6.0.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:013090383863b72c62a702d07678b658fa2567aa58d373d963cca245b017e065"}, + {file = "lxml-6.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c86df1c9af35d903d2b52d22ea3e66db8058d21dc0f59842ca5deb0595921141"}, + {file = "lxml-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4337e4aec93b7c011f7ee2e357b0d30562edd1955620fdd4aeab6aacd90d43c5"}, + {file = "lxml-6.0.0-pp39-pypy39_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17f090a9bc0ce8da51a5632092f98a7e7f84bca26f33d161a98b57f7fb0004ca"}, + {file = "lxml-6.0.0-pp39-pypy39_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9da022c14baeec36edfcc8daf0e281e2f55b950249a455776f0d1adeeada4734"}, + {file = "lxml-6.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a55da151d0b0c6ab176b4e761670ac0e2667817a1e0dadd04a01d0561a219349"}, + {file = "lxml-6.0.0.tar.gz", hash = "sha256:032e65120339d44cdc3efc326c9f660f5f7205f3a535c1fdbf898b29ea01fb72"}, ] [package.extras] @@ -2478,7 +2421,6 @@ cssselect = ["cssselect (>=0.7)"] html-clean = ["lxml_html_clean"] html5 = ["html5lib"] htmlsoup = ["BeautifulSoup4"] -source = ["Cython (>=3.0.11,<3.1.0)"] [[package]] name = "mako" @@ -5563,4 +5505,4 @@ cffi = ["cffi (>=1.11)"] [metadata] lock-version = "2.1" python-versions = "^3.12.2" -content-hash = "93ccf6e11b76f9dd5ffa82aabc0b9f5405d171fd22d4ee4c65843e60f0d2d547" +content-hash = "834b9c7c192d29705a4c88a05266dc15e66b6e83385b04eb49560e97885a0874" diff --git a/pyproject.toml b/pyproject.toml index 04ae1c0b1..d451bd519 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ flask-sqlalchemy = "^3.1.1" gunicorn = {version = "==23.0.0", extras = ["eventlet"]} iso8601 = "==2.1.0" jsonschema = {version = "==4.24.0", extras = ["format"]} -lxml = "==5.4.0" +lxml = "==6.0.0" marshmallow = "^4.0.0" marshmallow-sqlalchemy = "^1.4.2" newrelic = "*" From 0aca341805700a2ead53e94761a07dbc57c5fe67 Mon Sep 17 00:00:00 2001 From: Beverly Nguyen Date: Thu, 26 Jun 2025 17:49:17 -0700 Subject: [PATCH 5/6] pytest fixes --- app/dao/jobs_dao.py | 4 +++- tests/app/dao/test_jobs_dao.py | 12 +++++++++--- tests/app/job/test_rest.py | 34 ++++++++++++++++++++++++---------- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/app/dao/jobs_dao.py b/app/dao/jobs_dao.py index b35ecf5a6..99c994624 100644 --- a/app/dao/jobs_dao.py +++ b/app/dao/jobs_dao.py @@ -75,7 +75,9 @@ def dao_get_jobs_by_service_id( stmt = ( select(Job) .where(*query_filter) - .order_by(func.coalesce(Job.processing_started, Job.created_at).desc()) + .order_by( + func.coalesce(Job.processing_started, Job.created_at).desc(), Job.id.desc() + ) .limit(page_size) .offset(offset) ) diff --git a/tests/app/dao/test_jobs_dao.py b/tests/app/dao/test_jobs_dao.py index b499faefa..28da6eb70 100644 --- a/tests/app/dao/test_jobs_dao.py +++ b/tests/app/dao/test_jobs_dao.py @@ -209,12 +209,18 @@ def test_get_jobs_for_service_in_processed_at_then_created_at_order( ), ] + expected_order = sorted( + created_jobs, + key=lambda job: ((job.processing_started or job.created_at), str(job.id)), + reverse=True, + ) + jobs = dao_get_jobs_by_service_id(sample_template.service.id).items - assert len(jobs) == len(created_jobs) + assert len(jobs) == len(expected_order) - for index in range(0, len(created_jobs)): - assert jobs[index].id == created_jobs[index].id + for index in range(len(expected_order)): + assert jobs[index].id == expected_order[index].id def test_update_job(sample_job): diff --git a/tests/app/job/test_rest.py b/tests/app/job/test_rest.py index dbba6d729..3ab4e2e60 100644 --- a/tests/app/job/test_rest.py +++ b/tests/app/job/test_rest.py @@ -1070,16 +1070,30 @@ def test_get_jobs_should_retrieve_from_ft_notification_status_for_old_jobs( service_id=sample_template.service_id, ) - assert resp_json["data"][0]["id"] == str(job_3.id) - assert resp_json["data"][0]["statistics"] == [] - assert resp_json["data"][1]["id"] == str(job_2.id) - assert resp_json["data"][1]["statistics"] == [ - {"status": NotificationStatus.CREATED, "count": 1}, - ] - assert resp_json["data"][2]["id"] == str(job_1.id) - assert resp_json["data"][2]["statistics"] == [ - {"status": NotificationStatus.DELIVERED, "count": 6}, - ] + returned_jobs = resp_json["data"] + + expected_jobs = [job_3, job_2, job_1] + expected_order = sorted( + expected_jobs, + key=lambda job: ((job.processing_started or job.created_at), str(job.id)), + reverse=True, + ) + expected_ids = [str(job.id) for job in expected_order] + returned_ids = [job["id"] for job in returned_jobs if job["id"] in expected_ids] + assert returned_ids == expected_ids + + for job in expected_jobs: + idx = returned_ids.index(str(job.id)) + if job is job_3: + assert returned_jobs[idx]["statistics"] == [] + elif job is job_2: + assert returned_jobs[idx]["statistics"] == [ + {"status": NotificationStatus.CREATED, "count": 1}, + ] + elif job is job_1: + assert returned_jobs[idx]["statistics"] == [ + {"status": NotificationStatus.DELIVERED, "count": 6}, + ] @freeze_time("2017-07-17 07:17") From b4dda1b6d0edc3d2c4e45fb7fcf464def335d250 Mon Sep 17 00:00:00 2001 From: Beverly Nguyen Date: Thu, 26 Jun 2025 17:56:52 -0700 Subject: [PATCH 6/6] pytest --- tests/app/dao/test_jobs_dao.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/app/dao/test_jobs_dao.py b/tests/app/dao/test_jobs_dao.py index 28da6eb70..265c6521a 100644 --- a/tests/app/dao/test_jobs_dao.py +++ b/tests/app/dao/test_jobs_dao.py @@ -558,3 +558,19 @@ def test_unique_key_on_job_id_and_job_row_number_no_error_if_row_number_for_diff job_2 = create_job(template=sample_email_template) create_notification(job=job_1, job_row_number=0) create_notification(job=job_2, job_row_number=0) + + +def test_jobs_with_same_activity_time_are_sorted_by_id(sample_template): + from datetime import datetime + dt = datetime(2023, 1, 1, 12, 0, 0) + + job1 = create_job(sample_template, created_at=dt, processing_started=None) + job2 = create_job(sample_template, created_at=dt, processing_started=None) + + if str(job1.id) < str(job2.id): + job1, job2 = job2, job1 + + jobs = dao_get_jobs_by_service_id(sample_template.service.id).items + + assert jobs[0].id == job1.id + assert jobs[1].id == job2.id