diff --git a/.ds.baseline b/.ds.baseline index 8af5b59da..864192e1e 100644 --- a/.ds.baseline +++ b/.ds.baseline @@ -267,7 +267,7 @@ "filename": "tests/app/db.py", "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, - "line_number": 87, + "line_number": 90, "is_secret": false } ], @@ -277,7 +277,7 @@ "filename": "tests/app/notifications/test_receive_notification.py", "hashed_secret": "913a73b565c8e2c8ed94497580f619397709b8b6", "is_verified": false, - "line_number": 25, + "line_number": 27, "is_secret": false }, { @@ -285,7 +285,7 @@ "filename": "tests/app/notifications/test_receive_notification.py", "hashed_secret": "d70eab08607a4d05faa2d0d6647206599e9abc65", "is_verified": false, - "line_number": 55, + "line_number": 57, "is_secret": false } ], @@ -295,7 +295,7 @@ "filename": "tests/app/notifications/test_validators.py", "hashed_secret": "6c1a8443963d02d13ffe575a71abe19ea731fb66", "is_verified": false, - "line_number": 768, + "line_number": 672, "is_secret": false } ], @@ -305,7 +305,7 @@ "filename": "tests/app/service/test_rest.py", "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, - "line_number": 1275, + "line_number": 1285, "is_secret": false } ], @@ -341,7 +341,7 @@ "filename": "tests/app/user/test_rest.py", "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, - "line_number": 106, + "line_number": 110, "is_secret": false }, { @@ -349,7 +349,7 @@ "filename": "tests/app/user/test_rest.py", "hashed_secret": "0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33", "is_verified": false, - "line_number": 810, + "line_number": 864, "is_secret": false } ], @@ -384,5 +384,5 @@ } ] }, - "generated_at": "2024-11-11T20:34:04Z" + "generated_at": "2025-02-10T16:57:15Z" } diff --git a/.github/actions/setup-project/action.yml b/.github/actions/setup-project/action.yml index c095bd595..b2821a5b6 100644 --- a/.github/actions/setup-project/action.yml +++ b/.github/actions/setup-project/action.yml @@ -15,4 +15,4 @@ runs: python-version: "3.12.3" - name: Install poetry shell: bash - run: pip install --upgrade poetry + run: pip install poetry==1.8.5 diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 8324e6053..2d7311e1d 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -47,10 +47,11 @@ jobs: NOTIFY_E2E_TEST_HTTP_AUTH_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_HTTP_AUTH_PASSWORD }} NOTIFY_E2E_TEST_HTTP_AUTH_USER: ${{ secrets.NOTIFY_E2E_TEST_HTTP_AUTH_USER }} NOTIFY_E2E_TEST_PASSWORD: ${{ secrets.NOTIFY_E2E_TEST_PASSWORD }} - - name: Run style checks - run: poetry run flake8 . + - name: Check imports alphabetized run: poetry run isort --check-only ./app ./tests + - name: Run style checks + run: poetry run flake8 . - name: Check for dead code run: make dead-code - name: Run tests with coverage @@ -90,6 +91,8 @@ jobs: - uses: pypa/gh-action-pip-audit@v1.0.8 with: inputs: requirements.txt + ignore-vulns: | + PYSEC-2022-43162 static-scan: runs-on: ubuntu-latest @@ -134,7 +137,7 @@ jobs: env: SQLALCHEMY_DATABASE_TEST_URI: postgresql://user:password@localhost:5432/test_notification_api - name: Run OWASP API Scan - uses: zaproxy/action-api-scan@v0.5.0 + uses: zaproxy/action-api-scan@v0.9.0 with: docker_name: 'ghcr.io/zaproxy/zaproxy:weekly' target: 'http://localhost:6011/docs/openapi.yml' diff --git a/.github/workflows/daily_checks.yml b/.github/workflows/daily_checks.yml index 21374e219..edd1f7369 100644 --- a/.github/workflows/daily_checks.yml +++ b/.github/workflows/daily_checks.yml @@ -46,7 +46,7 @@ jobs: - name: Run scan run: bandit -r app/ -f txt -o /tmp/bandit-output.txt --confidence-level medium - name: Upload bandit artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: bandit-report path: /tmp/bandit-output.txt @@ -84,7 +84,7 @@ jobs: env: SQLALCHEMY_DATABASE_TEST_URI: postgresql://user:password@localhost:5432/test_notification_api - name: Run OWASP API Scan - uses: zaproxy/action-api-scan@v0.5.0 + uses: zaproxy/action-api-scan@v0.9.0 with: docker_name: 'ghcr.io/zaproxy/zaproxy:weekly' target: 'http://localhost:6011/docs/openapi.yml' diff --git a/.github/workflows/restage-apps.yml b/.github/workflows/restage-apps.yml index abdadcfe0..23c78f8cf 100644 --- a/.github/workflows/restage-apps.yml +++ b/.github/workflows/restage-apps.yml @@ -19,18 +19,18 @@ jobs: app: ["api", "admin"] steps: - name: Restage ${{matrix.app}} - uses: 18f/cg-deploy-action@main + uses: cloud-gov/cg-cli-tools@main with: cf_username: ${{ secrets.CLOUDGOV_USERNAME }} cf_password: ${{ secrets.CLOUDGOV_PASSWORD }} cf_org: gsa-tts-benefits-studio cf_space: notify-${{ inputs.environment }} - full_command: "cf restage --strategy rolling notify-${{matrix.app}}-${{inputs.environment}}" + command: "cf restage --strategy rolling notify-${{matrix.app}}-${{inputs.environment}}" - name: Restage ${{matrix.app}} egress - uses: 18f/cg-deploy-action@main + uses: cloud-gov/cg-cli-tools@main with: cf_username: ${{ secrets.CLOUDGOV_USERNAME }} cf_password: ${{ secrets.CLOUDGOV_PASSWORD }} cf_org: gsa-tts-benefits-studio cf_space: notify-${{ inputs.environment }}-egress - full_command: "cf restage --strategy rolling egress-proxy-notify-${{matrix.app}}-${{inputs.environment}}" + command: "cf restage --strategy rolling egress-proxy-notify-${{matrix.app}}-${{inputs.environment}}" diff --git a/.gitignore b/.gitignore index f60b72b58..cf35582a6 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ var/ .installed.cfg *.egg /cache +requirements.txt # PyInstaller # Usually these files are written by a python script from a template diff --git a/Makefile b/Makefile index acd31f390..741ceae5b 100644 --- a/Makefile +++ b/Makefile @@ -9,10 +9,12 @@ GIT_COMMIT ?= $(shell git rev-parse HEAD) ## DEVELOPMENT +## TODO this line should go under `make generate-version-file` +## poetry self update + .PHONY: bootstrap bootstrap: ## Set up everything to run the app make generate-version-file - poetry self update poetry self add poetry-dotenv-plugin poetry lock --no-update poetry install --sync --no-root @@ -29,6 +31,14 @@ bootstrap-with-docker: ## Build the image to run the app in Docker run-procfile: poetry run honcho start -f Procfile.dev + + +.PHONY: tada +tada: + poetry run isort . + poetry run black . + poetry run flake8 . + .PHONY: avg-complexity avg-complexity: echo "*** Shows average complexity in radon of all code ***" @@ -50,7 +60,8 @@ run-celery: ## Run celery, TODO remove purge for staging/prod -A run_celery.notify_celery worker \ --pidfile="/tmp/celery.pid" \ --loglevel=INFO \ - --concurrency=4 + --pool=threads + --concurrency=10 .PHONY: dead-code @@ -80,7 +91,7 @@ test: export NEW_RELIC_ENVIRONMENT=test test: ## Run tests and create coverage report poetry run black . poetry run flake8 . - poetry run isort --check-only ./app ./tests + poetry run isort ./app ./tests poetry run coverage run --omit=*/migrations/*,*/tests/* -m pytest --maxfail=10 ## TODO set this back to 95 asap diff --git a/app/__init__.py b/app/__init__.py index 16ffbd5a9..f7427f9f1 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,3 +1,4 @@ +import logging as real_logging import os import secrets import string @@ -17,6 +18,7 @@ from sqlalchemy import event from werkzeug.exceptions import HTTPException as WerkzeugHTTPException from werkzeug.local import LocalProxy +from app import config from app.clients import NotificationProviderClients from app.clients.cloudwatch.aws_cloudwatch import AwsCloudwatchClient from app.clients.document_download import DocumentDownloadClient @@ -36,6 +38,9 @@ class NotifyCelery(Celery): # Configure Celery app with options from the main app config. self.config_from_object(app.config["CELERY"]) + self.conf.worker_hijack_root_logger = False + logger = real_logging.getLogger("celery") + logger.propagate = False def send_task(self, name, args=None, kwargs=None, **other_kwargs): other_kwargs["headers"] = other_kwargs.get("headers") or {} @@ -54,15 +59,28 @@ class SQLAlchemy(_SQLAlchemy): def apply_driver_hacks(self, app, info, options): sa_url, options = super().apply_driver_hacks(app, info, options) + if "connect_args" not in options: options["connect_args"] = {} options["connect_args"]["options"] = "-c statement_timeout={}".format( int(app.config["SQLALCHEMY_STATEMENT_TIMEOUT"]) * 1000 ) + return (sa_url, options) -db = SQLAlchemy() +# Set db engine settings here for now. +# They were not being set previous (despite environmental variables with appropriate +# sounding names) and were defaulting to low values +db = SQLAlchemy( + engine_options={ + "pool_size": config.Config.SQLALCHEMY_POOL_SIZE, + "max_overflow": 10, + "pool_timeout": config.Config.SQLALCHEMY_POOL_TIMEOUT, + "pool_recycle": config.Config.SQLALCHEMY_POOL_RECYCLE, + "pool_pre_ping": True, + } +) migrate = Migrate() ma = Marshmallow() notify_celery = NotifyCelery() @@ -268,6 +286,13 @@ def init_app(app): @app.after_request def after_request(response): response.headers.add("X-Content-Type-Options", "nosniff") + + # Some dynamic scan findings + response.headers.add("Cross-Origin-Opener-Policy", "same-origin") + response.headers.add("Cross-Origin-Embedder-Policy", "require-corp") + response.headers.add("Cross-Origin-Resource-Policy", "same-origin") + response.headers.add("Cross-Origin-Opener-Policy", "same-origin") + return response @app.errorhandler(Exception) diff --git a/app/aws/s3.py b/app/aws/s3.py index f83b9059d..c33366a2c 100644 --- a/app/aws/s3.py +++ b/app/aws/s3.py @@ -1,7 +1,9 @@ +import csv import datetime import re import time from concurrent.futures import ThreadPoolExecutor +from io import StringIO import botocore from boto3 import Session @@ -23,7 +25,7 @@ s3_resource = None def set_job_cache(key, value): - current_app.logger.info(f"Setting {key} in the job_cache.") + current_app.logger.debug(f"Setting {key} in the job_cache.") job_cache = current_app.config["job_cache"] job_cache[key] = (value, time.time() + 8 * 24 * 60 * 60) @@ -34,14 +36,14 @@ def get_job_cache(key): if ret is None: current_app.logger.warning(f"Could not find {key} in the job_cache.") else: - current_app.logger.info(f"Got {key} from job_cache.") + current_app.logger.debug(f"Got {key} from job_cache.") return ret def len_job_cache(): job_cache = current_app.config["job_cache"] ret = len(job_cache) - current_app.logger.info(f"Length of job_cache is {ret}") + current_app.logger.debug(f"Length of job_cache is {ret}") return ret @@ -53,7 +55,7 @@ def clean_cache(): if expiry_time < current_time: keys_to_delete.append(key) - current_app.logger.info( + current_app.logger.debug( f"Deleting the following keys from the job_cache: {keys_to_delete}" ) for key in keys_to_delete: @@ -139,7 +141,7 @@ def cleanup_old_s3_objects(): try: remove_csv_object(obj["Key"]) - current_app.logger.info( + current_app.logger.debug( f"#delete-old-s3-objects Deleted: {obj['LastModified']} {obj['Key']}" ) except botocore.exceptions.ClientError: @@ -287,7 +289,7 @@ def file_exists(file_location): def get_job_location(service_id, job_id): - current_app.logger.info( + current_app.logger.debug( f"#s3-partitioning NEW JOB_LOCATION: {NEW_FILE_LOCATION_STRUCTURE.format(service_id, job_id)}" ) return ( @@ -305,7 +307,7 @@ def get_old_job_location(service_id, job_id): but it will take a few days where we have to support both formats. Remove this when everything works with the NEW_FILE_LOCATION_STRUCTURE. """ - current_app.logger.info( + current_app.logger.debug( f"#s3-partitioning OLD JOB LOCATION: {FILE_LOCATION_STRUCTURE.format(service_id, job_id)}" ) return ( @@ -395,26 +397,25 @@ def get_job_from_s3(service_id, job_id): def extract_phones(job): - job = job.split("\r\n") - first_row = job[0] - job.pop(0) - first_row = first_row.split(",") + job_csv_data = StringIO(job) + csv_reader = csv.reader(job_csv_data) + first_row = next(csv_reader) + phone_index = 0 - for item in first_row: - # Note: may contain a BOM and look like \ufeffphone number - if item.lower() in ["phone number", "\\ufeffphone number"]: + for i, item in enumerate(first_row): + if item.lower().lstrip("\ufeff") == "phone number": + phone_index = i break - phone_index = phone_index + 1 phones = {} job_row = 0 - for row in job: - row = row.split(",") + for row in csv_reader: if phone_index >= len(row): phones[job_row] = "Unavailable" current_app.logger.error( - "Corrupt csv file, missing columns or possibly a byte order mark in the file", + f"Corrupt csv file, missing columns or\ + possibly a byte order mark in the file, row looks like {row}", ) else: @@ -445,7 +446,7 @@ def extract_personalisation(job): def get_phone_number_from_s3(service_id, job_id, job_row_number): job = get_job_cache(job_id) if job is None: - current_app.logger.info(f"job {job_id} was not in the cache") + current_app.logger.debug(f"job {job_id} was not in the cache") job = get_job_from_s3(service_id, job_id) # Even if it is None, put it here to avoid KeyErrors set_job_cache(job_id, job) @@ -479,7 +480,7 @@ def get_personalisation_from_s3(service_id, job_id, job_row_number): # So this is a little recycling mechanism to reduce the number of downloads. job = get_job_cache(job_id) if job is None: - current_app.logger.info(f"job {job_id} was not in the cache") + current_app.logger.debug(f"job {job_id} was not in the cache") job = get_job_from_s3(service_id, job_id) # Even if it is None, put it here to avoid KeyErrors set_job_cache(job_id, job) @@ -503,7 +504,7 @@ def get_personalisation_from_s3(service_id, job_id, job_row_number): def get_job_metadata_from_s3(service_id, job_id): - current_app.logger.info( + current_app.logger.debug( f"#s3-partitioning CALLING GET_JOB_METADATA with {service_id}, {job_id}" ) obj = get_s3_object(*get_job_location(service_id, job_id)) diff --git a/app/billing/rest.py b/app/billing/rest.py index a0500fb57..60c613f1c 100644 --- a/app/billing/rest.py +++ b/app/billing/rest.py @@ -1,5 +1,6 @@ from flask import Blueprint, jsonify, request +from app import db from app.billing.billing_schemas import ( create_or_update_free_sms_fragment_limit_schema, serialize_ft_billing_remove_emails, @@ -60,7 +61,7 @@ def get_free_sms_fragment_limit(service_id): ) if annual_billing is None: - service = Service.query.get(service_id) + service = db.session.get(Service, service_id) # An entry does not exist in annual_billing table for that service and year. # Set the annual billing to the default free allowance based on the organization type of the service. diff --git a/app/celery/nightly_tasks.py b/app/celery/nightly_tasks.py index f51b0ec9a..01bdbbd67 100644 --- a/app/celery/nightly_tasks.py +++ b/app/celery/nightly_tasks.py @@ -52,7 +52,8 @@ def cleanup_unfinished_jobs(): # The query already checks that the processing_finished time is null, so here we are saying # if it started more than 4 hours ago, that's too long try: - acceptable_finish_time = job.processing_started + timedelta(minutes=5) + if job.processing_started is not None: + acceptable_finish_time = job.processing_started + timedelta(minutes=5) except TypeError: current_app.logger.exception( f"Job ID {job.id} processing_started is {job.processing_started}.", diff --git a/app/celery/process_ses_receipts_tasks.py b/app/celery/process_ses_receipts_tasks.py index c03df0c98..c0b7d3413 100644 --- a/app/celery/process_ses_receipts_tasks.py +++ b/app/celery/process_ses_receipts_tasks.py @@ -12,7 +12,7 @@ from app.celery.service_callback_tasks import ( send_complaint_to_service, send_delivery_status_to_service, ) -from app.config import QueueNames +from app.config import Config, QueueNames from app.dao import notifications_dao from app.dao.complaint_dao import save_complaint from app.dao.notifications_dao import dao_get_notification_history_by_reference @@ -65,7 +65,9 @@ def process_ses_results(self, response): f"Callback may have arrived before notification was" f"persisted to the DB. Adding task to retry queue" ) - self.retry(queue=QueueNames.RETRY) + self.retry( + queue=QueueNames.RETRY, expires=Config.DEFAULT_REDIS_EXPIRE_TIME + ) else: current_app.logger.warning( f"Notification not found for reference: {reference} " @@ -115,7 +117,7 @@ def process_ses_results(self, response): except Exception: current_app.logger.exception("Error processing SES results") - self.retry(queue=QueueNames.RETRY) + self.retry(queue=QueueNames.RETRY, expires=Config.DEFAULT_REDIS_EXPIRE_TIME) def determine_notification_bounce_type(ses_message): diff --git a/app/celery/provider_tasks.py b/app/celery/provider_tasks.py index 011b00d98..a3ed1f9ef 100644 --- a/app/celery/provider_tasks.py +++ b/app/celery/provider_tasks.py @@ -1,107 +1,20 @@ import json import os -from datetime import timedelta -from botocore.exceptions import ClientError from flask import current_app from sqlalchemy.orm.exc import NoResultFound -from app import aws_cloudwatch_client, notify_celery, redis_store +from app import notify_celery, redis_store from app.clients.email import EmailClientNonRetryableException from app.clients.email.aws_ses import AwsSesClientThrottlingSendRateException from app.clients.sms import SmsClientResponseException -from app.config import QueueNames +from app.config import Config, QueueNames from app.dao import notifications_dao -from app.dao.notifications_dao import ( - sanitize_successful_notification_by_id, - update_notification_status_by_id, -) +from app.dao.notifications_dao import update_notification_status_by_id from app.delivery import send_to_providers from app.enums import NotificationStatus from app.exceptions import NotificationTechnicalFailureException -from app.utils import utc_now - -# This is the amount of time to wait after sending an sms message before we check the aws logs and look for delivery -# receipts -DELIVERY_RECEIPT_DELAY_IN_SECONDS = 30 - - -@notify_celery.task( - bind=True, - name="check_sms_delivery_receipt", - max_retries=48, - default_retry_delay=300, -) -def check_sms_delivery_receipt(self, message_id, notification_id, sent_at): - """ - This is called after deliver_sms to check the status of the message. This uses the same number of - retries and the same delay period as deliver_sms. In addition, this fires five minutes after - deliver_sms initially. So the idea is that most messages will succeed and show up in the logs quickly. - Other message will resolve successfully after a retry or to. A few will fail but it will take up to - 4 hours to know for sure. The call to check_sms will raise an exception if neither a success nor a - failure appears in the cloudwatch logs, so this should keep retrying until the log appears, or until - we run out of retries. - """ - # TODO the localstack cloudwatch doesn't currently have our log groups. Possibly create them with awslocal? - if aws_cloudwatch_client.is_localstack(): - status = "success" - provider_response = "this is a fake successful localstack sms message" - carrier = "unknown" - else: - try: - status, provider_response, carrier = aws_cloudwatch_client.check_sms( - message_id, notification_id, sent_at - ) - except NotificationTechnicalFailureException as ntfe: - provider_response = "Unable to find carrier response -- still looking" - status = "pending" - carrier = "" - update_notification_status_by_id( - notification_id, - status, - carrier=carrier, - provider_response=provider_response, - ) - raise self.retry(exc=ntfe) - except ClientError as err: - # Probably a ThrottlingException but could be something else - error_code = err.response["Error"]["Code"] - provider_response = ( - f"{error_code} while checking sms receipt -- still looking" - ) - status = "pending" - carrier = "" - update_notification_status_by_id( - notification_id, - status, - carrier=carrier, - provider_response=provider_response, - ) - raise self.retry(exc=err) - - if status == "success": - status = NotificationStatus.DELIVERED - elif status == "failure": - status = NotificationStatus.FAILED - # if status is not success or failure the client raised an exception and this method will retry - - if status == NotificationStatus.DELIVERED: - sanitize_successful_notification_by_id( - notification_id, carrier=carrier, provider_response=provider_response - ) - current_app.logger.info( - f"Sanitized notification {notification_id} that was successfully delivered" - ) - else: - update_notification_status_by_id( - notification_id, - status, - carrier=carrier, - provider_response=provider_response, - ) - current_app.logger.info( - f"Updated notification {notification_id} with response '{provider_response}'" - ) +from notifications_utils.clients.redis import total_limit_cache_key @notify_celery.task( @@ -127,15 +40,11 @@ def deliver_sms(self, notification_id): ansi_green + f"AUTHENTICATION CODE: {notification.content}" + ansi_reset ) # Code branches off to send_to_providers.py - message_id = send_to_providers.send_sms_to_provider(notification) - # We have to put it in UTC. For other timezones, the delay - # will be ignored and it will fire immediately (although this probably only affects developer testing) - my_eta = utc_now() + timedelta(seconds=DELIVERY_RECEIPT_DELAY_IN_SECONDS) - check_sms_delivery_receipt.apply_async( - [message_id, notification_id, notification.created_at], - eta=my_eta, - queue=QueueNames.CHECK_SMS, - ) + send_to_providers.send_sms_to_provider(notification) + + cache_key = total_limit_cache_key(notification.service_id) + redis_store.incr(cache_key) + except Exception as e: update_notification_status_by_id( notification_id, @@ -152,9 +61,15 @@ def deliver_sms(self, notification_id): try: if self.request.retries == 0: - self.retry(queue=QueueNames.RETRY, countdown=0) + self.retry( + queue=QueueNames.RETRY, + countdown=0, + expires=Config.DEFAULT_REDIS_EXPIRE_TIME, + ) else: - self.retry(queue=QueueNames.RETRY) + self.retry( + queue=QueueNames.RETRY, expires=Config.DEFAULT_REDIS_EXPIRE_TIME + ) except self.MaxRetriesExceededError: message = ( "RETRY FAILED: Max retries reached. The task send_sms_to_provider failed for notification {}. " @@ -170,7 +85,7 @@ def deliver_sms(self, notification_id): @notify_celery.task( - bind=True, name="deliver_email", max_retries=48, default_retry_delay=300 + bind=True, name="deliver_email", max_retries=48, default_retry_delay=30 ) def deliver_email(self, notification_id): try: @@ -182,8 +97,12 @@ def deliver_email(self, notification_id): if not notification: raise NoResultFound() personalisation = redis_store.get(f"email-personalisation-{notification_id}") + recipient = redis_store.get(f"email-recipient-{notification_id}") + if personalisation: + notification.personalisation = json.loads(personalisation) + if recipient: + notification.recipient = json.loads(recipient) - notification.personalisation = json.loads(personalisation) send_to_providers.send_email_to_provider(notification) except EmailClientNonRetryableException: current_app.logger.exception(f"Email notification {notification_id} failed") @@ -199,7 +118,7 @@ def deliver_email(self, notification_id): f"RETRY: Email notification {notification_id} failed" ) - self.retry(queue=QueueNames.RETRY) + self.retry(queue=QueueNames.RETRY, expires=Config.DEFAULT_REDIS_EXPIRE_TIME) except self.MaxRetriesExceededError: message = ( "RETRY FAILED: Max retries reached. " diff --git a/app/celery/scheduled_tasks.py b/app/celery/scheduled_tasks.py index 3597bdbb7..2ff72780d 100644 --- a/app/celery/scheduled_tasks.py +++ b/app/celery/scheduled_tasks.py @@ -1,16 +1,18 @@ -from datetime import timedelta +import json +from datetime import datetime, timedelta from flask import current_app -from sqlalchemy import between +from sqlalchemy import between, select, union from sqlalchemy.exc import SQLAlchemyError -from app import notify_celery, zendesk_client +from app import db, notify_celery, redis_store, zendesk_client from app.celery.tasks import ( get_recipient_csv_and_template_and_sender_id, process_incomplete_jobs, process_job, process_row, ) +from app.clients.cloudwatch.aws_cloudwatch import AwsCloudwatchClient from app.config import QueueNames from app.dao.invited_org_user_dao import ( delete_org_invitations_created_more_than_two_days_ago, @@ -18,20 +20,26 @@ from app.dao.invited_org_user_dao import ( from app.dao.invited_user_dao import expire_invitations_created_more_than_two_days_ago from app.dao.jobs_dao import ( dao_set_scheduled_jobs_to_pending, - dao_update_job, + dao_update_job_status_to_error, find_jobs_with_missing_rows, find_missing_row_for_job, ) -from app.dao.notifications_dao import notifications_not_yet_sent +from app.dao.notifications_dao import ( + dao_batch_insert_notifications, + dao_close_out_delivery_receipts, + dao_update_delivery_receipts, + notifications_not_yet_sent, +) from app.dao.services_dao import ( dao_find_services_sending_to_tv_numbers, dao_find_services_with_high_failure_rates, ) from app.dao.users_dao import delete_codes_older_created_more_than_a_day_ago from app.enums import JobStatus, NotificationType -from app.models import Job +from app.models import Job, Notification from app.notifications.process_notifications import send_notification_to_queue from app.utils import utc_now +from notifications_utils import aware_utcnow from notifications_utils.clients.zendesk.zendesk_client import NotifySupportTicket MAX_NOTIFICATION_FAILS = 10000 @@ -95,40 +103,46 @@ def check_job_status(): select from jobs where job_status == 'in progress' - and processing started between 30 and 35 minutes ago + and processing started some time ago OR where the job_status == 'pending' - and the job scheduled_for timestamp is between 30 and 35 minutes ago. + and the job scheduled_for timestamp is some time ago. if any results then update the job_status to 'error' process the rows in the csv that are missing (in another task) just do the check here. """ - thirty_minutes_ago = utc_now() - timedelta(minutes=30) - thirty_five_minutes_ago = utc_now() - timedelta(minutes=35) + START_MINUTES = 245 + END_MINUTES = 240 + end_minutes_ago = utc_now() - timedelta(minutes=END_MINUTES) + start_minutes_ago = utc_now() - timedelta(minutes=START_MINUTES) - incomplete_in_progress_jobs = Job.query.filter( + incomplete_in_progress_jobs = select(Job).where( Job.job_status == JobStatus.IN_PROGRESS, - between(Job.processing_started, thirty_five_minutes_ago, thirty_minutes_ago), + between(Job.processing_started, start_minutes_ago, end_minutes_ago), ) - incomplete_pending_jobs = Job.query.filter( + incomplete_pending_jobs = select(Job).where( Job.job_status == JobStatus.PENDING, Job.scheduled_for.isnot(None), - between(Job.scheduled_for, thirty_five_minutes_ago, thirty_minutes_ago), + between(Job.scheduled_for, start_minutes_ago, end_minutes_ago), + ) + jobs_not_completed_after_allotted_time = union( + incomplete_in_progress_jobs, incomplete_pending_jobs + ) + jobs_not_completed_after_allotted_time = ( + jobs_not_completed_after_allotted_time.order_by( + Job.processing_started, Job.scheduled_for + ) ) - jobs_not_complete_after_30_minutes = ( - incomplete_in_progress_jobs.union(incomplete_pending_jobs) - .order_by(Job.processing_started, Job.scheduled_for) - .all() - ) + jobs_not_complete_after_allotted_time = db.session.execute( + jobs_not_completed_after_allotted_time + ).all() # temporarily mark them as ERROR so that they don't get picked up by future check_job_status tasks # if they haven't been re-processed in time. job_ids = [] - for job in jobs_not_complete_after_30_minutes: - job.job_status = JobStatus.ERROR - dao_update_job(job) + for job in jobs_not_complete_after_allotted_time: + dao_update_job_status_to_error(job) job_ids.append(str(job.id)) - if job_ids: current_app.logger.info("Job(s) {} have not completed.".format(job_ids)) process_incomplete_jobs.apply_async([job_ids], queue=QueueNames.JOBS) @@ -158,6 +172,7 @@ def replay_created_notifications(): @notify_celery.task(name="check-for-missing-rows-in-completed-jobs") def check_for_missing_rows_in_completed_jobs(): + jobs = find_jobs_with_missing_rows() for job in jobs: ( @@ -169,9 +184,7 @@ def check_for_missing_rows_in_completed_jobs(): for row_to_process in missing_rows: row = recipient_csv[row_to_process.missing_row] current_app.logger.info( - "Processing missing row: {} for job: {}".format( - row_to_process.missing_row, job.id - ) + f"Processing missing row: {row_to_process.missing_row} for job: {job.id}" ) process_row(row, template, job, job.service, sender_id=sender_id) @@ -231,3 +244,102 @@ def check_for_services_with_high_failure_rates_or_sending_to_tv_numbers(): technical_ticket=True, ) zendesk_client.send_ticket_to_zendesk(ticket) + + +@notify_celery.task( + bind=True, max_retries=7, default_retry_delay=3600, name="process-delivery-receipts" +) +def process_delivery_receipts(self): + # If we need to check db settings do it here for convenience + # current_app.logger.info(f"POOL SIZE {app.db.engine.pool.size()}") + """ + Every eight minutes or so (see config.py) we run this task, which searches the last ten + minutes of logs for delivery receipts and batch updates the db with the results. The overlap + is intentional. We don't mind re-updating things, it is better than losing data. + + We also set this to retry with exponential backoff in the case of failure. The only way this would + fail is if, for example the db went down, or redis filled causing the app to stop processing. But if + it does fail, we need to go back over at some point when things are running again and process those results. + """ + try: + batch_size = 1000 # in theory with postgresql this could be 10k to 20k? + + cloudwatch = AwsCloudwatchClient() + cloudwatch.init_app(current_app) + start_time = aware_utcnow() - timedelta(minutes=3) + end_time = aware_utcnow() + delivered_receipts, failed_receipts = cloudwatch.check_delivery_receipts( + start_time, end_time + ) + delivered_receipts = list(delivered_receipts) + for i in range(0, len(delivered_receipts), batch_size): + batch = delivered_receipts[i : i + batch_size] + dao_update_delivery_receipts(batch, True) + failed_receipts = list(failed_receipts) + for i in range(0, len(failed_receipts), batch_size): + batch = failed_receipts[i : i + batch_size] + dao_update_delivery_receipts(batch, False) + except Exception as ex: + retry_count = self.request.retries + wait_time = 3600 * 2**retry_count + try: + raise self.retry(ex=ex, countdown=wait_time) + except self.MaxRetriesExceededError: + current_app.logger.error( + "Failed process delivery receipts after max retries" + ) + + +@notify_celery.task( + bind=True, max_retries=2, default_retry_delay=3600, name="cleanup-delivery-receipts" +) +def cleanup_delivery_receipts(self): + dao_close_out_delivery_receipts() + + +@notify_celery.task(bind=True, name="batch-insert-notifications") +def batch_insert_notifications(self): + batch = [] + + # TODO We probably need some way to clear the list if + # things go haywire. A command? + + # with redis_store.pipeline(): + # while redis_store.llen("message_queue") > 0: + # redis_store.lpop("message_queue") + # current_app.logger.info("EMPTY!") + # return + current_len = redis_store.llen("message_queue") + with redis_store.pipeline(): + # since this list is being fed by other processes, just grab what is available when + # this call is made and process that. + + count = 0 + while count < current_len: + count = count + 1 + notification_bytes = redis_store.lpop("message_queue") + notification_dict = json.loads(notification_bytes.decode("utf-8")) + notification_dict["status"] = notification_dict.pop("notification_status") + if not notification_dict.get("created_at"): + notification_dict["created_at"] = utc_now() + elif isinstance(notification_dict["created_at"], list): + notification_dict["created_at"] = notification_dict["created_at"][0] + notification = Notification(**notification_dict) + if notification is not None: + batch.append(notification) + try: + dao_batch_insert_notifications(batch) + except Exception: + current_app.logger.exception("Notification batch insert failed") + for n in batch: + # Use 'created_at' as a TTL so we don't retry infinitely + notification_time = n.created_at + if isinstance(notification_time, str): + notification_time = datetime.fromisoformat(n.created_at) + if notification_time < utc_now() - timedelta(seconds=50): + current_app.logger.warning( + f"Abandoning stale data, could not write to db: {n.serialize_for_redis(n)}" + ) + continue + else: + redis_store.rpush("message_queue", json.dumps(n.serialize_for_redis(n))) diff --git a/app/celery/tasks.py b/app/celery/tasks.py index c8ad8cc6d..92795c44a 100644 --- a/app/celery/tasks.py +++ b/app/celery/tasks.py @@ -1,5 +1,7 @@ import json +from time import sleep +from celery.signals import task_postrun from flask import current_app from requests import HTTPError, RequestException, request from sqlalchemy.exc import IntegrityError, SQLAlchemyError @@ -7,7 +9,7 @@ from sqlalchemy.exc import IntegrityError, SQLAlchemyError from app import create_uuid, encryption, notify_celery from app.aws import s3 from app.celery import provider_tasks -from app.config import QueueNames +from app.config import Config, QueueNames from app.dao.inbound_sms_dao import dao_get_inbound_sms_by_id from app.dao.jobs_dao import dao_get_job_by_id, dao_update_job from app.dao.notifications_dao import ( @@ -20,7 +22,10 @@ from app.dao.service_sms_sender_dao import dao_get_service_sms_senders_by_id from app.dao.templates_dao import dao_get_template_by_id from app.enums import JobStatus, KeyType, NotificationType from app.errors import TotalRequestsError -from app.notifications.process_notifications import persist_notification +from app.notifications.process_notifications import ( + get_notification, + 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 @@ -34,9 +39,7 @@ def process_job(job_id, sender_id=None): start = utc_now() job = dao_get_job_by_id(job_id) current_app.logger.info( - "Starting process-job task for job id {} with status: {}".format( - job_id, job.job_status - ) + f"Starting process-job task for job id {job_id} with status: {job.job_status}" ) if job.job_status != JobStatus.PENDING: @@ -52,7 +55,7 @@ def process_job(job_id, sender_id=None): job.job_status = JobStatus.CANCELLED dao_update_job(job) current_app.logger.warning( - "Job {} has been cancelled, service {} is inactive".format( + f"Job {job_id} has been cancelled, service {service.id} is inactive".format( job_id, service.id ) ) @@ -66,13 +69,21 @@ def process_job(job_id, sender_id=None): ) current_app.logger.info( - "Starting job {} processing {} notifications".format( - job_id, job.notification_count - ) + f"Starting job {job_id} processing {job.notification_count} notifications" ) + # notify-api-1495 we are going to sleep periodically to give other + # jobs running at the same time a chance to get some of their messages + # sent. Sleep for 1 second after every 3 sends, which gives us throughput + # of about 3600*3 per hour and would keep the queue clear assuming only one sender. + # It will also hopefully eliminate throttling when we send messages which we are + # currently seeing. + count = 0 for row in recipient_csv.get_rows(): process_row(row, template, job, service, sender_id=sender_id) + count = count + 1 + if count % 3 == 0: + sleep(1) # End point/Exit point for message send flow. job_complete(job, start=start) @@ -143,11 +154,19 @@ def process_row(row, template, job, service, sender_id=None): ), task_kwargs, queue=QueueNames.DATABASE, + expires=Config.DEFAULT_REDIS_EXPIRE_TIME, ) return notification_id +# TODO +# Originally this was checking a daily limit +# It is now checking an overall limit (annual?) for the free tier +# Is there any limit for the paid tier? +# Assuming the limit is annual, is it calendar year, fiscal year, MOU year? +# Do we need a command to run to clear the redis value, or should it happen automatically? def __total_sending_limits_for_job_exceeded(service, job, job_id): + print(hilite("ENTER __total_sending_limits_for_job_exceeded")) try: total_sent = check_service_over_total_message_limit(KeyType.NORMAL, service) if total_sent + job.notification_count > service.total_message_limit: @@ -160,13 +179,20 @@ def __total_sending_limits_for_job_exceeded(service, job, job_id): dao_update_job(job) current_app.logger.exception( "Job {} size {} error. Total sending limits {} exceeded".format( - job_id, job.notification_count, service.message_limit + job_id, job.notification_count, service.total_message_limit ), ) return True -@notify_celery.task(bind=True, name="save-sms", max_retries=5, default_retry_delay=300) +@task_postrun.connect +def log_task_ejection(sender=None, task_id=None, **kwargs): + current_app.logger.info( + f"Task {task_id} ({sender.name if sender else 'unknown_task'}) has been completed and removed" + ) + + +@notify_celery.task(bind=True, name="save-sms", max_retries=2, default_retry_delay=600) def save_sms(self, service_id, notification_id, encrypted_notification, sender_id=None): """Persist notification to db and place notification in queue to send to sns.""" notification = encryption.decrypt(encrypted_notification) @@ -194,9 +220,7 @@ def save_sms(self, service_id, notification_id, encrypted_notification, sender_i f"service not allowed to send for job_id {notification.get('job', None)}, aborting" ) ) - current_app.logger.debug( - "SMS {} failed as restricted service".format(notification_id) - ) + current_app.logger.debug(f"SMS {notification_id} failed as restricted service") return try: @@ -206,22 +230,30 @@ def save_sms(self, service_id, notification_id, encrypted_notification, sender_i job = dao_get_job_by_id(job_id) created_by_id = job.created_by_id - saved_notification = persist_notification( - template_id=notification["template"], - template_version=notification["template_version"], - recipient=notification["to"], - service=service, - personalisation=notification.get("personalisation"), - notification_type=NotificationType.SMS, - api_key_id=None, - key_type=KeyType.NORMAL, - created_at=utc_now(), - created_by_id=created_by_id, - job_id=notification.get("job", None), - job_row_number=notification.get("row_number", None), - notification_id=notification_id, - reply_to_text=reply_to_text, - ) + try: + saved_notification = persist_notification( + template_id=notification["template"], + template_version=notification["template_version"], + recipient=notification["to"], + service=service, + personalisation=notification.get("personalisation"), + notification_type=NotificationType.SMS, + api_key_id=None, + key_type=KeyType.NORMAL, + created_at=utc_now(), + created_by_id=created_by_id, + job_id=notification.get("job", None), + job_row_number=notification.get("row_number", None), + notification_id=notification_id, + reply_to_text=reply_to_text, + ) + except IntegrityError: + current_app.logger.warning( + f"{NotificationType.SMS}: {notification_id} already exists." + ) + # If we don't have the return statement here, we will fall through and end + # up retrying because IntegrityError is a subclass of SQLAlchemyError + return # Kick off sns process in provider_tasks.py sn = saved_notification @@ -231,15 +263,12 @@ def save_sms(self, service_id, notification_id, encrypted_notification, sender_i ) ) provider_tasks.deliver_sms.apply_async( - [str(saved_notification.id)], queue=QueueNames.SEND_SMS + [str(saved_notification.id)], queue=QueueNames.SEND_SMS, countdown=60 ) current_app.logger.debug( - "SMS {} created at {} for job {}".format( - saved_notification.id, - saved_notification.created_at, - notification.get("job", None), - ) + f"SMS {saved_notification.id} created at {saved_notification.created_at} " + f"for job {notification.get('job', None)}" ) except SQLAlchemyError as e: @@ -271,7 +300,7 @@ def save_email( "Email {} failed as restricted service".format(notification_id) ) return - + original_notification = get_notification(notification_id) try: saved_notification = persist_notification( template_id=notification["template"], @@ -288,10 +317,11 @@ def save_email( notification_id=notification_id, reply_to_text=reply_to_text, ) - - provider_tasks.deliver_email.apply_async( - [str(saved_notification.id)], queue=QueueNames.SEND_EMAIL - ) + # we only want to send once + if original_notification is None: + provider_tasks.deliver_email.apply_async( + [str(saved_notification.id)], queue=QueueNames.SEND_EMAIL + ) current_app.logger.debug( "Email {} created at {}".format( @@ -310,7 +340,7 @@ def save_api_email(self, encrypted_notification): @notify_celery.task( - bind=True, name="save-api-sms", max_retries=5, default_retry_delay=300 + bind=True, name="save-api-sms", max_retries=2, default_retry_delay=600 ) def save_api_sms(self, encrypted_notification): save_api_email_or_sms(self, encrypted_notification) @@ -329,6 +359,8 @@ def save_api_email_or_sms(self, encrypted_notification): if notification["notification_type"] == NotificationType.EMAIL else provider_tasks.deliver_sms ) + + original_notification = get_notification(notification["id"]) try: persist_notification( notification_id=notification["id"], @@ -346,19 +378,24 @@ def save_api_email_or_sms(self, encrypted_notification): status=notification["status"], document_download_count=notification["document_download_count"], ) + # Only get here if save to the db was successful (i.e. first time) + if original_notification is None: + provider_task.apply_async([notification["id"]], queue=q) + current_app.logger.debug( + f"{notification['id']} has been persisted and sent to delivery queue." + ) - provider_task.apply_async([notification["id"]], queue=q) - current_app.logger.debug( - f"{notification['notification_type']} {notification['id']} has been persisted and sent to delivery queue." - ) except IntegrityError: - current_app.logger.info( + current_app.logger.warning( f"{notification['notification_type']} {notification['id']} already exists." ) + # If we don't have the return statement here, we will fall through and end + # up retrying because IntegrityError is a subclass of SQLAlchemyError + return except SQLAlchemyError: try: - self.retry(queue=QueueNames.RETRY) + self.retry(queue=QueueNames.RETRY, expires=Config.DEFAULT_REDIS_EXPIRE_TIME) except self.MaxRetriesExceededError: current_app.logger.exception( f"Max retry failed Failed to persist notification {notification['id']}", @@ -379,7 +416,11 @@ def handle_exception(task, notification, notification_id, exc): # This probably (hopefully) is not an issue with Redis as the celery backing store current_app.logger.exception("Retry" + retry_msg) try: - task.retry(queue=QueueNames.RETRY, exc=exc) + task.retry( + queue=QueueNames.RETRY, + exc=exc, + expires=Config.DEFAULT_REDIS_EXPIRE_TIME, + ) except task.MaxRetriesExceededError: current_app.logger.exception("Max retry failed" + retry_msg) @@ -428,7 +469,9 @@ def send_inbound_sms_to_service(self, inbound_sms_id, service_id): ) if not isinstance(e, HTTPError) or e.response.status_code >= 500: try: - self.retry(queue=QueueNames.RETRY) + self.retry( + queue=QueueNames.RETRY, expires=Config.DEFAULT_REDIS_EXPIRE_TIME + ) except self.MaxRetriesExceededError: current_app.logger.exception( "Retry: send_inbound_sms_to_service has retried the max number of" diff --git a/app/clients/__init__.py b/app/clients/__init__.py index 88565bd22..f185e45e2 100644 --- a/app/clients/__init__.py +++ b/app/clients/__init__.py @@ -13,12 +13,7 @@ AWS_CLIENT_CONFIG = Config( "addressing_style": "virtual", }, use_fips_endpoint=True, - # This is the default but just for doc sake - # there may come a time when increasing this helps - # with job cache management. - # max_pool_connections=10, - # Reducing to 7 connections due to BrokenPipeErrors - max_pool_connections=7, + max_pool_connections=50, # This should be equal or greater than our celery concurrency ) diff --git a/app/clients/cloudwatch/aws_cloudwatch.py b/app/clients/cloudwatch/aws_cloudwatch.py index 36bcf5dca..43bedbb35 100644 --- a/app/clients/cloudwatch/aws_cloudwatch.py +++ b/app/clients/cloudwatch/aws_cloudwatch.py @@ -1,15 +1,12 @@ import json import os import re -from datetime import timedelta from boto3 import client 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, utc_now class AwsCloudwatchClient(Client): @@ -49,48 +46,32 @@ class AwsCloudwatchClient(Client): def is_localstack(self): return self._is_localstack - def _get_log(self, my_filter, log_group_name, sent_at): + def _get_log(self, log_group_name, start, end): # Check all cloudwatch logs from the time the notification was sent (currently 5 minutes previously) until now - now = utc_now() - beginning = sent_at next_token = None all_log_events = [] - current_app.logger.info(f"START TIME {beginning} END TIME {now}") - # There has been a change somewhere and the time range we were previously using has become too - # narrow or wrong in some way, so events can't be found. For the time being, adjust by adding - # a buffer on each side of 12 hours. - TWELVE_HOURS = 12 * 60 * 60 * 1000 + while True: if next_token: response = self._client.filter_log_events( logGroupName=log_group_name, - filterPattern=my_filter, nextToken=next_token, - startTime=int(beginning.timestamp() * 1000) - TWELVE_HOURS, - endTime=int(now.timestamp() * 1000) + TWELVE_HOURS, + startTime=int(start.timestamp() * 1000), + endTime=int(end.timestamp() * 1000), ) else: response = self._client.filter_log_events( logGroupName=log_group_name, - filterPattern=my_filter, - startTime=int(beginning.timestamp() * 1000) - TWELVE_HOURS, - endTime=int(now.timestamp() * 1000) + TWELVE_HOURS, + startTime=int(start.timestamp() * 1000), + endTime=int(end.timestamp() * 1000), ) log_events = response.get("events", []) all_log_events.extend(log_events) - if len(log_events) > 0: - # We found it - - break next_token = response.get("nextToken") if not next_token: break return all_log_events - def _extract_account_number(self, ses_domain_arn): - account_number = ses_domain_arn.split(":") - return account_number - def warn_if_dev_is_opted_out(self, provider_response, notification_id): if ( "is opted out" in provider_response.lower() @@ -108,60 +89,108 @@ class AwsCloudwatchClient(Client): return logline return None - def check_sms(self, message_id, notification_id, created_at): + def _extract_account_number(self, ses_domain_arn): + account_number = ses_domain_arn.split(":") + return account_number + + def event_to_db_format(self, event): + + # massage the data into the form the db expects. When we switch + # from filter_log_events to log insights this will be convenient + if isinstance(event, str): + event = json.loads(event) + + # Don't trust AWS to always send the same JSON structure back + # However, if we don't get message_id and status we might as well blow up + # because it's pointless to continue + phone_carrier = self._aws_value_or_default(event, "delivery", "phoneCarrier") + provider_response = self._aws_value_or_default( + event, "delivery", "providerResponse" + ) + my_timestamp = self._aws_value_or_default(event, "notification", "timestamp") + return { + "notification.messageId": event["notification"]["messageId"], + "status": event["status"], + "delivery.phoneCarrier": phone_carrier, + "delivery.providerResponse": provider_response, + "@timestamp": my_timestamp, + } + + # Here is an example of how to get the events with log insights + # def do_log_insights(): + # query = """ + # fields @timestamp, status, message, recipient + # | filter status = "DELIVERED" + # | sort @timestamp asc + # """ + # temp_client = boto3.client( + # "logs", + # region_name="us-gov-west-1", + # aws_access_key_id=AWS_ACCESS_KEY_ID, + # aws_secret_access_key=AWS_SECRET_ACCESS_KEY, + # config=AWS_CLIENT_CONFIG, + # ) + # start = utc_now() + # end = utc_now - timedelta(hours=1) + # response = temp_client.start_query( + # logGroupName = LOG_GROUP_NAME_DELIVERED, + # startTime = int(start.timestamp()), + # endTime= int(end.timestamp()), + # queryString = query + + # ) + # query_id = response['queryId'] + # while True: + # result = temp_client.get_query_results(queryId=query_id) + # if result['status'] == 'Complete': + # break + # time.sleep(1) + + # delivery_receipts = [] + # for log in result['results']: + # receipt = {field['field']: field['value'] for field in log} + # delivery_receipts.append(receipt) + # print(receipt) + + # print(len(delivery_receipts)) + + # In the long run we want to use Log Insights because it is more efficient + # that filter_log_events. But we are blocked by a permissions issue in the broker. + # So for now, use filter_log_events and grab all log_events over a 10 minute interval, + # and run this on a schedule. + def check_delivery_receipts(self, start, end): region = cloud_config.sns_region - # TODO this clumsy approach to getting the account number will be fixed as part of notify-api #258 account_number = self._extract_account_number(cloud_config.ses_domain_arn) - - time_now = utc_now() log_group_name = f"sns/{region}/{account_number[4]}/DirectPublishToPhoneNumber" - filter_pattern = '{$.notification.messageId="XXXXX"}' - filter_pattern = filter_pattern.replace("XXXXX", message_id) - all_log_events = self._get_log(filter_pattern, log_group_name, created_at) - if all_log_events and len(all_log_events) > 0: - event = all_log_events[0] - message = json.loads(event["message"]) - self.warn_if_dev_is_opted_out( - message["delivery"]["providerResponse"], notification_id - ) - # 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"], - message["delivery"].get("phoneCarrier", "Unknown Carrier"), - ) - + delivered_event_set = self._get_receipts(log_group_name, start, end) + current_app.logger.info( + (f"Delivered message count: {len(delivered_event_set)}") + ) log_group_name = ( f"sns/{region}/{account_number[4]}/DirectPublishToPhoneNumber/Failure" ) - all_failed_events = self._get_log(filter_pattern, log_group_name, created_at) - if all_failed_events and len(all_failed_events) > 0: - event = all_failed_events[0] - message = json.loads(event["message"]) - self.warn_if_dev_is_opted_out( - message["delivery"]["providerResponse"], notification_id - ) + failed_event_set = self._get_receipts(log_group_name, start, end) + current_app.logger.info((f"Failed message count: {len(failed_event_set)}")) - current_app.logger.info( - hilite(f"FAILED: {message} for message_id {message_id}") - ) - return ( - "failure", - message["delivery"]["providerResponse"], - message["delivery"].get("phoneCarrier", "Unknown Carrier"), - ) + return delivered_event_set, failed_event_set - if time_now > (created_at + timedelta(hours=3)): - # see app/models.py Notification. This message corresponds to "permanent-failure", - # but we are copy/pasting here to avoid circular imports. - return "failure", "Unable to find carrier response." - raise NotificationTechnicalFailureException( - f"No event found for message_id {message_id} notification_id {notification_id}" - ) + def _get_receipts(self, log_group_name, start, end): + event_set = set() + all_events = self._get_log(log_group_name, start, end) + for event in all_events: + try: + actual_event = self.event_to_db_format(event["message"]) + event_set.add(json.dumps(actual_event)) + except Exception: + current_app.logger.exception( + f"Could not format delivery receipt {event} for db insert" + ) + return event_set + + def _aws_value_or_default(self, event, top_level, second_level): + if event.get(top_level) is None or event[top_level].get(second_level) is None: + my_var = "" + else: + my_var = event[top_level][second_level] + + return my_var diff --git a/app/clients/sms/aws_sns.py b/app/clients/sms/aws_sns.py index 8b5d6c963..d36af600c 100644 --- a/app/clients/sms/aws_sns.py +++ b/app/clients/sms/aws_sns.py @@ -63,12 +63,31 @@ class AwsSnsClient(SmsClient): } } + default_num = " ".join(self.current_app.config["AWS_US_TOLL_FREE_NUMBER"]) + if isinstance(sender, str): + non_scrubbable = " ".join(sender) + + self.current_app.logger.info( + f"notify-api-1385 sender {non_scrubbable} is a {type(sender)} default is a {type(default_num)}" + ) + else: + self.current_app.logger.warning( + f"notify-api-1385 sender is type {type(sender)}!! {sender}" + ) if self._valid_sender_number(sender): + self.current_app.logger.info( + f"notify-api-1385 use valid sender {non_scrubbable} instead of default {default_num}" + ) + attributes["AWS.MM.SMS.OriginationNumber"] = { "DataType": "String", "StringValue": sender, } else: + self.current_app.logger.info( + f"notify-api-1385 use default {default_num} instead of invalid sender" + ) + attributes["AWS.MM.SMS.OriginationNumber"] = { "DataType": "String", "StringValue": self.current_app.config["AWS_US_TOLL_FREE_NUMBER"], diff --git a/app/commands.py b/app/commands.py index 5580e7632..b865a5363 100644 --- a/app/commands.py +++ b/app/commands.py @@ -12,7 +12,7 @@ from click_datetime import Datetime as click_dt from faker import Faker from flask import current_app, json from notifications_python_client.authentication import create_jwt_token -from sqlalchemy import and_, text +from sqlalchemy import and_, select, text, update from sqlalchemy.exc import IntegrityError from sqlalchemy.orm.exc import NoResultFound @@ -123,8 +123,8 @@ def purge_functional_test_data(user_email_prefix): if getenv("NOTIFY_ENVIRONMENT", "") not in ["development", "test"]: current_app.logger.error("Can only be run in development") return - - users = User.query.filter(User.email_address.like(f"{user_email_prefix}%")).all() + stmt = select(User).where(User.email_address.like(f"{user_email_prefix}%")) + users = db.session.execute(stmt).scalars().all() for usr in users: # Make sure the full email includes a uuid in it # Just in case someone decides to use a similar email address. @@ -338,9 +338,10 @@ def populate_organizations_from_file(file_name): email_branding = None email_branding_column = columns[5].strip() if len(email_branding_column) > 0: - email_branding = EmailBranding.query.filter( + stmt = select(EmailBranding).where( EmailBranding.name == email_branding_column - ).one() + ) + email_branding = db.session.execute(stmt).scalars().one() data = { "name": columns[0], "active": True, @@ -406,10 +407,14 @@ def populate_organization_agreement_details_from_file(file_name): @notify_command(name="associate-services-to-organizations") def associate_services_to_organizations(): - services = Service.get_history_model().query.filter_by(version=1).all() + stmt = select(Service.get_history_model()).where( + Service.get_history_model().version == 1 + ) + services = db.session.execute(stmt).scalars().all() for s in services: - created_by_user = User.query.filter_by(id=s.created_by_id).first() + stmt = select(User).where(User.id == s.created_by_id) + created_by_user = db.session.execute(stmt).scalars().first() organization = dao_get_organization_by_email_address( created_by_user.email_address ) @@ -467,15 +472,16 @@ def populate_go_live(file_name): @notify_command(name="fix-billable-units") def fix_billable_units(): - query = Notification.query.filter( + stmt = select(Notification).where( Notification.notification_type == NotificationType.SMS, Notification.status != NotificationStatus.CREATED, Notification.sent_at == None, # noqa Notification.billable_units == 0, Notification.key_type != KeyType.TEST, ) + all = db.session.execute(stmt).scalars().all() - for notification in query.all(): + for notification in all: template_model = dao_get_template_by_id( notification.template_id, notification.template_version ) @@ -490,9 +496,12 @@ def fix_billable_units(): f"Updating notification: {notification.id} with {template.fragment_count} billable_units" ) - Notification.query.filter(Notification.id == notification.id).update( - {"billable_units": template.fragment_count} + stmt = ( + update(Notification) + .where(Notification.id == notification.id) + .values({"billable_units": template.fragment_count}) ) + db.session.execute(stmt) db.session.commit() current_app.logger.info("End fix_billable_units") @@ -637,8 +646,9 @@ def populate_annual_billing_with_defaults(year, missing_services_only): This is useful to ensure all services start the new year with the correct annual billing. """ if missing_services_only: - active_services = ( - Service.query.filter(Service.active) + stmt = ( + select(Service) + .where(Service.active) .outerjoin( AnnualBilling, and_( @@ -646,15 +656,16 @@ def populate_annual_billing_with_defaults(year, missing_services_only): AnnualBilling.financial_year_start == year, ), ) - .filter(AnnualBilling.id == None) # noqa - .all() + .where(AnnualBilling.id == None) # noqa ) + active_services = db.session.execute(stmt).scalars().all() else: - active_services = Service.query.filter(Service.active).all() + stmt = select(Service).where(Service.active) + active_services = db.session.execute(stmt).scalars().all() previous_year = year - 1 services_with_zero_free_allowance = ( db.session.query(AnnualBilling.service_id) - .filter( + .where( AnnualBilling.financial_year_start == previous_year, AnnualBilling.free_sms_fragment_limit == 0, ) @@ -750,7 +761,8 @@ def create_user_jwt(token): def _update_template(id, name, template_type, content, subject): - template = Template.query.filter_by(id=id).first() + stmt = select(Template).where(Template.id == id) + template = db.session.execute(stmt).scalars().first() if not template: template = Template(id=id) template.service_id = "d6aa2c68-a2d9-4437-ab19-3ae8eb202553" @@ -761,7 +773,8 @@ def _update_template(id, name, template_type, content, subject): template.content = "\n".join(content) template.subject = subject - history = TemplateHistory.query.filter_by(id=id).first() + stmt = select(TemplateHistory).where(TemplateHistory.id == id) + history = db.session.execute(stmt).scalars().first() if not history: history = TemplateHistory(id=id) history.service_id = "d6aa2c68-a2d9-4437-ab19-3ae8eb202553" @@ -776,6 +789,17 @@ def _update_template(id, name, template_type, content, subject): db.session.commit() +@notify_command(name="clear-redis-list") +@click.option("-n", "--name_of_list", required=True) +def clear_redis_list(name_of_list): + my_len_before = redis_store.llen(name_of_list) + redis_store.ltrim(name_of_list, 1, 0) + my_len_after = redis_store.llen(name_of_list) + current_app.logger.info( + f"Cleared redis list {name_of_list}. Before: {my_len_before} after {my_len_after}" + ) + + @notify_command(name="update-templates") def update_templates(): with open(current_app.config["CONFIG_FILES"] + "/templates.json") as f: @@ -822,6 +846,19 @@ def create_new_service(name, message_limit, restricted, email_from, created_by_i db.session.rollback() +@notify_command(name="get-service-sender-phones") +@click.option("-s", "--service_id", required=True, prompt=True) +def get_service_sender_phones(service_id): + sender_phone_numbers = """ + select sms_sender, is_default + from service_sms_senders + where service_id = :service_id + """ + rows = db.session.execute(text(sender_phone_numbers), {"service_id": service_id}) + for row in rows: + print(row) + + @notify_command(name="promote-user-to-platform-admin") @click.option("-u", "--user-email-address", required=True, prompt=True) def promote_user_to_platform_admin(user_email_address): diff --git a/app/config.py b/app/config.py index 4a8c880d3..2cabab9b7 100644 --- a/app/config.py +++ b/app/config.py @@ -2,10 +2,12 @@ import json from datetime import datetime, timedelta from os import getenv, path +from boto3 import Session from celery.schedules import crontab from kombu import Exchange, Queue import notifications_utils +from app.clients import AWS_CLIENT_CONFIG from app.cloudfoundry_config import cloud_config @@ -51,8 +53,16 @@ class TaskNames(object): SCAN_FILE = "scan-file" +session = Session( + aws_access_key_id=getenv("CSV_AWS_ACCESS_KEY_ID"), + aws_secret_access_key=getenv("CSV_AWS_SECRET_ACCESS_KEY"), + region_name=getenv("CSV_AWS_REGION"), +) + + class Config(object): NOTIFY_APP_NAME = "api" + DEFAULT_REDIS_EXPIRE_TIME = 4 * 24 * 60 * 60 NOTIFY_ENVIRONMENT = getenv("NOTIFY_ENVIRONMENT", "development") # URL of admin app ADMIN_BASE_URL = getenv("ADMIN_BASE_URL", "http://localhost:6012") @@ -80,7 +90,7 @@ class Config(object): SQLALCHEMY_DATABASE_URI = cloud_config.database_url SQLALCHEMY_RECORD_QUERIES = False SQLALCHEMY_TRACK_MODIFICATIONS = False - SQLALCHEMY_POOL_SIZE = int(getenv("SQLALCHEMY_POOL_SIZE", 5)) + SQLALCHEMY_POOL_SIZE = int(getenv("SQLALCHEMY_POOL_SIZE", 40)) SQLALCHEMY_POOL_TIMEOUT = 30 SQLALCHEMY_POOL_RECYCLE = 300 SQLALCHEMY_STATEMENT_TIMEOUT = 1200 @@ -165,7 +175,13 @@ class Config(object): current_minute = (datetime.now().minute + 1) % 60 + S3_CLIENT = session.client("s3") + S3_RESOURCE = session.resource("s3", config=AWS_CLIENT_CONFIG) + CELERY = { + "worker_max_tasks_per_child": 500, + "task_ignore_result": True, + "result_persistent": False, "broker_url": REDIS_URL, "broker_transport_options": { "visibility_timeout": 310, @@ -194,6 +210,21 @@ class Config(object): "schedule": timedelta(minutes=63), "options": {"queue": QueueNames.PERIODIC}, }, + "process-delivery-receipts": { + "task": "process-delivery-receipts", + "schedule": timedelta(minutes=2), + "options": {"queue": QueueNames.PERIODIC}, + }, + "cleanup-delivery-receipts": { + "task": "cleanup-delivery-receipts", + "schedule": timedelta(minutes=82), + "options": {"queue": QueueNames.PERIODIC}, + }, + "batch-insert-notifications": { + "task": "batch-insert-notifications", + "schedule": 10.0, + "options": {"queue": QueueNames.PERIODIC}, + }, "expire-or-delete-invitations": { "task": "expire-or-delete-invitations", "schedule": timedelta(minutes=66), @@ -308,7 +339,7 @@ class Config(object): FREE_SMS_TIER_FRAGMENT_COUNT = 250000 - TOTAL_MESSAGE_LIMIT = 250000 + TOTAL_MESSAGE_LIMIT = 100000 DAILY_MESSAGE_LIMIT = notifications_utils.DAILY_MESSAGE_LIMIT diff --git a/app/dao/annual_billing_dao.py b/app/dao/annual_billing_dao.py index 306a2dd86..c740c627a 100644 --- a/app/dao/annual_billing_dao.py +++ b/app/dao/annual_billing_dao.py @@ -29,8 +29,8 @@ def dao_create_or_update_annual_billing_for_year( def dao_get_annual_billing(service_id): stmt = ( select(AnnualBilling) - .filter_by( - service_id=service_id, + .where( + AnnualBilling.service_id == service_id, ) .order_by(AnnualBilling.financial_year_start) ) @@ -43,7 +43,7 @@ def dao_update_annual_billing_for_future_years( ): stmt = ( update(AnnualBilling) - .filter( + .where( AnnualBilling.service_id == service_id, AnnualBilling.financial_year_start > financial_year_start, ) @@ -57,8 +57,9 @@ def dao_get_free_sms_fragment_limit_for_year(service_id, financial_year_start=No if not financial_year_start: financial_year_start = get_current_calendar_year_start_year() - stmt = select(AnnualBilling).filter_by( - service_id=service_id, financial_year_start=financial_year_start + stmt = select(AnnualBilling).where( + AnnualBilling.service_id == service_id, + AnnualBilling.financial_year_start == financial_year_start, ) return db.session.execute(stmt).scalars().first() @@ -66,8 +67,8 @@ def dao_get_free_sms_fragment_limit_for_year(service_id, financial_year_start=No def dao_get_all_free_sms_fragment_limit(service_id): stmt = ( select(AnnualBilling) - .filter_by( - service_id=service_id, + .where( + AnnualBilling.service_id == service_id, ) .order_by(AnnualBilling.financial_year_start) ) diff --git a/app/dao/api_key_dao.py b/app/dao/api_key_dao.py index 06266ab18..205b0fb8c 100644 --- a/app/dao/api_key_dao.py +++ b/app/dao/api_key_dao.py @@ -1,7 +1,7 @@ import uuid from datetime import timedelta -from sqlalchemy import func, or_ +from sqlalchemy import func, or_, select from app import db from app.dao.dao_utils import autocommit, version_class @@ -23,31 +23,61 @@ def save_model_api_key(api_key): @autocommit @version_class(ApiKey) def expire_api_key(service_id, api_key_id): - api_key = ApiKey.query.filter_by(id=api_key_id, service_id=service_id).one() + api_key = ( + db.session.execute( + select(ApiKey).where( + ApiKey.id == api_key_id, ApiKey.service_id == service_id + ) + ) + .scalars() + .one() + ) api_key.expiry_date = utc_now() db.session.add(api_key) def get_model_api_keys(service_id, id=None): if id: - return ApiKey.query.filter_by( - id=id, service_id=service_id, expiry_date=None - ).one() + return ( + db.session.execute( + select(ApiKey).where( + ApiKey.id == id, + ApiKey.service_id == service_id, + ApiKey.expiry_date == None, # noqa + ) + ) + .scalars() + .one() + ) seven_days_ago = utc_now() - timedelta(days=7) - return ApiKey.query.filter( - or_( - ApiKey.expiry_date == None, # noqa - func.date(ApiKey.expiry_date) > seven_days_ago, # noqa - ), - ApiKey.service_id == service_id, - ).all() + return ( + db.session.execute( + select(ApiKey).where( + or_( + ApiKey.expiry_date == None, # noqa + func.date(ApiKey.expiry_date) > seven_days_ago, # noqa + ), + ApiKey.service_id == service_id, + ) + ) + .scalars() + .all() + ) def get_unsigned_secrets(service_id): """ This method can only be exposed to the Authentication of the api calls. """ - api_keys = ApiKey.query.filter_by(service_id=service_id, expiry_date=None).all() + api_keys = ( + db.session.execute( + select(ApiKey).where( + ApiKey.service_id == service_id, ApiKey.expiry_date == None # noqa + ) + ) + .scalars() + .all() + ) keys = [x.secret for x in api_keys] return keys @@ -56,5 +86,13 @@ def get_unsigned_secret(key_id): """ This method can only be exposed to the Authentication of the api calls. """ - api_key = ApiKey.query.filter_by(id=key_id, expiry_date=None).one() + api_key = ( + db.session.execute( + select(ApiKey).where( + ApiKey.id == key_id, ApiKey.expiry_date == None # noqa + ) + ) + .scalars() + .one() + ) return api_key.secret diff --git a/app/dao/complaint_dao.py b/app/dao/complaint_dao.py index 63b7487fb..c306ee0fd 100644 --- a/app/dao/complaint_dao.py +++ b/app/dao/complaint_dao.py @@ -33,7 +33,7 @@ def fetch_paginated_complaints(page=1): def fetch_complaints_by_service(service_id): stmt = ( select(Complaint) - .filter_by(service_id=service_id) + .where(Complaint.service_id == service_id) .order_by(desc(Complaint.created_at)) ) return db.session.execute(stmt).scalars().all() @@ -46,6 +46,6 @@ def fetch_count_of_complaints(start_date, end_date): stmt = ( select(func.count()) .select_from(Complaint) - .filter(Complaint.created_at >= start_date, Complaint.created_at < end_date) + .where(Complaint.created_at >= start_date, Complaint.created_at < end_date) ) return db.session.execute(stmt).scalar() or 0 diff --git a/app/dao/email_branding_dao.py b/app/dao/email_branding_dao.py index 1dedd78a8..bb41ceadf 100644 --- a/app/dao/email_branding_dao.py +++ b/app/dao/email_branding_dao.py @@ -1,18 +1,32 @@ +from sqlalchemy import select + from app import db from app.dao.dao_utils import autocommit from app.models import EmailBranding def dao_get_email_branding_options(): - return EmailBranding.query.all() + return db.session.execute(select(EmailBranding)).scalars().all() def dao_get_email_branding_by_id(email_branding_id): - return EmailBranding.query.filter_by(id=email_branding_id).one() + return ( + db.session.execute( + select(EmailBranding).where(EmailBranding.id == email_branding_id) + ) + .scalars() + .one() + ) def dao_get_email_branding_by_name(email_branding_name): - return EmailBranding.query.filter_by(name=email_branding_name).first() + return ( + db.session.execute( + select(EmailBranding).where(EmailBranding.name == email_branding_name) + ) + .scalars() + .first() + ) @autocommit diff --git a/app/dao/fact_billing_dao.py b/app/dao/fact_billing_dao.py index 132f62bf2..bcb685c52 100644 --- a/app/dao/fact_billing_dao.py +++ b/app/dao/fact_billing_dao.py @@ -52,7 +52,7 @@ def fetch_sms_free_allowance_remainder_until_date(end_date): FactBilling.notification_type == NotificationType.SMS, ), ) - .filter( + .where( AnnualBilling.financial_year_start == billing_year, ) .group_by( @@ -65,7 +65,7 @@ def fetch_sms_free_allowance_remainder_until_date(end_date): def fetch_sms_billing_for_all_services(start_date, end_date): # ASSUMPTION: AnnualBilling has been populated for year. - allowance_left_at_start_date_query = fetch_sms_free_allowance_remainder_until_date( + allowance_left_at_start_date_stmt = fetch_sms_free_allowance_remainder_until_date( start_date ).subquery() @@ -76,14 +76,14 @@ def fetch_sms_billing_for_all_services(start_date, end_date): # subtract sms_billable_units units accrued since report's start date to get up-to-date # allowance remainder sms_allowance_left = func.greatest( - allowance_left_at_start_date_query.c.sms_remainder - sms_billable_units, 0 + allowance_left_at_start_date_stmt.c.sms_remainder - sms_billable_units, 0 ) # billable units here are for period between start date and end date only, so to see # how many are chargeable, we need to see how much free allowance was used up in the # period up until report's start date and then do a subtraction chargeable_sms = func.greatest( - sms_billable_units - allowance_left_at_start_date_query.c.sms_remainder, 0 + sms_billable_units - allowance_left_at_start_date_stmt.c.sms_remainder, 0 ) sms_cost = chargeable_sms * FactBilling.rate @@ -93,7 +93,7 @@ def fetch_sms_billing_for_all_services(start_date, end_date): Organization.id.label("organization_id"), Service.name.label("service_name"), Service.id.label("service_id"), - allowance_left_at_start_date_query.c.free_sms_fragment_limit, + allowance_left_at_start_date_stmt.c.free_sms_fragment_limit, FactBilling.rate.label("sms_rate"), sms_allowance_left.label("sms_remainder"), sms_billable_units.label("sms_billable_units"), @@ -102,15 +102,15 @@ def fetch_sms_billing_for_all_services(start_date, end_date): ) .select_from(Service) .outerjoin( - allowance_left_at_start_date_query, - Service.id == allowance_left_at_start_date_query.c.service_id, + allowance_left_at_start_date_stmt, + Service.id == allowance_left_at_start_date_stmt.c.service_id, ) .outerjoin(Service.organization) .join( FactBilling, FactBilling.service_id == Service.id, ) - .filter( + .where( FactBilling.local_date >= start_date, FactBilling.local_date <= end_date, FactBilling.notification_type == NotificationType.SMS, @@ -120,8 +120,8 @@ def fetch_sms_billing_for_all_services(start_date, end_date): Organization.id, Service.id, Service.name, - allowance_left_at_start_date_query.c.free_sms_fragment_limit, - allowance_left_at_start_date_query.c.sms_remainder, + allowance_left_at_start_date_stmt.c.free_sms_fragment_limit, + allowance_left_at_start_date_stmt.c.sms_remainder, FactBilling.rate, ) .order_by(Organization.name, Service.name) @@ -151,15 +151,15 @@ def fetch_billing_totals_for_year(service_id, year): union( *[ select( - query.c.notification_type.label("notification_type"), - query.c.rate.label("rate"), - func.sum(query.c.notifications_sent).label("notifications_sent"), - func.sum(query.c.chargeable_units).label("chargeable_units"), - func.sum(query.c.cost).label("cost"), - func.sum(query.c.free_allowance_used).label("free_allowance_used"), - func.sum(query.c.charged_units).label("charged_units"), - ).group_by(query.c.rate, query.c.notification_type) - for query in [ + stmt.c.notification_type.label("notification_type"), + stmt.c.rate.label("rate"), + func.sum(stmt.c.notifications_sent).label("notifications_sent"), + func.sum(stmt.c.chargeable_units).label("chargeable_units"), + func.sum(stmt.c.cost).label("cost"), + func.sum(stmt.c.free_allowance_used).label("free_allowance_used"), + func.sum(stmt.c.charged_units).label("charged_units"), + ).group_by(stmt.c.rate, stmt.c.notification_type) + for stmt in [ query_service_sms_usage_for_year(service_id, year).subquery(), query_service_email_usage_for_year(service_id, year).subquery(), ] @@ -206,22 +206,22 @@ def fetch_monthly_billing_for_year(service_id, year): union( *[ select( - query.c.rate.label("rate"), - query.c.notification_type.label("notification_type"), - func.date_trunc("month", query.c.local_date) + stmt.c.rate.label("rate"), + stmt.c.notification_type.label("notification_type"), + func.date_trunc("month", stmt.c.local_date) .cast(Date) .label("month"), - func.sum(query.c.notifications_sent).label("notifications_sent"), - func.sum(query.c.chargeable_units).label("chargeable_units"), - func.sum(query.c.cost).label("cost"), - func.sum(query.c.free_allowance_used).label("free_allowance_used"), - func.sum(query.c.charged_units).label("charged_units"), + func.sum(stmt.c.notifications_sent).label("notifications_sent"), + func.sum(stmt.c.chargeable_units).label("chargeable_units"), + func.sum(stmt.c.cost).label("cost"), + func.sum(stmt.c.free_allowance_used).label("free_allowance_used"), + func.sum(stmt.c.charged_units).label("charged_units"), ).group_by( - query.c.rate, - query.c.notification_type, + stmt.c.rate, + stmt.c.notification_type, "month", ) - for query in [ + for stmt in [ query_service_sms_usage_for_year(service_id, year).subquery(), query_service_email_usage_for_year(service_id, year).subquery(), ] @@ -250,7 +250,7 @@ def query_service_email_usage_for_year(service_id, year): FactBilling.billable_units.label("charged_units"), ) .select_from(FactBilling) - .filter( + .where( FactBilling.service_id == service_id, FactBilling.local_date >= year_start, FactBilling.local_date <= year_end, @@ -338,7 +338,7 @@ def query_service_sms_usage_for_year(service_id, year): ) .select_from(FactBilling) .join(AnnualBilling, AnnualBilling.service_id == service_id) - .filter( + .where( FactBilling.service_id == service_id, FactBilling.local_date >= year_start, FactBilling.local_date <= year_end, @@ -355,7 +355,7 @@ def delete_billing_data_for_service_for_day(process_day, service_id): Returns how many rows were deleted """ - stmt = delete(FactBilling).filter( + stmt = delete(FactBilling).where( FactBilling.local_date == process_day, FactBilling.service_id == service_id ) result = db.session.execute(stmt) @@ -371,9 +371,9 @@ def fetch_billing_data_for_day(process_day, service_id=None, check_permissions=F ) transit_data = [] if not service_id: - services = Service.query.all() + services = db.session.execute(select(Service)).scalars().all() else: - services = [Service.query.get(service_id)] + services = [db.session.get(Service, service_id)] for service in services: for notification_type in (NotificationType.SMS, NotificationType.EMAIL): @@ -403,7 +403,7 @@ def _query_for_billing_data(notification_type, start_date, end_date, service): func.count().label("notifications_sent"), ) .select_from(NotificationAllTimeView) - .filter( + .where( NotificationAllTimeView.status.in_( NotificationStatus.sent_email_types() ), @@ -438,7 +438,7 @@ def _query_for_billing_data(notification_type, start_date, end_date, service): func.count().label("notifications_sent"), ) .select_from(NotificationAllTimeView) - .filter( + .where( NotificationAllTimeView.status.in_( NotificationStatus.billable_sms_types() ), @@ -474,7 +474,7 @@ def get_service_ids_that_need_billing_populated(start_date, end_date): stmt = ( select(NotificationHistory.service_id) .select_from(NotificationHistory) - .filter( + .where( NotificationHistory.created_at >= start_date, NotificationHistory.created_at <= end_date, NotificationHistory.notification_type.in_( @@ -568,7 +568,7 @@ def fetch_email_usage_for_organization(organization_id, start_date, end_date): FactBilling, FactBilling.service_id == Service.id, ) - .filter( + .where( FactBilling.local_date >= start_date, FactBilling.local_date <= end_date, FactBilling.notification_type == NotificationType.EMAIL, @@ -586,12 +586,12 @@ def fetch_email_usage_for_organization(organization_id, start_date, end_date): def fetch_sms_billing_for_organization(organization_id, financial_year): # ASSUMPTION: AnnualBilling has been populated for year. - ft_billing_subquery = query_organization_sms_usage_for_year( + ft_billing_substmt = query_organization_sms_usage_for_year( organization_id, financial_year ).subquery() sms_billable_units = func.sum( - func.coalesce(ft_billing_subquery.c.chargeable_units, 0) + func.coalesce(ft_billing_substmt.c.chargeable_units, 0) ) # subtract sms_billable_units units accrued since report's start date to get up-to-date @@ -600,8 +600,8 @@ def fetch_sms_billing_for_organization(organization_id, financial_year): AnnualBilling.free_sms_fragment_limit - sms_billable_units, 0 ) - chargeable_sms = func.sum(ft_billing_subquery.c.charged_units) - sms_cost = func.sum(ft_billing_subquery.c.cost) + chargeable_sms = func.sum(ft_billing_substmt.c.charged_units) + sms_cost = func.sum(ft_billing_substmt.c.cost) query = ( select( @@ -622,8 +622,8 @@ def fetch_sms_billing_for_organization(organization_id, financial_year): AnnualBilling.financial_year_start == financial_year, ), ) - .outerjoin(ft_billing_subquery, Service.id == ft_billing_subquery.c.service_id) - .filter( + .outerjoin(ft_billing_substmt, Service.id == ft_billing_substmt.c.service_id) + .where( Service.organization_id == organization_id, Service.restricted.is_(False) ) .group_by(Service.id, Service.name, AnnualBilling.free_sms_fragment_limit) @@ -688,7 +688,7 @@ def query_organization_sms_usage_for_year(organization_id, year): FactBilling.notification_type == NotificationType.SMS, ), ) - .filter( + .where( Service.organization_id == organization_id, AnnualBilling.financial_year_start == year, ) @@ -812,9 +812,7 @@ def fetch_daily_volumes_for_platform(start_date, end_date): ) ).label("email_totals"), ) - .filter( - FactBilling.local_date >= start_date, FactBilling.local_date <= end_date - ) + .where(FactBilling.local_date >= start_date, FactBilling.local_date <= end_date) .group_by(FactBilling.local_date, FactBilling.notification_type) .subquery() ) @@ -857,7 +855,7 @@ def fetch_daily_sms_provider_volumes_for_platform(start_date, end_date): ).label("sms_cost"), ) .select_from(FactBilling) - .filter( + .where( FactBilling.notification_type == NotificationType.SMS, FactBilling.local_date >= start_date, FactBilling.local_date <= end_date, @@ -912,9 +910,7 @@ def fetch_volumes_by_service(start_date, end_date): ).label("email_totals"), ) .select_from(FactBilling) - .filter( - FactBilling.local_date >= start_date, FactBilling.local_date <= end_date - ) + .where(FactBilling.local_date >= start_date, FactBilling.local_date <= end_date) .group_by( FactBilling.local_date, FactBilling.service_id, @@ -930,7 +926,7 @@ def fetch_volumes_by_service(start_date, end_date): AnnualBilling.free_sms_fragment_limit, ) .select_from(AnnualBilling) - .filter(AnnualBilling.financial_year_start <= year_end_date) + .where(AnnualBilling.financial_year_start <= year_end_date) .group_by(AnnualBilling.service_id, AnnualBilling.free_sms_fragment_limit) .subquery() ) @@ -957,7 +953,7 @@ def fetch_volumes_by_service(start_date, end_date): .outerjoin( # include services without volume volume_stats, Service.id == volume_stats.c.service_id ) - .filter( + .where( Service.restricted.is_(False), Service.count_as_live.is_(True), Service.active.is_(True), diff --git a/app/dao/fact_notification_status_dao.py b/app/dao/fact_notification_status_dao.py index 4b238642e..52a691453 100644 --- a/app/dao/fact_notification_status_dao.py +++ b/app/dao/fact_notification_status_dao.py @@ -33,7 +33,7 @@ def update_fact_notification_status(process_day, notification_type, service_id): end_date = get_midnight_in_utc(process_day + timedelta(days=1)) # delete any existing rows in case some no longer exist e.g. if all messages are sent - stmt = delete(FactNotificationStatus).filter( + stmt = delete(FactNotificationStatus).where( FactNotificationStatus.local_date == process_day, FactNotificationStatus.notification_type == notification_type, FactNotificationStatus.service_id == service_id, @@ -55,7 +55,7 @@ def update_fact_notification_status(process_day, notification_type, service_id): func.count().label("notification_count"), ) .select_from(NotificationAllTimeView) - .filter( + .where( NotificationAllTimeView.created_at >= start_date, NotificationAllTimeView.created_at < end_date, NotificationAllTimeView.notification_type == notification_type, @@ -97,7 +97,7 @@ def fetch_notification_status_for_service_by_month(start_date, end_date, service func.count(NotificationAllTimeView.id).label("count"), ) .select_from(NotificationAllTimeView) - .filter( + .where( NotificationAllTimeView.service_id == service_id, NotificationAllTimeView.created_at >= start_date, NotificationAllTimeView.created_at < end_date, @@ -122,7 +122,7 @@ def fetch_notification_status_for_service_for_day(fetch_day, service_id): func.count().label("count"), ) .select_from(Notification) - .filter( + .where( Notification.created_at >= get_midnight_in_utc(fetch_day), Notification.created_at < get_midnight_in_utc(fetch_day + timedelta(days=1)), @@ -191,7 +191,7 @@ def fetch_notification_status_for_service_for_today_and_7_previous_days( all_stats_alias = aliased(all_stats_union, name="all_stats") # Final query with optional template joins - query = select( + stmt = select( *( [ TemplateFolder.name.label("folder"), @@ -214,8 +214,8 @@ def fetch_notification_status_for_service_for_today_and_7_previous_days( ) if by_template: - query = ( - query.join(Template, all_stats_alias.c.template_id == Template.id) + stmt = ( + stmt.join(Template, all_stats_alias.c.template_id == Template.id) .join(User, Template.created_by_id == User.id) .outerjoin( template_folder_map, Template.id == template_folder_map.c.template_id @@ -227,7 +227,7 @@ def fetch_notification_status_for_service_for_today_and_7_previous_days( ) # Group by all necessary fields except date_used - query = query.group_by( + stmt = stmt.group_by( *( [ TemplateFolder.name, @@ -245,7 +245,7 @@ def fetch_notification_status_for_service_for_today_and_7_previous_days( ) # Execute the query using Flask-SQLAlchemy's session - result = db.session.execute(query) + result = db.session.execute(stmt) return result.mappings().all() @@ -260,7 +260,7 @@ def fetch_notification_status_totals_for_all_services(start_date, end_date): func.sum(FactNotificationStatus.notification_count).label("count"), ) .select_from(FactNotificationStatus) - .filter( + .where( FactNotificationStatus.local_date >= start_date, FactNotificationStatus.local_date <= end_date, ) @@ -279,7 +279,7 @@ def fetch_notification_status_totals_for_all_services(start_date, end_date): Notification.key_type.cast(db.Text), func.count().label("count"), ) - .filter(Notification.created_at >= today) + .where(Notification.created_at >= today) .group_by( Notification.notification_type, Notification.status, @@ -313,7 +313,7 @@ def fetch_notification_statuses_for_job(job_id): func.sum(FactNotificationStatus.notification_count).label("count"), ) .select_from(FactNotificationStatus) - .filter( + .where( FactNotificationStatus.job_id == job_id, ) .group_by(FactNotificationStatus.notification_status) @@ -338,7 +338,7 @@ def fetch_stats_for_all_services_by_date_range( func.sum(FactNotificationStatus.notification_count).label("count"), ) .select_from(FactNotificationStatus) - .filter( + .where( FactNotificationStatus.local_date >= start_date, FactNotificationStatus.local_date <= end_date, FactNotificationStatus.service_id == Service.id, @@ -357,11 +357,11 @@ def fetch_stats_for_all_services_by_date_range( ) ) if not include_from_test_key: - stats = stats.filter(FactNotificationStatus.key_type != KeyType.TEST) + stats = stats.where(FactNotificationStatus.key_type != KeyType.TEST) if start_date <= utc_now().date() <= end_date: today = get_midnight_in_utc(utc_now()) - subquery = ( + substmt = ( select( Notification.notification_type.label("notification_type"), Notification.status.label("status"), @@ -369,7 +369,7 @@ def fetch_stats_for_all_services_by_date_range( func.count(Notification.id).label("count"), ) .select_from(Notification) - .filter(Notification.created_at >= today) + .where(Notification.created_at >= today) .group_by( Notification.notification_type, Notification.status, @@ -377,8 +377,8 @@ def fetch_stats_for_all_services_by_date_range( ) ) if not include_from_test_key: - subquery = subquery.filter(Notification.key_type != KeyType.TEST) - subquery = subquery.subquery() + substmt = substmt.where(Notification.key_type != KeyType.TEST) + substmt = substmt.subquery() stats_for_today = select( Service.id.label("service_id"), @@ -386,10 +386,10 @@ def fetch_stats_for_all_services_by_date_range( Service.restricted.label("restricted"), Service.active.label("active"), Service.created_at.label("created_at"), - subquery.c.notification_type.cast(db.Text).label("notification_type"), - subquery.c.status.cast(db.Text).label("status"), - subquery.c.count.label("count"), - ).outerjoin(subquery, subquery.c.service_id == Service.id) + substmt.c.notification_type.cast(db.Text).label("notification_type"), + substmt.c.status.cast(db.Text).label("status"), + substmt.c.count.label("count"), + ).outerjoin(substmt, substmt.c.service_id == Service.id) all_stats_table = stats.union_all(stats_for_today).subquery() query = ( @@ -435,7 +435,7 @@ def fetch_monthly_template_usage_for_service(start_date, end_date, service_id): func.sum(FactNotificationStatus.notification_count).label("count"), ) .join(Template, FactNotificationStatus.template_id == Template.id) - .filter( + .where( FactNotificationStatus.service_id == service_id, FactNotificationStatus.local_date >= start_date, FactNotificationStatus.local_date <= end_date, @@ -473,7 +473,7 @@ def fetch_monthly_template_usage_for_service(start_date, end_date, service_id): Template, Notification.template_id == Template.id, ) - .filter( + .where( Notification.created_at >= today, Notification.service_id == service_id, Notification.key_type != KeyType.TEST, @@ -515,7 +515,7 @@ def fetch_monthly_template_usage_for_service(start_date, end_date, service_id): def get_total_notifications_for_date_range(start_date, end_date): - query = ( + stmt = ( select( FactNotificationStatus.local_date.label("local_date"), func.sum( @@ -539,18 +539,18 @@ def get_total_notifications_for_date_range(start_date, end_date): ) ).label("sms"), ) - .filter( + .where( FactNotificationStatus.key_type != KeyType.TEST, ) .group_by(FactNotificationStatus.local_date) .order_by(FactNotificationStatus.local_date) ) if start_date and end_date: - query = query.filter( + stmt = stmt.where( FactNotificationStatus.local_date >= start_date, FactNotificationStatus.local_date <= end_date, ) - return db.session.execute(query).all() + return db.session.execute(stmt).all() def fetch_monthly_notification_statuses_per_service(start_date, end_date): @@ -629,7 +629,7 @@ def fetch_monthly_notification_statuses_per_service(start_date, end_date): ).label("count_sent"), ) .join(Service, FactNotificationStatus.service_id == Service.id) - .filter( + .where( FactNotificationStatus.notification_status != NotificationStatus.CREATED, Service.active.is_(True), FactNotificationStatus.key_type != KeyType.TEST, diff --git a/app/dao/fact_processing_time_dao.py b/app/dao/fact_processing_time_dao.py index af8efcf10..3fb513c9d 100644 --- a/app/dao/fact_processing_time_dao.py +++ b/app/dao/fact_processing_time_dao.py @@ -1,3 +1,4 @@ +from sqlalchemy import select from sqlalchemy.dialects.postgresql import insert from sqlalchemy.sql.expression import case @@ -34,7 +35,7 @@ def insert_update_processing_time(processing_time): def get_processing_time_percentage_for_date_range(start_date, end_date): query = ( - db.session.query( + select( FactProcessingTime.local_date.cast(db.Text).label("date"), FactProcessingTime.messages_total, FactProcessingTime.messages_within_10_secs, @@ -52,11 +53,11 @@ def get_processing_time_percentage_for_date_range(start_date, end_date): (FactProcessingTime.messages_total == 0, 100.0), ).label("percentage"), ) - .filter( + .where( FactProcessingTime.local_date >= start_date, FactProcessingTime.local_date <= end_date, ) .order_by(FactProcessingTime.local_date) ) - return query.all() + return db.session.execute(query).all() diff --git a/app/dao/inbound_numbers_dao.py b/app/dao/inbound_numbers_dao.py index a86ba530e..58c7df03a 100644 --- a/app/dao/inbound_numbers_dao.py +++ b/app/dao/inbound_numbers_dao.py @@ -11,19 +11,19 @@ def dao_get_inbound_numbers(): def dao_get_available_inbound_numbers(): - stmt = select(InboundNumber).filter( + stmt = select(InboundNumber).where( InboundNumber.active, InboundNumber.service_id.is_(None) ) return db.session.execute(stmt).scalars().all() def dao_get_inbound_number_for_service(service_id): - stmt = select(InboundNumber).filter(InboundNumber.service_id == service_id) + stmt = select(InboundNumber).where(InboundNumber.service_id == service_id) return db.session.execute(stmt).scalars().first() def dao_get_inbound_number(inbound_number_id): - stmt = select(InboundNumber).filter(InboundNumber.id == inbound_number_id) + stmt = select(InboundNumber).where(InboundNumber.id == inbound_number_id) return db.session.execute(stmt).scalars().first() @@ -35,7 +35,7 @@ def dao_set_inbound_number_to_service(service_id, inbound_number): @autocommit def dao_set_inbound_number_active_flag(service_id, active): - stmt = select(InboundNumber).filter(InboundNumber.service_id == service_id) + stmt = select(InboundNumber).where(InboundNumber.service_id == service_id) inbound_number = db.session.execute(stmt).scalars().first() inbound_number.active = active diff --git a/app/dao/inbound_sms_dao.py b/app/dao/inbound_sms_dao.py index c9b4417e3..c54cf8c33 100644 --- a/app/dao/inbound_sms_dao.py +++ b/app/dao/inbound_sms_dao.py @@ -20,15 +20,15 @@ def dao_get_inbound_sms_for_service( ): q = ( select(InboundSms) - .filter(InboundSms.service_id == service_id) + .where(InboundSms.service_id == service_id) .order_by(InboundSms.created_at.desc()) ) if limit_days is not None: start_date = midnight_n_days_ago(limit_days) - q = q.filter(InboundSms.created_at >= start_date) + q = q.where(InboundSms.created_at >= start_date) if user_number: - q = q.filter(InboundSms.user_number == user_number) + q = q.where(InboundSms.user_number == user_number) if limit: q = q.limit(limit) @@ -47,22 +47,32 @@ def dao_get_paginated_inbound_sms_for_service_for_public_api( if older_than: older_than_created_at = ( db.session.query(InboundSms.created_at) - .filter(InboundSms.id == older_than) + .where(InboundSms.id == older_than) .scalar_subquery() ) filters.append(InboundSms.created_at < older_than_created_at) + page = 1 # ? + offset = (page - 1) * page_size # As part of the move to sqlalchemy 2.0, we do this manual pagination - query = db.session.query(InboundSms).filter(*filters) - paginated_items = query.order_by(desc(InboundSms.created_at)).limit(page_size).all() - return paginated_items + stmt = ( + select(InboundSms) + .where(*filters) + .order_by(desc(InboundSms.created_at)) + .limit(page_size) + .offset(offset) + ) + paginated_items = db.session.execute(stmt).scalars().all() + total_items = db.session.execute(select(func.count()).where(*filters)).scalar() or 0 + pagination = Pagination(paginated_items, page, page_size, total_items) + return pagination def dao_count_inbound_sms_for_service(service_id, limit_days): stmt = ( select(func.count()) .select_from(InboundSms) - .filter( + .where( InboundSms.service_id == service_id, InboundSms.created_at >= midnight_n_days_ago(limit_days), ) @@ -74,7 +84,7 @@ def dao_count_inbound_sms_for_service(service_id, limit_days): def _insert_inbound_sms_history(subquery, query_limit=10000): offset = 0 subquery_select = select(subquery) - inbound_sms_query = select( + inbound_sms_stmt = select( InboundSms.id, InboundSms.created_at, InboundSms.service_id, @@ -84,13 +94,13 @@ def _insert_inbound_sms_history(subquery, query_limit=10000): InboundSms.provider, ).where(InboundSms.id.in_(subquery_select)) - count_query = select(func.count()).select_from(inbound_sms_query.subquery()) + count_query = select(func.count()).select_from(inbound_sms_stmt.subquery()) inbound_sms_count = db.session.execute(count_query).scalar() or 0 while offset < inbound_sms_count: statement = insert(InboundSmsHistory).from_select( InboundSmsHistory.__table__.c, - inbound_sms_query.limit(query_limit).offset(offset), + inbound_sms_stmt.limit(query_limit).offset(offset), ) statement = statement.on_conflict_do_nothing( @@ -107,7 +117,7 @@ def _delete_inbound_sms(datetime_to_delete_from, query_filter): subquery = ( select(InboundSms.id) - .filter(InboundSms.created_at < datetime_to_delete_from, *query_filter) + .where(InboundSms.created_at < datetime_to_delete_from, *query_filter) .limit(query_limit) .subquery() ) @@ -118,7 +128,7 @@ def _delete_inbound_sms(datetime_to_delete_from, query_filter): while number_deleted > 0: _insert_inbound_sms_history(subquery, query_limit=query_limit) - stmt = delete(InboundSms).filter(InboundSms.id.in_(subquery)) + stmt = delete(InboundSms).where(InboundSms.id.in_(subquery)) number_deleted = db.session.execute(stmt).rowcount db.session.commit() deleted += number_deleted @@ -135,7 +145,7 @@ def delete_inbound_sms_older_than_retention(): stmt = ( select(ServiceDataRetention) .join(ServiceDataRetention.service) - .filter(ServiceDataRetention.notification_type == NotificationType.SMS) + .where(ServiceDataRetention.notification_type == NotificationType.SMS) ) flexible_data_retention = db.session.execute(stmt).scalars().all() @@ -170,7 +180,9 @@ def delete_inbound_sms_older_than_retention(): def dao_get_inbound_sms_by_id(service_id, inbound_id): - stmt = select(InboundSms).filter_by(id=inbound_id, service_id=service_id) + stmt = select(InboundSms).where( + InboundSms.id == inbound_id, InboundSms.service_id == service_id + ) return db.session.execute(stmt).scalars().one() diff --git a/app/dao/invited_org_user_dao.py b/app/dao/invited_org_user_dao.py index 2bcf36a05..a44f7123e 100644 --- a/app/dao/invited_org_user_dao.py +++ b/app/dao/invited_org_user_dao.py @@ -1,5 +1,7 @@ from datetime import timedelta +from sqlalchemy import select + from app import db from app.models import InvitedOrganizationUser from app.utils import utc_now @@ -11,25 +13,46 @@ def save_invited_org_user(invited_org_user): def get_invited_org_user(organization_id, invited_org_user_id): - return InvitedOrganizationUser.query.filter_by( - organization_id=organization_id, id=invited_org_user_id - ).one() + return ( + db.session.execute( + select(InvitedOrganizationUser).where( + InvitedOrganizationUser.organization_id == organization_id, + InvitedOrganizationUser.id == invited_org_user_id, + ) + ) + .scalars() + .one() + ) def get_invited_org_user_by_id(invited_org_user_id): - return InvitedOrganizationUser.query.filter_by(id=invited_org_user_id).one() + return ( + db.session.execute( + select(InvitedOrganizationUser).where( + InvitedOrganizationUser.id == invited_org_user_id + ) + ) + .scalars() + .one() + ) def get_invited_org_users_for_organization(organization_id): - return InvitedOrganizationUser.query.filter_by( - organization_id=organization_id - ).all() + return ( + db.session.execute( + select(InvitedOrganizationUser).where( + InvitedOrganizationUser.organization_id == organization_id + ) + ) + .scalars() + .all() + ) def delete_org_invitations_created_more_than_two_days_ago(): deleted = ( db.session.query(InvitedOrganizationUser) - .filter(InvitedOrganizationUser.created_at <= utc_now() - timedelta(days=2)) + .where(InvitedOrganizationUser.created_at <= utc_now() - timedelta(days=2)) .delete() ) db.session.commit() diff --git a/app/dao/invited_user_dao.py b/app/dao/invited_user_dao.py index a342f504d..31d61dc52 100644 --- a/app/dao/invited_user_dao.py +++ b/app/dao/invited_user_dao.py @@ -1,5 +1,7 @@ from datetime import timedelta +from sqlalchemy import select + from app import db from app.enums import InvitedUserStatus from app.models import InvitedUser @@ -12,36 +14,43 @@ def save_invited_user(invited_user): def get_invited_user_by_service_and_id(service_id, invited_user_id): - return InvitedUser.query.filter( + + stmt = select(InvitedUser).where( InvitedUser.service_id == service_id, InvitedUser.id == invited_user_id, - ).one() + ) + return db.session.execute(stmt).scalars().one() def get_expired_invite_by_service_and_id(service_id, invited_user_id): - return InvitedUser.query.filter( + stmt = select(InvitedUser).where( InvitedUser.service_id == service_id, InvitedUser.id == invited_user_id, InvitedUser.status == InvitedUserStatus.EXPIRED, - ).one() + ) + return db.session.execute(stmt).scalars().one() def get_invited_user_by_id(invited_user_id): - return InvitedUser.query.filter(InvitedUser.id == invited_user_id).one() + stmt = select(InvitedUser).where(InvitedUser.id == invited_user_id) + return db.session.execute(stmt).scalars().one() def get_expired_invited_users_for_service(service_id): - return InvitedUser.query.filter(InvitedUser.service_id == service_id).all() + # TODO why does this return all invited users? + stmt = select(InvitedUser).where(InvitedUser.service_id == service_id) + return db.session.execute(stmt).scalars().all() def get_invited_users_for_service(service_id): - return InvitedUser.query.filter(InvitedUser.service_id == service_id).all() + stmt = select(InvitedUser).where(InvitedUser.service_id == service_id) + return db.session.execute(stmt).scalars().all() def expire_invitations_created_more_than_two_days_ago(): expired = ( db.session.query(InvitedUser) - .filter( + .where( InvitedUser.created_at <= utc_now() - timedelta(days=2), InvitedUser.status.in_((InvitedUserStatus.PENDING,)), ) diff --git a/app/dao/jobs_dao.py b/app/dao/jobs_dao.py index ddec26956..feec601a4 100644 --- a/app/dao/jobs_dao.py +++ b/app/dao/jobs_dao.py @@ -3,7 +3,7 @@ import uuid from datetime import timedelta from flask import current_app -from sqlalchemy import and_, asc, desc, func, select +from sqlalchemy import and_, asc, desc, func, select, update from app import db from app.dao.pagination import Pagination @@ -21,7 +21,7 @@ from app.utils import midnight_n_days_ago, utc_now def dao_get_notification_outcomes_for_job(service_id, job_id): stmt = ( select(func.count(Notification.status).label("count"), Notification.status) - .filter(Notification.service_id == service_id, Notification.job_id == job_id) + .where(Notification.service_id == service_id, Notification.job_id == job_id) .group_by(Notification.status) ) notification_statuses = db.session.execute(stmt).all() @@ -30,7 +30,7 @@ def dao_get_notification_outcomes_for_job(service_id, job_id): stmt = select( FactNotificationStatus.notification_count.label("count"), FactNotificationStatus.notification_status.label("status"), - ).filter( + ).where( FactNotificationStatus.service_id == service_id, FactNotificationStatus.job_id == job_id, ) @@ -39,13 +39,14 @@ def dao_get_notification_outcomes_for_job(service_id, job_id): def dao_get_job_by_service_id_and_job_id(service_id, job_id): - stmt = select(Job).filter_by(service_id=service_id, id=job_id) + stmt = select(Job).where(Job.service_id == service_id, Job.id == job_id) return db.session.execute(stmt).scalars().one() def dao_get_unfinished_jobs(): + stmt = select(Job).filter(Job.processing_finished.is_(None)) - return db.session.execute(stmt).all() + return db.session.execute(stmt).scalars().all() def dao_get_jobs_by_service_id( @@ -67,13 +68,13 @@ def dao_get_jobs_by_service_id( query_filter.append(Job.job_status.in_(statuses)) total_items = db.session.execute( - select(func.count()).select_from(Job).filter(*query_filter) + select(func.count()).select_from(Job).where(*query_filter) ).scalar_one() offset = (page - 1) * page_size stmt = ( select(Job) - .filter(*query_filter) + .where(*query_filter) .order_by(Job.processing_started.desc(), Job.created_at.desc()) .limit(page_size) .offset(offset) @@ -89,7 +90,7 @@ def dao_get_scheduled_job_stats( stmt = select( func.count(Job.id), func.min(Job.scheduled_for), - ).filter( + ).where( Job.service_id == service_id, Job.job_status == JobStatus.SCHEDULED, ) @@ -97,7 +98,7 @@ def dao_get_scheduled_job_stats( def dao_get_job_by_id(job_id): - stmt = select(Job).filter_by(id=job_id) + stmt = select(Job).where(Job.id == job_id) return db.session.execute(stmt).scalars().one() @@ -117,7 +118,7 @@ def dao_set_scheduled_jobs_to_pending(): """ stmt = ( select(Job) - .filter( + .where( Job.job_status == JobStatus.SCHEDULED, Job.scheduled_for < utc_now(), ) @@ -136,7 +137,7 @@ def dao_set_scheduled_jobs_to_pending(): def dao_get_future_scheduled_job_by_id_and_service_id(job_id, service_id): - stmt = select(Job).filter( + stmt = select(Job).where( Job.service_id == service_id, Job.id == job_id, Job.job_status == JobStatus.SCHEDULED, @@ -176,8 +177,14 @@ def dao_update_job(job): db.session.commit() +def dao_update_job_status_to_error(job): + stmt = update(Job).where(Job.id == job.id).values(job_status=JobStatus.ERROR) + db.session.execute(stmt) + db.session.commit() + + def dao_get_jobs_older_than_data_retention(notification_types): - stmt = select(ServiceDataRetention).filter( + stmt = select(ServiceDataRetention).where( ServiceDataRetention.notification_type.in_(notification_types) ) flexible_data_retention = db.session.execute(stmt).scalars().all() @@ -188,7 +195,7 @@ def dao_get_jobs_older_than_data_retention(notification_types): stmt = ( select(Job) .join(Template) - .filter( + .where( func.coalesce(Job.scheduled_for, Job.created_at) < end_date, Job.archived == False, # noqa Template.template_type == f.notification_type, @@ -209,7 +216,7 @@ def dao_get_jobs_older_than_data_retention(notification_types): stmt = ( select(Job) .join(Template) - .filter( + .where( func.coalesce(Job.scheduled_for, Job.created_at) < end_date, Job.archived == False, # noqa Template.template_type == notification_type, @@ -229,7 +236,7 @@ def find_jobs_with_missing_rows(): yesterday = utc_now() - timedelta(days=1) jobs_with_rows_missing = ( select(Job) - .filter( + .where( Job.job_status == JobStatus.FINISHED, Job.processing_finished < ten_minutes_ago, Job.processing_finished > yesterday, @@ -258,6 +265,6 @@ def find_missing_row_for_job(job_id, job_size): Notification.job_id == job_id, ), ) - .filter(Notification.job_row_number == None) # noqa + .where(Notification.job_row_number == None) # noqa ) return db.session.execute(query).all() diff --git a/app/dao/notifications_dao.py b/app/dao/notifications_dao.py index 1d07473c1..806f5e957 100644 --- a/app/dao/notifications_dao.py +++ b/app/dao/notifications_dao.py @@ -1,7 +1,22 @@ -from datetime import timedelta +import json +import os +from datetime import datetime, timedelta +from time import time from flask import current_app -from sqlalchemy import asc, delete, desc, func, or_, select, text, union, update +from sqlalchemy import ( + TIMESTAMP, + asc, + cast, + delete, + desc, + func, + or_, + select, + text, + union, + update, +) from sqlalchemy.orm import joinedload from sqlalchemy.orm.exc import NoResultFound from sqlalchemy.sql import functions @@ -10,6 +25,7 @@ from werkzeug.datastructures import MultiDict from app import create_uuid, db from app.dao.dao_utils import autocommit +from app.dao.inbound_sms_dao import Pagination from app.enums import KeyType, NotificationStatus, NotificationType from app.models import FactNotificationStatus, Notification, NotificationHistory from app.utils import ( @@ -29,7 +45,7 @@ from notifications_utils.recipients import ( def dao_get_last_date_template_was_used(template_id, service_id): last_date_from_notifications = ( db.session.query(functions.max(Notification.created_at)) - .filter( + .where( Notification.service_id == service_id, Notification.template_id == template_id, Notification.key_type != KeyType.TEST, @@ -42,7 +58,7 @@ def dao_get_last_date_template_was_used(template_id, service_id): last_date = ( db.session.query(functions.max(FactNotificationStatus.local_date)) - .filter( + .where( FactNotificationStatus.template_id == template_id, FactNotificationStatus.key_type != KeyType.TEST, ) @@ -52,6 +68,12 @@ def dao_get_last_date_template_was_used(template_id, service_id): return last_date +def dao_notification_exists(notification_id) -> bool: + stmt = select(Notification).where(Notification.id == notification_id) + result = db.session.execute(stmt).scalar() + return result is not None + + @autocommit def dao_create_notification(notification): if not notification.id: @@ -71,7 +93,36 @@ def dao_create_notification(notification): # notify-api-742 remove phone numbers from db notification.to = "1" notification.normalised_to = "1" - db.session.add(notification) + + # notify-api-1454 insert only if it doesn't exist + if not dao_notification_exists(notification.id): + db.session.add(notification) + # There have been issues with invites expiring. + # Ensure the created at value is set and debug. + if notification.notification_type == "email": + orig_time = notification.created_at + now_time = utc_now() + try: + diff_time = now_time - orig_time + except TypeError: + try: + orig_time = datetime.strptime(orig_time, "%Y-%m-%dT%H:%M:%S.%fZ") + except ValueError: + orig_time = datetime.strptime(orig_time, "%Y-%m-%d") + diff_time = now_time - orig_time + current_app.logger.error( + f"dao_create_notification orig created at: {orig_time} and now created at: {now_time}" + ) + if diff_time.total_seconds() > 300: + current_app.logger.error( + "Something is wrong with notification.created_at in email!" + ) + if os.getenv("NOTIFY_ENVIRONMENT") not in ["test"]: + notification.created_at = now_time + dao_update_notification(notification) + current_app.logger.error( + f"Email notification created_at reset to {notification.created_at}" + ) def country_records_delivery(phone_prefix): @@ -105,14 +156,22 @@ def _update_notification_status( return notification +def update_notification_message_id(notification_id, message_id): + stmt = ( + update(Notification) + .where(Notification.id == notification_id) + .values(message_id=message_id) + ) + db.session.execute(stmt) + db.session.commit() + + @autocommit def update_notification_status_by_id( notification_id, status, sent_by=None, provider_response=None, carrier=None ): stmt = ( - select(Notification) - .with_for_update() - .filter(Notification.id == notification_id) + select(Notification).with_for_update().where(Notification.id == notification_id) ) notification = db.session.execute(stmt).scalars().first() @@ -157,7 +216,7 @@ def update_notification_status_by_id( @autocommit def update_notification_status_by_reference(reference, status): # this is used to update emails - stmt = select(Notification).filter(Notification.reference == reference) + stmt = select(Notification).where(Notification.reference == reference) notification = db.session.execute(stmt).scalars().first() if not notification: @@ -192,40 +251,60 @@ def get_notifications_for_job( ): if page_size is None: page_size = current_app.config["PAGE_SIZE"] - query = Notification.query.filter_by(service_id=service_id, job_id=job_id) - query = _filter_query(query, filter_dict) - return query.order_by(asc(Notification.job_row_number)).paginate( - page=page, per_page=page_size + + stmt = select(Notification).where( + Notification.service_id == service_id, Notification.job_id == job_id ) + stmt = _filter_query(stmt, filter_dict) + stmt = stmt.order_by(asc(Notification.job_row_number)) + + results = db.session.execute(stmt).scalars().all() + + page_size = current_app.config["PAGE_SIZE"] + offset = (page - 1) * page_size + paginated_results = results[offset : offset + page_size] + pagination = Pagination(paginated_results, page, page_size, len(results)) + return pagination def dao_get_notification_count_for_job_id(*, job_id): - stmt = select(func.count(Notification.id)).filter_by(job_id=job_id) + stmt = select(func.count(Notification.id)).where(Notification.job_id == job_id) return db.session.execute(stmt).scalar() def dao_get_notification_count_for_service(*, service_id): - stmt = select(func.count(Notification.id)).filter_by(service_id=service_id) + stmt = select(func.count(Notification.id)).where( + Notification.service_id == service_id + ) return db.session.execute(stmt).scalar() def dao_get_failed_notification_count(): - stmt = select(func.count(Notification.id)).filter_by( - status=NotificationStatus.FAILED + stmt = select(func.count(Notification.id)).where( + Notification.status == NotificationStatus.FAILED ) return db.session.execute(stmt).scalar() def get_notification_with_personalisation(service_id, notification_id, key_type): - filter_dict = {"service_id": service_id, "id": notification_id} - if key_type: - filter_dict["key_type"] = key_type stmt = ( select(Notification) - .filter_by(**filter_dict) + .where( + Notification.service_id == service_id, Notification.id == notification_id + ) .options(joinedload(Notification.template)) ) + if key_type: + stmt = ( + select(Notification) + .where( + Notification.service_id == service_id, + Notification.id == notification_id, + Notification.key_type == key_type, + ) + .options(joinedload(Notification.template)) + ) return db.session.execute(stmt).scalars().one() @@ -235,7 +314,7 @@ def get_notification_by_id(notification_id, service_id=None, _raise=False): if service_id: filters.append(Notification.service_id == service_id) - stmt = select(Notification).filter(*filters) + stmt = select(Notification).where(*filters) return ( db.session.execute(stmt).scalars().one() @@ -271,7 +350,7 @@ def get_notifications_for_service( if older_than is not None: older_than_created_at = ( db.session.query(Notification.created_at) - .filter(Notification.id == older_than) + .where(Notification.id == older_than) .as_scalar() ) filters.append(Notification.created_at < older_than_created_at) @@ -290,22 +369,22 @@ def get_notifications_for_service( if client_reference is not None: filters.append(Notification.client_reference == client_reference) - query = Notification.query.filter(*filters) - query = _filter_query(query, filter_dict) + stmt = select(Notification).where(*filters) + stmt = _filter_query(stmt, filter_dict) if personalisation: - query = query.options(joinedload(Notification.template)) + stmt = stmt.options(joinedload(Notification.template)) - return query.order_by(desc(Notification.created_at)).paginate( - page=page, - per_page=page_size, - count=count_pages, - error_out=error_out, - ) + stmt = stmt.order_by(desc(Notification.created_at)) + results = db.session.execute(stmt).scalars().all() + offset = (page - 1) * page_size + paginated_results = results[offset : offset + page_size] + pagination = Pagination(paginated_results, page, page_size, len(results)) + return pagination -def _filter_query(query, filter_dict=None): +def _filter_query(stmt, filter_dict=None): if filter_dict is None: - return query + return stmt multidict = MultiDict(filter_dict) @@ -313,14 +392,14 @@ def _filter_query(query, filter_dict=None): statuses = multidict.getlist("status") if statuses: - query = query.filter(Notification.status.in_(statuses)) + stmt = stmt.where(Notification.status.in_(statuses)) # filter by template template_types = multidict.getlist("template_type") if template_types: - query = query.filter(Notification.notification_type.in_(template_types)) + stmt = stmt.where(Notification.notification_type.in_(template_types)) - return query + return stmt def sanitize_successful_notification_by_id(notification_id, carrier, provider_response): @@ -421,7 +500,7 @@ def move_notifications_to_notification_history( deleted += delete_count_per_call # Deleting test Notifications, test notifications are not persisted to NotificationHistory - stmt = delete(Notification).filter( + stmt = delete(Notification).where( Notification.notification_type == notification_type, Notification.service_id == service_id, Notification.created_at < timestamp_to_delete_backwards_from, @@ -435,7 +514,7 @@ def move_notifications_to_notification_history( @autocommit def dao_delete_notifications_by_id(notification_id): - db.session.query(Notification).filter(Notification.id == notification_id).delete( + db.session.query(Notification).where(Notification.id == notification_id).delete( synchronize_session="fetch" ) @@ -451,7 +530,7 @@ def dao_timeout_notifications(cutoff_time, limit=100000): stmt = ( select(Notification) - .filter( + .where( Notification.created_at < cutoff_time, Notification.status.in_(current_statuses), Notification.notification_type.in_( @@ -464,7 +543,7 @@ def dao_timeout_notifications(cutoff_time, limit=100000): stmt = ( update(Notification) - .filter(Notification.id.in_([n.id for n in notifications])) + .where(Notification.id.in_([n.id for n in notifications])) .values({"status": new_status, "updated_at": updated_at}) ) db.session.execute(stmt) @@ -477,7 +556,7 @@ def dao_timeout_notifications(cutoff_time, limit=100000): def dao_update_notifications_by_reference(references, update_dict): stmt = ( update(Notification) - .filter(Notification.reference.in_(references)) + .where(Notification.reference.in_(references)) .values(update_dict) ) result = db.session.execute(stmt) @@ -487,7 +566,7 @@ def dao_update_notifications_by_reference(references, update_dict): if updated_count != len(references): stmt = ( update(NotificationHistory) - .filter(NotificationHistory.reference.in_(references)) + .where(NotificationHistory.reference.in_(references)) .values(update_dict) ) result = db.session.execute(stmt) @@ -550,7 +629,7 @@ def dao_get_notifications_by_recipient_or_reference( results = ( db.session.query(Notification) - .filter(*filters) + .where(*filters) .order_by(desc(Notification.created_at)) .paginate(page=page, per_page=page_size, count=False, error_out=error_out) ) @@ -558,7 +637,7 @@ def dao_get_notifications_by_recipient_or_reference( def dao_get_notification_by_reference(reference): - stmt = select(Notification).filter(Notification.reference == reference) + stmt = select(Notification).where(Notification.reference == reference) return db.session.execute(stmt).scalars().one() @@ -566,10 +645,10 @@ def dao_get_notification_history_by_reference(reference): try: # This try except is necessary because in test keys and research mode does not create notification history. # Otherwise we could just search for the NotificationHistory object - stmt = select(Notification).filter(Notification.reference == reference) + stmt = select(Notification).where(Notification.reference == reference) return db.session.execute(stmt).scalars().one() except NoResultFound: - stmt = select(NotificationHistory).filter( + stmt = select(NotificationHistory).where( NotificationHistory.reference == reference ) return db.session.execute(stmt).scalars().one() @@ -612,7 +691,7 @@ def dao_get_notifications_processing_time_stats(start_date, end_date): def dao_get_last_notification_added_for_job_id(job_id): stmt = ( select(Notification) - .filter(Notification.job_id == job_id) + .where(Notification.job_id == job_id) .order_by(Notification.job_row_number.desc()) ) last_notification_added = db.session.execute(stmt).scalars().first() @@ -623,7 +702,7 @@ def dao_get_last_notification_added_for_job_id(job_id): def notifications_not_yet_sent(should_be_sending_after_seconds, notification_type): older_than_date = utc_now() - timedelta(seconds=should_be_sending_after_seconds) - stmt = select(Notification).filter( + stmt = select(Notification).where( Notification.created_at <= older_than_date, Notification.notification_type == notification_type, Notification.status == NotificationStatus.CREATED, @@ -655,7 +734,7 @@ def get_service_ids_with_notifications_before(notification_type, timestamp): return { row.service_id for row in db.session.query(Notification.service_id) - .filter( + .where( Notification.notification_type == notification_type, Notification.created_at < timestamp, ) @@ -669,7 +748,7 @@ def get_service_ids_with_notifications_on_date(notification_type, date): notification_table_query = db.session.query( Notification.service_id.label("service_id") - ).filter( + ).where( Notification.notification_type == notification_type, # using >= + < is much more efficient than date(created_at) Notification.created_at >= start_date, @@ -680,7 +759,7 @@ def get_service_ids_with_notifications_on_date(notification_type, date): # provided the task to populate it has run before they were archived. ft_status_table_query = db.session.query( FactNotificationStatus.service_id.label("service_id") - ).filter( + ).where( FactNotificationStatus.notification_type == notification_type, FactNotificationStatus.local_date == date, ) @@ -691,3 +770,85 @@ def get_service_ids_with_notifications_on_date(notification_type, date): union(notification_table_query, ft_status_table_query).subquery() ).distinct() } + + +def dao_update_delivery_receipts(receipts, delivered): + start_time_millis = time() * 1000 + new_receipts = [] + for r in receipts: + if isinstance(r, str): + r = json.loads(r) + new_receipts.append(r) + + receipts = new_receipts + + id_to_carrier = { + r["notification.messageId"]: r["delivery.phoneCarrier"] for r in receipts + } + id_to_provider_response = { + r["notification.messageId"]: r["delivery.providerResponse"] for r in receipts + } + id_to_timestamp = {r["notification.messageId"]: r["@timestamp"] for r in receipts} + + status_to_update_with = NotificationStatus.DELIVERED + if not delivered: + status_to_update_with = NotificationStatus.FAILED + stmt = ( + update(Notification) + .where(Notification.message_id.in_(id_to_carrier.keys())) + .values( + carrier=case( + *[ + (Notification.message_id == key, value) + for key, value in id_to_carrier.items() + ] + ), + status=status_to_update_with, + sent_at=case( + *[ + (Notification.message_id == key, cast(value, TIMESTAMP)) + for key, value in id_to_timestamp.items() + ] + ), + provider_response=case( + *[ + (Notification.message_id == key, value) + for key, value in id_to_provider_response.items() + ] + ), + ) + ) + db.session.execute(stmt) + db.session.commit() + elapsed_time = (time() * 1000) - start_time_millis + current_app.logger.info( + f"#loadtestperformance batch update query time: \ + updated {len(receipts)} notification in {elapsed_time} ms" + ) + + +def dao_close_out_delivery_receipts(): + THREE_DAYS_AGO = utc_now() - timedelta(minutes=3) + stmt = ( + update(Notification) + .where( + Notification.status == NotificationStatus.PENDING, + Notification.sent_at < THREE_DAYS_AGO, + ) + .values(status=NotificationStatus.FAILED, provider_response="Technical Failure") + ) + result = db.session.execute(stmt) + + db.session.commit() + if result: + current_app.logger.info( + f"Marked {result.rowcount} notifications as technical failures" + ) + + +def dao_batch_insert_notifications(batch): + + db.session.bulk_save_objects(batch) + db.session.commit() + current_app.logger.info(f"Batch inserted notifications: {len(batch)}") + return len(batch) diff --git a/app/dao/organization_dao.py b/app/dao/organization_dao.py index 668ac6c25..75aa5f68f 100644 --- a/app/dao/organization_dao.py +++ b/app/dao/organization_dao.py @@ -17,7 +17,7 @@ def dao_count_organizations_with_live_services(): stmt = ( select(func.count(func.distinct(Organization.id))) .join(Organization.services) - .filter( + .where( Service.active.is_(True), Service.restricted.is_(False), Service.count_as_live.is_(True), @@ -27,17 +27,19 @@ def dao_count_organizations_with_live_services(): def dao_get_organization_services(organization_id): - stmt = select(Organization).filter_by(id=organization_id) + stmt = select(Organization).where(Organization.id == organization_id) return db.session.execute(stmt).scalars().one().services def dao_get_organization_live_services(organization_id): - stmt = select(Service).filter_by(organization_id=organization_id, restricted=False) + stmt = select(Service).where( + Service.organization_id == organization_id, Service.restricted == False # noqa + ) return db.session.execute(stmt).scalars().all() def dao_get_organization_by_id(organization_id): - stmt = select(Organization).filter_by(id=organization_id) + stmt = select(Organization).where(Organization.id == organization_id) return db.session.execute(stmt).scalars().one() @@ -49,14 +51,16 @@ def dao_get_organization_by_email_address(email_address): if email_address.endswith( "@{}".format(domain.domain) ) or email_address.endswith(".{}".format(domain.domain)): - stmt = select(Organization).filter_by(id=domain.organization_id) + stmt = select(Organization).where(Organization.id == domain.organization_id) return db.session.execute(stmt).scalars().one() return None def dao_get_organization_by_service_id(service_id): - stmt = select(Organization).join(Organization.services).filter_by(id=service_id) + stmt = ( + select(Organization).join(Organization.services).where(Service.id == service_id) + ) return db.session.execute(stmt).scalars().first() @@ -74,7 +78,7 @@ def dao_update_organization(organization_id, **kwargs): num_updated = db.session.execute(stmt).rowcount if isinstance(domains, list): - stmt = delete(Domain).filter_by(organization_id=organization_id) + stmt = delete(Domain).where(Domain.organization_id == organization_id) db.session.execute(stmt) db.session.bulk_save_objects( [ @@ -108,7 +112,7 @@ def _update_organization_services(organization, attribute, only_where_none=True) @autocommit @version_class(Service) def dao_add_service_to_organization(service, organization_id): - stmt = select(Organization).filter_by(id=organization_id) + stmt = select(Organization).where(Organization.id == organization_id) organization = db.session.execute(stmt).scalars().one() service.organization_id = organization_id @@ -121,7 +125,7 @@ def dao_get_users_for_organization(organization_id): return ( db.session.query(User) .join(User.organizations) - .filter(Organization.id == organization_id, User.state == "active") + .where(Organization.id == organization_id, User.state == "active") .order_by(User.created_at) .all() ) @@ -130,7 +134,7 @@ def dao_get_users_for_organization(organization_id): @autocommit def dao_add_user_to_organization(organization_id, user_id): organization = dao_get_organization_by_id(organization_id) - stmt = select(User).filter_by(id=user_id) + stmt = select(User).where(User.id == user_id) user = db.session.execute(stmt).scalars().one() user.organizations.append(organization) db.session.add(organization) diff --git a/app/dao/permissions_dao.py b/app/dao/permissions_dao.py index 92e8fc291..5d86b306b 100644 --- a/app/dao/permissions_dao.py +++ b/app/dao/permissions_dao.py @@ -1,7 +1,9 @@ +from sqlalchemy import delete, select + from app import db from app.dao import DAOClass from app.enums import PermissionType -from app.models import Permission +from app.models import Permission, Service class PermissionDAO(DAOClass): @@ -14,22 +16,29 @@ class PermissionDAO(DAOClass): self.create_instance(permission, _commit=False) def remove_user_service_permissions(self, user, service): - query = self.Meta.model.query.filter_by(user=user, service=service) - query.delete() + db.session.execute( + delete(self.Meta.model).where( + self.Meta.model.user == user, self.Meta.model.service == service + ) + ) + db.session.commit() def remove_user_service_permissions_for_all_services(self, user): - query = self.Meta.model.query.filter_by(user=user) - query.delete() + db.session.execute(delete(self.Meta.model).where(self.Meta.model.user == user)) + db.session.commit() def set_user_service_permission( self, user, service, permissions, _commit=False, replace=False ): try: if replace: - query = self.Meta.model.query.filter( - self.Meta.model.user == user, self.Meta.model.service == service + db.session.execute( + delete(self.Meta.model).where( + self.Meta.model.user == user, self.Meta.model.service == service + ) ) - query.delete() + + db.session.commit() for p in permissions: p.user = user p.service = service @@ -44,17 +53,26 @@ class PermissionDAO(DAOClass): def get_permissions_by_user_id(self, user_id): return ( - self.Meta.model.query.filter_by(user_id=user_id) - .join(Permission.service) - .filter_by(active=True) + db.session.execute( + select(Permission) + .join(Service) + .where(Permission.user_id == user_id) + .where(Service.active.is_(True)) + ) + .scalars() .all() ) def get_permissions_by_user_id_and_service_id(self, user_id, service_id): return ( - self.Meta.model.query.filter_by(user_id=user_id) - .join(Permission.service) - .filter_by(active=True, id=service_id) + db.session.execute( + select(Permission) + .join(Service) + .where(Permission.user_id == user_id) + .where(Service.active.is_(True)) + .where(Service.id == service_id) + ) + .scalars() .all() ) diff --git a/app/dao/provider_details_dao.py b/app/dao/provider_details_dao.py index b0ab48d09..81a8cc3d3 100644 --- a/app/dao/provider_details_dao.py +++ b/app/dao/provider_details_dao.py @@ -1,7 +1,7 @@ from datetime import datetime from flask import current_app -from sqlalchemy import desc, func +from sqlalchemy import desc, func, select from app import db from app.dao.dao_utils import autocommit @@ -11,11 +11,12 @@ from app.utils import utc_now def get_provider_details_by_id(provider_details_id): - return ProviderDetails.query.get(provider_details_id) + return db.session.get(ProviderDetails, provider_details_id) def get_provider_details_by_identifier(identifier): - return ProviderDetails.query.filter_by(identifier=identifier).one() + stmt = select(ProviderDetails).where(ProviderDetails.identifier == identifier) + return db.session.execute(stmt).scalars().one() def get_alternative_sms_provider(identifier): @@ -25,12 +26,14 @@ def get_alternative_sms_provider(identifier): def dao_get_provider_versions(provider_id): - return ( - ProviderDetailsHistory.query.filter_by(id=provider_id) + stmt = ( + select(ProviderDetailsHistory) + .where(ProviderDetailsHistory.id == provider_id) .order_by(desc(ProviderDetailsHistory.version)) - .limit(100) # limit results instead of adding pagination - .all() + .limit(100) ) + # limit results instead of adding pagination + return db.session.execute(stmt).scalars().all() def _get_sms_providers_for_update(time_threshold): @@ -42,14 +45,15 @@ def _get_sms_providers_for_update(time_threshold): release the transaction in that case """ # get current priority of both providers - q = ( - ProviderDetails.query.filter( + stmt = ( + select(ProviderDetails) + .where( ProviderDetails.notification_type == NotificationType.SMS, ProviderDetails.active, ) .with_for_update() - .all() ) + q = db.session.execute(stmt).scalars().all() # if something updated recently, don't update again. If the updated_at is null, treat it as min time if any( @@ -72,7 +76,8 @@ def get_provider_details_by_notification_type( if supports_international: filters.append(ProviderDetails.supports_international == supports_international) - return ProviderDetails.query.filter(*filters).all() + stmt = select(ProviderDetails).where(*filters) + return db.session.execute(stmt).scalars().all() @autocommit @@ -97,14 +102,14 @@ def dao_get_provider_stats(): current_datetime = utc_now() first_day_of_the_month = current_datetime.date().replace(day=1) - subquery = ( + substmt = ( db.session.query( FactBilling.provider, func.sum(FactBilling.billable_units * FactBilling.rate_multiplier).label( "current_month_billable_sms" ), ) - .filter( + .where( FactBilling.notification_type == NotificationType.SMS, FactBilling.local_date >= first_day_of_the_month, ) @@ -122,11 +127,11 @@ def dao_get_provider_stats(): ProviderDetails.updated_at, ProviderDetails.supports_international, User.name.label("created_by_name"), - func.coalesce(subquery.c.current_month_billable_sms, 0).label( + func.coalesce(substmt.c.current_month_billable_sms, 0).label( "current_month_billable_sms" ), ) - .outerjoin(subquery, ProviderDetails.identifier == subquery.c.provider) + .outerjoin(substmt, ProviderDetails.identifier == substmt.c.provider) .outerjoin(User, ProviderDetails.created_by_id == User.id) .order_by( ProviderDetails.notification_type, diff --git a/app/dao/service_callback_api_dao.py b/app/dao/service_callback_api_dao.py index a1a39d982..4c81b5c5f 100644 --- a/app/dao/service_callback_api_dao.py +++ b/app/dao/service_callback_api_dao.py @@ -1,3 +1,5 @@ +from sqlalchemy import select + from app import create_uuid, db from app.dao.dao_utils import autocommit, version_class from app.enums import CallbackType @@ -29,23 +31,42 @@ def reset_service_callback_api( def get_service_callback_api(service_callback_api_id, service_id): - return ServiceCallbackApi.query.filter_by( - id=service_callback_api_id, service_id=service_id - ).first() + return ( + db.session.execute( + select(ServiceCallbackApi).where( + ServiceCallbackApi.id == service_callback_api_id, + ServiceCallbackApi.service_id == service_id, + ) + ) + .scalars() + .first() + ) def get_service_delivery_status_callback_api_for_service(service_id): - return ServiceCallbackApi.query.filter_by( - service_id=service_id, - callback_type=CallbackType.DELIVERY_STATUS, - ).first() + return ( + db.session.execute( + select(ServiceCallbackApi).where( + ServiceCallbackApi.service_id == service_id, + ServiceCallbackApi.callback_type == CallbackType.DELIVERY_STATUS, + ) + ) + .scalars() + .first() + ) def get_service_complaint_callback_api_for_service(service_id): - return ServiceCallbackApi.query.filter_by( - service_id=service_id, - callback_type=CallbackType.COMPLAINT, - ).first() + return ( + db.session.execute( + select(ServiceCallbackApi).where( + ServiceCallbackApi.service_id == service_id, + ServiceCallbackApi.callback_type == CallbackType.COMPLAINT, + ) + ) + .scalars() + .first() + ) @autocommit diff --git a/app/dao/service_data_retention_dao.py b/app/dao/service_data_retention_dao.py index b95ca5720..cd2c1fd4b 100644 --- a/app/dao/service_data_retention_dao.py +++ b/app/dao/service_data_retention_dao.py @@ -1,3 +1,5 @@ +from sqlalchemy import select, update + from app import db from app.dao.dao_utils import autocommit from app.models import ServiceDataRetention @@ -5,29 +7,31 @@ from app.utils import utc_now def fetch_service_data_retention_by_id(service_id, data_retention_id): - data_retention = ServiceDataRetention.query.filter_by( - service_id=service_id, id=data_retention_id - ).first() - return data_retention + stmt = select(ServiceDataRetention).where( + ServiceDataRetention.service_id == service_id, + ServiceDataRetention.id == data_retention_id, + ) + return db.session.execute(stmt).scalars().first() def fetch_service_data_retention(service_id): - data_retention_list = ( - ServiceDataRetention.query.filter_by(service_id=service_id) + stmt = ( + select(ServiceDataRetention) + .where(ServiceDataRetention.service_id == service_id) .order_by( # in the order that models.notification_types are created (email, sms, letter) ServiceDataRetention.notification_type ) - .all() ) - return data_retention_list + return db.session.execute(stmt).scalars().all() def fetch_service_data_retention_by_notification_type(service_id, notification_type): - data_retention_list = ServiceDataRetention.query.filter_by( - service_id=service_id, notification_type=notification_type - ).first() - return data_retention_list + stmt = select(ServiceDataRetention).where( + ServiceDataRetention.service_id == service_id, + ServiceDataRetention.notification_type == notification_type, + ) + return db.session.execute(stmt).scalars().first() @autocommit @@ -46,16 +50,22 @@ def insert_service_data_retention(service_id, notification_type, days_of_retenti def update_service_data_retention( service_data_retention_id, service_id, days_of_retention ): - updated_count = ServiceDataRetention.query.filter( - ServiceDataRetention.id == service_data_retention_id, - ServiceDataRetention.service_id == service_id, - ).update({"days_of_retention": days_of_retention, "updated_at": utc_now()}) - return updated_count + stmt = ( + update(ServiceDataRetention) + .where( + ServiceDataRetention.id == service_data_retention_id, + ServiceDataRetention.service_id == service_id, + ) + .values({"days_of_retention": days_of_retention, "updated_at": utc_now()}) + ) + result = db.session.execute(stmt) + return result.rowcount def fetch_service_data_retention_for_all_services_by_notification_type( notification_type, ): - return ServiceDataRetention.query.filter( + stmt = select(ServiceDataRetention).where( ServiceDataRetention.notification_type == notification_type - ).all() + ) + return db.session.execute(stmt).scalars().all() diff --git a/app/dao/service_email_reply_to_dao.py b/app/dao/service_email_reply_to_dao.py index a95690b2f..bbb0b8751 100644 --- a/app/dao/service_email_reply_to_dao.py +++ b/app/dao/service_email_reply_to_dao.py @@ -1,4 +1,4 @@ -from sqlalchemy import desc +from sqlalchemy import desc, select from app import db from app.dao.dao_utils import autocommit @@ -10,7 +10,7 @@ from app.models import ServiceEmailReplyTo def dao_get_reply_to_by_service_id(service_id): reply_to = ( db.session.query(ServiceEmailReplyTo) - .filter( + .where( ServiceEmailReplyTo.service_id == service_id, ServiceEmailReplyTo.archived == False, # noqa ) @@ -25,7 +25,7 @@ def dao_get_reply_to_by_service_id(service_id): def dao_get_reply_to_by_id(service_id, reply_to_id): reply_to = ( db.session.query(ServiceEmailReplyTo) - .filter( + .where( ServiceEmailReplyTo.service_id == service_id, ServiceEmailReplyTo.id == reply_to_id, ServiceEmailReplyTo.archived == False, # noqa @@ -62,7 +62,7 @@ def update_reply_to_email_address(service_id, reply_to_id, email_address, is_def "You must have at least one reply to email address as the default.", 400 ) - reply_to_update = ServiceEmailReplyTo.query.get(reply_to_id) + reply_to_update = db.session.get(ServiceEmailReplyTo, reply_to_id) reply_to_update.email_address = email_address reply_to_update.is_default = is_default db.session.add(reply_to_update) @@ -71,9 +71,16 @@ def update_reply_to_email_address(service_id, reply_to_id, email_address, is_def @autocommit def archive_reply_to_email_address(service_id, reply_to_id): - reply_to_archive = ServiceEmailReplyTo.query.filter_by( - id=reply_to_id, service_id=service_id - ).one() + reply_to_archive = ( + db.session.execute( + select(ServiceEmailReplyTo).where( + ServiceEmailReplyTo.id == reply_to_id, + ServiceEmailReplyTo.service_id == service_id, + ) + ) + .scalars() + .one() + ) if reply_to_archive.is_default: raise ArchiveValidationError( diff --git a/app/dao/service_guest_list_dao.py b/app/dao/service_guest_list_dao.py index acd39703c..8e128a213 100644 --- a/app/dao/service_guest_list_dao.py +++ b/app/dao/service_guest_list_dao.py @@ -1,11 +1,12 @@ +from sqlalchemy import delete, select + from app import db from app.models import ServiceGuestList def dao_fetch_service_guest_list(service_id): - return ServiceGuestList.query.filter( - ServiceGuestList.service_id == service_id - ).all() + stmt = select(ServiceGuestList).where(ServiceGuestList.service_id == service_id) + return db.session.execute(stmt).scalars().all() def dao_add_and_commit_guest_list_contacts(objs): @@ -14,6 +15,6 @@ def dao_add_and_commit_guest_list_contacts(objs): def dao_remove_service_guest_list(service_id): - return ServiceGuestList.query.filter( - ServiceGuestList.service_id == service_id - ).delete() + stmt = delete(ServiceGuestList).where(ServiceGuestList.service_id == service_id) + result = db.session.execute(stmt) + return result.rowcount diff --git a/app/dao/service_inbound_api_dao.py b/app/dao/service_inbound_api_dao.py index a04affe9e..45efaefd7 100644 --- a/app/dao/service_inbound_api_dao.py +++ b/app/dao/service_inbound_api_dao.py @@ -1,3 +1,5 @@ +from sqlalchemy import select + from app import create_uuid, db from app.dao.dao_utils import autocommit, version_class from app.models import ServiceInboundApi @@ -28,13 +30,26 @@ def reset_service_inbound_api( def get_service_inbound_api(service_inbound_api_id, service_id): - return ServiceInboundApi.query.filter_by( - id=service_inbound_api_id, service_id=service_id - ).first() + return ( + db.session.execute( + select(ServiceInboundApi).where( + ServiceInboundApi.id == service_inbound_api_id, + ServiceInboundApi.service_id == service_id, + ) + ) + .scalars() + .first() + ) def get_service_inbound_api_for_service(service_id): - return ServiceInboundApi.query.filter_by(service_id=service_id).first() + return ( + db.session.execute( + select(ServiceInboundApi).where(ServiceInboundApi.service_id == service_id) + ) + .scalars() + .first() + ) @autocommit diff --git a/app/dao/service_permissions_dao.py b/app/dao/service_permissions_dao.py index 0793b35b6..8ea40b614 100644 --- a/app/dao/service_permissions_dao.py +++ b/app/dao/service_permissions_dao.py @@ -7,7 +7,7 @@ from app.models import ServicePermission def dao_fetch_service_permissions(service_id): - stmt = select(ServicePermission).filter(ServicePermission.service_id == service_id) + stmt = select(ServicePermission).where(ServicePermission.service_id == service_id) return db.session.execute(stmt).scalars().all() diff --git a/app/dao/service_sms_sender_dao.py b/app/dao/service_sms_sender_dao.py index 82796b05f..e2d244c52 100644 --- a/app/dao/service_sms_sender_dao.py +++ b/app/dao/service_sms_sender_dao.py @@ -17,8 +17,10 @@ def insert_service_sms_sender(service, sms_sender): def dao_get_service_sms_senders_by_id(service_id, service_sms_sender_id): - stmt = select(ServiceSmsSender).filter_by( - id=service_sms_sender_id, service_id=service_id, archived=False + stmt = select(ServiceSmsSender).where( + ServiceSmsSender.id == service_sms_sender_id, + ServiceSmsSender.service_id == service_id, + ServiceSmsSender.archived == False, # noqa ) return db.session.execute(stmt).scalars().one() @@ -27,7 +29,10 @@ def dao_get_sms_senders_by_service_id(service_id): stmt = ( select(ServiceSmsSender) - .filter_by(service_id=service_id, archived=False) + .where( + ServiceSmsSender.service_id == service_id, + ServiceSmsSender.archived == False, # noqa + ) .order_by(desc(ServiceSmsSender.is_default)) ) return db.session.execute(stmt).scalars().all() @@ -65,7 +70,7 @@ def dao_update_service_sms_sender( if old_default.id == service_sms_sender_id: raise Exception("You must have at least one SMS sender as the default") - sms_sender_to_update = ServiceSmsSender.query.get(service_sms_sender_id) + sms_sender_to_update = db.session.get(ServiceSmsSender, service_sms_sender_id) sms_sender_to_update.is_default = is_default if not sms_sender_to_update.inbound_number_id and sms_sender: sms_sender_to_update.sms_sender = sms_sender @@ -85,9 +90,16 @@ def update_existing_sms_sender_with_inbound_number( @autocommit def archive_sms_sender(service_id, sms_sender_id): - sms_sender_to_archive = ServiceSmsSender.query.filter_by( - id=sms_sender_id, service_id=service_id - ).one() + sms_sender_to_archive = ( + db.session.execute( + select(ServiceSmsSender).where( + ServiceSmsSender.id == sms_sender_id, + ServiceSmsSender.service_id == service_id, + ) + ) + .scalars() + .one() + ) if sms_sender_to_archive.inbound_number_id: raise ArchiveValidationError("You cannot delete an inbound number") diff --git a/app/dao/service_user_dao.py b/app/dao/service_user_dao.py index d60c92ba6..d1c30ecb5 100644 --- a/app/dao/service_user_dao.py +++ b/app/dao/service_user_dao.py @@ -6,7 +6,9 @@ from app.models import ServiceUser, User def dao_get_service_user(user_id, service_id): - stmt = select(ServiceUser).filter_by(user_id=user_id, service_id=service_id) + stmt = select(ServiceUser).where( + ServiceUser.user_id == user_id, ServiceUser.service_id == service_id + ) return db.session.execute(stmt).scalars().one_or_none() @@ -15,13 +17,17 @@ def dao_get_active_service_users(service_id): stmt = ( select(ServiceUser) .join(User, User.id == ServiceUser.user_id) - .filter(User.state == "active", ServiceUser.service_id == service_id) + .where(User.state == "active", ServiceUser.service_id == service_id) ) return db.session.execute(stmt).scalars().all() def dao_get_service_users_by_user_id(user_id): - return ServiceUser.query.filter_by(user_id=user_id).all() + return ( + db.session.execute(select(ServiceUser).where(ServiceUser.user_id == user_id)) + .scalars() + .all() + ) @autocommit diff --git a/app/dao/services_dao.py b/app/dao/services_dao.py index 260008193..47be682ce 100644 --- a/app/dao/services_dao.py +++ b/app/dao/services_dao.py @@ -96,7 +96,7 @@ def dao_fetch_live_services_data(): this_year_ft_billing = ( select(FactBilling) - .filter( + .where( FactBilling.local_date >= year_start_date, FactBilling.local_date <= year_end_date, ) @@ -145,7 +145,7 @@ def dao_fetch_live_services_data(): this_year_ft_billing, Service.id == this_year_ft_billing.c.service_id ) .outerjoin(User, Service.go_live_user_id == User.id) - .filter( + .where( Service.count_as_live.is_(True), Service.active.is_(True), Service.restricted.is_(False), @@ -216,10 +216,12 @@ def dao_fetch_service_by_inbound_number(number): def dao_fetch_service_by_id_with_api_keys(service_id, only_active=False): stmt = ( - select(Service).filter_by(id=service_id).options(joinedload(Service.api_keys)) + select(Service) + .where(Service.id == service_id) + .options(joinedload(Service.api_keys)) ) if only_active: - stmt = stmt.filter(Service.active) + stmt = stmt.where(Service.active) return db.session.execute(stmt).scalars().unique().one() @@ -227,12 +229,12 @@ def dao_fetch_all_services_by_user(user_id, only_active=False): stmt = ( select(Service) - .filter(Service.users.any(id=user_id)) + .where(Service.users.any(id=user_id)) .order_by(asc(Service.created_at)) .options(joinedload(Service.users)) ) if only_active: - stmt = stmt.filter(Service.active) + stmt = stmt.where(Service.active) return db.session.execute(stmt).scalars().unique().all() @@ -240,7 +242,7 @@ def dao_fetch_all_services_created_by_user(user_id): stmt = ( select(Service) - .filter_by(created_by_id=user_id) + .where(Service.created_by_id == user_id) .order_by(asc(Service.created_at)) ) @@ -260,7 +262,7 @@ def dao_archive_service(service_id): joinedload(Service.templates).subqueryload(Template.template_redacted), joinedload(Service.api_keys), ) - .filter(Service.id == service_id) + .where(Service.id == service_id) ) service = db.session.execute(stmt).scalars().unique().one() @@ -281,7 +283,7 @@ def dao_fetch_service_by_id_and_user(service_id, user_id): stmt = ( select(Service) - .filter(Service.users.any(id=user_id), Service.id == service_id) + .where(Service.users.any(id=user_id), Service.id == service_id) .options(joinedload(Service.users)) ) result = db.session.execute(stmt).scalar_one() @@ -392,27 +394,39 @@ def delete_service_and_all_associated_db_objects(service): db.session.execute(stmt) db.session.commit() - subq = select(Template.id).filter_by(service=service).subquery() + subq = select(Template.id).where(Template.service == service).subquery() - stmt = delete(TemplateRedacted).filter(TemplateRedacted.template_id.in_(subq)) + stmt = delete(TemplateRedacted).where(TemplateRedacted.template_id.in_(subq)) _delete_commit(stmt) - _delete_commit(delete(ServiceSmsSender).filter_by(service=service)) - _delete_commit(delete(ServiceEmailReplyTo).filter_by(service=service)) - _delete_commit(delete(InvitedUser).filter_by(service=service)) - _delete_commit(delete(Permission).filter_by(service=service)) - _delete_commit(delete(NotificationHistory).filter_by(service=service)) - _delete_commit(delete(Notification).filter_by(service=service)) - _delete_commit(delete(Job).filter_by(service=service)) - _delete_commit(delete(Template).filter_by(service=service)) - _delete_commit(delete(TemplateHistory).filter_by(service_id=service.id)) - _delete_commit(delete(ServicePermission).filter_by(service_id=service.id)) - _delete_commit(delete(ApiKey).filter_by(service=service)) - _delete_commit(delete(ApiKey.get_history_model()).filter_by(service_id=service.id)) - _delete_commit(delete(AnnualBilling).filter_by(service_id=service.id)) + _delete_commit(delete(ServiceSmsSender).where(ServiceSmsSender.service == service)) + _delete_commit( + delete(ServiceEmailReplyTo).where(ServiceEmailReplyTo.service == service) + ) + _delete_commit(delete(InvitedUser).where(InvitedUser.service == service)) + _delete_commit(delete(Permission).where(Permission.service == service)) + _delete_commit( + delete(NotificationHistory).where(NotificationHistory.service == service) + ) + _delete_commit(delete(Notification).where(Notification.service == service)) + _delete_commit(delete(Job).where(Job.service == service)) + _delete_commit(delete(Template).where(Template.service == service)) + _delete_commit( + delete(TemplateHistory).where(TemplateHistory.service_id == service.id) + ) + _delete_commit( + delete(ServicePermission).where(ServicePermission.service_id == service.id) + ) + _delete_commit(delete(ApiKey).where(ApiKey.service == service)) + _delete_commit( + delete(ApiKey.get_history_model()).where( + ApiKey.get_history_model().service_id == service.id + ) + ) + _delete_commit(delete(AnnualBilling).where(AnnualBilling.service_id == service.id)) stmt = ( - select(VerifyCode).join(User).filter(User.id.in_([x.id for x in service.users])) + select(VerifyCode).join(User).where(User.id.in_([x.id for x in service.users])) ) verify_codes = db.session.execute(stmt).scalars().all() list(map(db.session.delete, verify_codes)) @@ -421,7 +435,7 @@ def delete_service_and_all_associated_db_objects(service): for user in users: user.organizations = [] service.users.remove(user) - _delete_commit(delete(Service.get_history_model()).filter_by(id=service.id)) + _delete_commit(delete(Service.get_history_model()).where(Service.id == service.id)) db.session.delete(service) db.session.commit() for user in users: @@ -438,7 +452,7 @@ def dao_fetch_todays_stats_for_service(service_id): Notification.status, func.count(Notification.id).label("count"), ) - .filter( + .where( Notification.service_id == service_id, Notification.key_type != KeyType.TEST, Notification.created_at >= start_date, @@ -455,6 +469,35 @@ 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)) + total_substmt = ( + select( + func.date_trunc("day", NotificationAllTimeView.created_at).label("day"), + Job.notification_count.label("notification_count"), + ) + .join(Job, NotificationAllTimeView.job_id == Job.id) + .where( + NotificationAllTimeView.service_id == service_id, + NotificationAllTimeView.key_type != KeyType.TEST, + NotificationAllTimeView.created_at >= start_date, + NotificationAllTimeView.created_at < end_date, + ) + .group_by( + Job.id, + Job.notification_count, + func.date_trunc("day", NotificationAllTimeView.created_at), + ) + .subquery() + ) + + total_stmt = select( + total_substmt.c.day, + func.sum(total_substmt.c.notification_count).label("total_notifications"), + ).group_by(total_substmt.c.day) + + total_notifications = { + row.day: row.total_notifications for row in db.session.execute(total_stmt).all() + } + stmt = ( select( NotificationAllTimeView.notification_type, @@ -462,7 +505,7 @@ def dao_fetch_stats_for_service_from_days(service_id, start_date, end_date): func.date_trunc("day", NotificationAllTimeView.created_at).label("day"), func.count(NotificationAllTimeView.id).label("count"), ) - .filter( + .where( NotificationAllTimeView.service_id == service_id, NotificationAllTimeView.key_type != KeyType.TEST, NotificationAllTimeView.created_at >= start_date, @@ -474,7 +517,10 @@ def dao_fetch_stats_for_service_from_days(service_id, start_date, end_date): func.date_trunc("day", NotificationAllTimeView.created_at), ) ) - return db.session.execute(stmt).all() + + data = db.session.execute(stmt).all() + + return total_notifications, data def dao_fetch_stats_for_service_from_days_for_user( @@ -483,6 +529,36 @@ def dao_fetch_stats_for_service_from_days_for_user( start_date = get_midnight_in_utc(start_date) end_date = get_midnight_in_utc(end_date + timedelta(days=1)) + total_substmt = ( + select( + func.date_trunc("day", NotificationAllTimeView.created_at).label("day"), + Job.notification_count.label("notification_count"), + ) + .join(Job, NotificationAllTimeView.job_id == Job.id) + .where( + 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( + Job.id, + Job.notification_count, + func.date_trunc("day", NotificationAllTimeView.created_at), + ) + .subquery() + ) + + total_stmt = select( + total_substmt.c.day, + func.sum(total_substmt.c.notification_count).label("total_notifications"), + ).group_by(total_substmt.c.day) + + total_notifications = { + row.day: row.total_notifications for row in db.session.execute(total_stmt).all() + } + stmt = ( select( NotificationAllTimeView.notification_type, @@ -490,8 +566,7 @@ def dao_fetch_stats_for_service_from_days_for_user( func.date_trunc("day", NotificationAllTimeView.created_at).label("day"), func.count(NotificationAllTimeView.id).label("count"), ) - .select_from(NotificationAllTimeView) - .filter( + .where( NotificationAllTimeView.service_id == service_id, NotificationAllTimeView.key_type != KeyType.TEST, NotificationAllTimeView.created_at >= start_date, @@ -504,7 +579,10 @@ def dao_fetch_stats_for_service_from_days_for_user( func.date_trunc("day", NotificationAllTimeView.created_at), ) ) - return db.session.execute(stmt).scalars().all() + + data = db.session.execute(stmt).all() + + return total_notifications, data def dao_fetch_todays_stats_for_all_services( @@ -514,14 +592,14 @@ def dao_fetch_todays_stats_for_all_services( start_date = get_midnight_in_utc(today) end_date = get_midnight_in_utc(today + timedelta(days=1)) - subquery = ( + substmt = ( select( Notification.notification_type, Notification.status, Notification.service_id, func.count(Notification.id).label("count"), ) - .filter( + .where( Notification.created_at >= start_date, Notification.created_at < end_date ) .group_by( @@ -530,9 +608,9 @@ def dao_fetch_todays_stats_for_all_services( ) if not include_from_test_key: - subquery = subquery.filter(Notification.key_type != KeyType.TEST) + substmt = substmt.where(Notification.key_type != KeyType.TEST) - subquery = subquery.subquery() + substmt = substmt.subquery() stmt = ( select( @@ -541,16 +619,16 @@ def dao_fetch_todays_stats_for_all_services( Service.restricted, Service.active, Service.created_at, - subquery.c.notification_type, - subquery.c.status, - subquery.c.count, + substmt.c.notification_type, + substmt.c.status, + substmt.c.count, ) - .outerjoin(subquery, subquery.c.service_id == Service.id) + .outerjoin(substmt, substmt.c.service_id == Service.id) .order_by(Service.id) ) if only_active: - stmt = stmt.filter(Service.active) + stmt = stmt.where(Service.active) return db.session.execute(stmt).all() @@ -565,7 +643,7 @@ def dao_suspend_service(service_id): stmt = ( select(Service) .options(joinedload(Service.api_keys)) - .filter(Service.id == service_id) + .where(Service.id == service_id) ) service = db.session.execute(stmt).scalars().unique().one() @@ -598,7 +676,7 @@ def dao_find_services_sending_to_tv_numbers(start_date, end_date, threshold=500) Notification.service_id.label("service_id"), func.count(Notification.id).label("notification_count"), ) - .filter( + .where( Notification.service_id == Service.id, Notification.created_at >= start_date, Notification.created_at <= end_date, @@ -617,12 +695,12 @@ def dao_find_services_sending_to_tv_numbers(start_date, end_date, threshold=500) def dao_find_services_with_high_failure_rates(start_date, end_date, threshold=10000): - subquery = ( + substmt = ( select( func.count(Notification.id).label("total_count"), Notification.service_id.label("service_id"), ) - .filter( + .where( Notification.service_id == Service.id, Notification.created_at >= start_date, Notification.created_at <= end_date, @@ -637,20 +715,20 @@ def dao_find_services_with_high_failure_rates(start_date, end_date, threshold=10 .having(func.count(Notification.id) >= threshold) ) - subquery = subquery.subquery() + substmt = substmt.subquery() stmt = ( select( Notification.service_id.label("service_id"), func.count(Notification.id).label("permanent_failure_count"), - subquery.c.total_count.label("total_count"), + substmt.c.total_count.label("total_count"), ( cast(func.count(Notification.id), Float) - / cast(subquery.c.total_count, Float) + / cast(substmt.c.total_count, Float) ).label("permanent_failure_rate"), ) - .join(subquery, subquery.c.service_id == Notification.service_id) - .filter( + .join(substmt, substmt.c.service_id == Notification.service_id) + .where( Notification.service_id == Service.id, Notification.created_at >= start_date, Notification.created_at <= end_date, @@ -660,10 +738,10 @@ def dao_find_services_with_high_failure_rates(start_date, end_date, threshold=10 Service.restricted == False, # noqa Service.active == True, # noqa ) - .group_by(Notification.service_id, subquery.c.total_count) + .group_by(Notification.service_id, substmt.c.total_count) .having( cast(func.count(Notification.id), Float) - / cast(subquery.c.total_count, Float) + / cast(substmt.c.total_count, Float) >= 0.25 ) ) @@ -682,7 +760,7 @@ def get_live_services_with_organization(): ) .select_from(Service) .outerjoin(Service.organization) - .filter( + .where( Service.count_as_live.is_(True), Service.active.is_(True), Service.restricted.is_(False), @@ -704,7 +782,7 @@ def fetch_notification_stats_for_service_by_month_by_user( (NotificationAllTimeView.status).label("notification_status"), func.count(NotificationAllTimeView.id).label("count"), ) - .filter( + .where( NotificationAllTimeView.service_id == service_id, NotificationAllTimeView.created_at >= start_date, NotificationAllTimeView.created_at < end_date, @@ -720,7 +798,9 @@ def fetch_notification_stats_for_service_by_month_by_user( return db.session.execute(stmt).all() -def get_specific_days_stats(data, start_date, days=None, end_date=None): +def get_specific_days_stats( + data, start_date, days=None, end_date=None, total_notifications=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: @@ -731,13 +811,19 @@ def get_specific_days_stats(data, start_date, days=None, end_date=None): 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} + day: [row for row in data if row.day == day] + for day in {item.day for item in data} } stats = { - day.strftime("%Y-%m-%d"): statistics.format_statistics(rows) + day.strftime("%Y-%m-%d"): statistics.format_statistics( + rows, + total_notifications=( + total_notifications.get(day, 0) + if total_notifications is not None + else None + ), + ) for day, rows in grouped_data.items() } - return stats diff --git a/app/dao/template_folder_dao.py b/app/dao/template_folder_dao.py index 269f407e0..36416edd6 100644 --- a/app/dao/template_folder_dao.py +++ b/app/dao/template_folder_dao.py @@ -6,14 +6,14 @@ from app.models import TemplateFolder def dao_get_template_folder_by_id_and_service_id(template_folder_id, service_id): - stmt = select(TemplateFolder).filter( + stmt = select(TemplateFolder).where( TemplateFolder.id == template_folder_id, TemplateFolder.service_id == service_id ) return db.session.execute(stmt).scalars().one() def dao_get_valid_template_folders_by_id(folder_ids): - stmt = select(TemplateFolder).filter(TemplateFolder.id.in_(folder_ids)) + stmt = select(TemplateFolder).where(TemplateFolder.id.in_(folder_ids)) return db.session.execute(stmt).scalars().all() diff --git a/app/dao/templates_dao.py b/app/dao/templates_dao.py index 7c5d7459e..c97e1fc10 100644 --- a/app/dao/templates_dao.py +++ b/app/dao/templates_dao.py @@ -46,21 +46,28 @@ def dao_redact_template(template, user_id): def dao_get_template_by_id_and_service_id(template_id, service_id, version=None): if version is not None: - stmt = select(TemplateHistory).filter_by( - id=template_id, hidden=False, service_id=service_id, version=version + stmt = select(TemplateHistory).where( + TemplateHistory.id == template_id, + TemplateHistory.hidden == False, # noqa + TemplateHistory.service_id == service_id, + TemplateHistory.version == version, ) return db.session.execute(stmt).scalars().one() - stmt = select(Template).filter_by( - id=template_id, hidden=False, service_id=service_id + stmt = select(Template).where( + Template.id == template_id, + Template.hidden == False, # noqa + Template.service_id == service_id, ) return db.session.execute(stmt).scalars().one() def dao_get_template_by_id(template_id, version=None): if version is not None: - stmt = select(TemplateHistory).filter_by(id=template_id, version=version) + stmt = select(TemplateHistory).where( + TemplateHistory.id == template_id, TemplateHistory.version == version + ) return db.session.execute(stmt).scalars().one() - stmt = select(Template).filter_by(id=template_id) + stmt = select(Template).where(Template.id == template_id) return db.session.execute(stmt).scalars().one() @@ -68,11 +75,11 @@ def dao_get_all_templates_for_service(service_id, template_type=None): if template_type is not None: stmt = ( select(Template) - .filter_by( - service_id=service_id, - template_type=template_type, - hidden=False, - archived=False, + .where( + Template.service_id == service_id, + Template.template_type == template_type, + Template.hidden == False, # noqa + Template.archived == False, # noqa ) .order_by( asc(Template.name), @@ -82,7 +89,11 @@ def dao_get_all_templates_for_service(service_id, template_type=None): return db.session.execute(stmt).scalars().all() stmt = ( select(Template) - .filter_by(service_id=service_id, hidden=False, archived=False) + .where( + Template.service_id == service_id, + Template.hidden == False, # noqa + Template.archived == False, # noqa + ) .order_by( asc(Template.name), asc(Template.template_type), @@ -94,10 +105,10 @@ def dao_get_all_templates_for_service(service_id, template_type=None): def dao_get_template_versions(service_id, template_id): stmt = ( select(TemplateHistory) - .filter_by( - service_id=service_id, - id=template_id, - hidden=False, + .where( + TemplateHistory.service_id == service_id, + TemplateHistory.id == template_id, + TemplateHistory.hidden == False, # noqa ) .order_by(desc(TemplateHistory.version)) ) diff --git a/app/dao/uploads_dao.py b/app/dao/uploads_dao.py index 1f7b7021c..48ee3bd73 100644 --- a/app/dao/uploads_dao.py +++ b/app/dao/uploads_dao.py @@ -1,9 +1,10 @@ from os import getenv from flask import current_app -from sqlalchemy import String, and_, desc, func, literal, text +from sqlalchemy import String, and_, desc, func, literal, select, text, union from app import db +from app.dao.inbound_sms_dao import Pagination from app.enums import JobStatus, NotificationStatus, NotificationType from app.models import Job, Notification, ServiceDataRetention, Template from app.utils import midnight_n_days_ago, utc_now @@ -51,8 +52,8 @@ def dao_get_uploads_by_service_id(service_id, limit_days=None, page=1, page_size if limit_days is not None: jobs_query_filter.append(Job.created_at >= midnight_n_days_ago(limit_days)) - jobs_query = ( - db.session.query( + jobs_stmt = ( + select( Job.id, Job.original_file_name, Job.notification_count, @@ -67,6 +68,7 @@ def dao_get_uploads_by_service_id(service_id, limit_days=None, page=1, page_size literal("job").label("upload_type"), literal(None).label("recipient"), ) + .select_from(Job) .join(Template, Job.template_id == Template.id) .outerjoin( ServiceDataRetention, @@ -76,7 +78,7 @@ def dao_get_uploads_by_service_id(service_id, limit_days=None, page=1, page_size == func.cast(ServiceDataRetention.notification_type, String), ), ) - .filter(*jobs_query_filter) + .where(*jobs_query_filter) ) letters_query_filter = [ @@ -93,13 +95,14 @@ def dao_get_uploads_by_service_id(service_id, limit_days=None, page=1, page_size Notification.created_at >= midnight_n_days_ago(limit_days) ) - letters_subquery = ( - db.session.query( + letters_substmt = ( + select( func.count().label("notification_count"), _naive_gmt_to_utc(_get_printing_datetime(Notification.created_at)).label( "printing_at" ), ) + .select_from(Notification) .join(Template, Notification.template_id == Template.id) .outerjoin( ServiceDataRetention, @@ -109,30 +112,39 @@ def dao_get_uploads_by_service_id(service_id, limit_days=None, page=1, page_size == func.cast(ServiceDataRetention.notification_type, String), ), ) - .filter(*letters_query_filter) + .where(*letters_query_filter) .group_by("printing_at") .subquery() ) - letters_query = db.session.query( - literal(None).label("id"), - literal("Uploaded letters").label("original_file_name"), - letters_subquery.c.notification_count.label("notification_count"), - literal("letter").label("template_type"), - literal(None).label("days_of_retention"), - letters_subquery.c.printing_at.label("created_at"), - literal(None).label("scheduled_for"), - letters_subquery.c.printing_at.label("processing_started"), - literal(None).label("status"), - literal("letter_day").label("upload_type"), - literal(None).label("recipient"), - ).group_by( - letters_subquery.c.notification_count, - letters_subquery.c.printing_at, + letters_stmt = ( + select( + literal(None).label("id"), + literal("Uploaded letters").label("original_file_name"), + letters_substmt.c.notification_count.label("notification_count"), + literal("letter").label("template_type"), + literal(None).label("days_of_retention"), + letters_substmt.c.printing_at.label("created_at"), + literal(None).label("scheduled_for"), + letters_substmt.c.printing_at.label("processing_started"), + literal(None).label("status"), + literal("letter_day").label("upload_type"), + literal(None).label("recipient"), + ) + .select_from(Notification) + .group_by( + letters_substmt.c.notification_count, + letters_substmt.c.printing_at, + ) ) - return ( - jobs_query.union_all(letters_query) - .order_by(desc("processing_started"), desc("created_at")) - .paginate(page=page, per_page=page_size) + stmt = union(jobs_stmt, letters_stmt).order_by( + desc("processing_started"), desc("created_at") ) + + results = db.session.execute(stmt).all() + page_size = current_app.config["PAGE_SIZE"] + offset = (page - 1) * page_size + paginated_results = results[offset : offset + page_size] + pagination = Pagination(paginated_results, page, page_size, len(results)) + return pagination diff --git a/app/dao/users_dao.py b/app/dao/users_dao.py index 690ecc7f9..8a411b27e 100644 --- a/app/dao/users_dao.py +++ b/app/dao/users_dao.py @@ -37,7 +37,7 @@ def get_login_gov_user(login_uuid, email_address): login.gov uuids are. Eventually the code that checks by email address should be removed. """ - stmt = select(User).filter_by(login_uuid=login_uuid) + stmt = select(User).where(User.login_uuid == login_uuid) user = db.session.execute(stmt).scalars().first() if user: if user.email_address != email_address: @@ -54,7 +54,7 @@ def get_login_gov_user(login_uuid, email_address): return user # Remove this 1 July 2025, all users should have login.gov uuids by now - stmt = select(User).filter(User.email_address.ilike(email_address)) + stmt = select(User).where(User.email_address.ilike(email_address)) user = db.session.execute(stmt).scalars().first() if user: @@ -65,7 +65,7 @@ def get_login_gov_user(login_uuid, email_address): def save_user_attribute(usr, update_dict=None): - db.session.query(User).filter_by(id=usr.id).update(update_dict or {}) + db.session.query(User).where(User.id == usr.id).update(update_dict or {}) db.session.commit() @@ -82,7 +82,7 @@ def save_model_user( user.email_access_validated_at = utc_now() if update_dict: _remove_values_for_keys_if_present(update_dict, ["id", "password_changed_at"]) - db.session.query(User).filter_by(id=user.id).update(update_dict or {}) + db.session.query(User).where(User.id == user.id).update(update_dict or {}) else: db.session.add(user) db.session.commit() @@ -105,7 +105,7 @@ def get_user_code(user, code, code_type): # time searching for the correct code. stmt = ( select(VerifyCode) - .filter_by(user=user, code_type=code_type) + .where(VerifyCode.user == user, VerifyCode.code_type == code_type) .order_by(VerifyCode.created_at.desc()) ) codes = db.session.execute(stmt).scalars().all() @@ -113,7 +113,7 @@ def get_user_code(user, code, code_type): def delete_codes_older_created_more_than_a_day_ago(): - stmt = delete(VerifyCode).filter( + stmt = delete(VerifyCode).where( VerifyCode.created_at < utc_now() - timedelta(hours=24) ) @@ -135,13 +135,13 @@ def delete_model_user(user): def delete_user_verify_codes(user): - stmt = delete(VerifyCode).filter_by(user=user) + stmt = delete(VerifyCode).where(VerifyCode.user == user) db.session.execute(stmt) db.session.commit() def count_user_verify_codes(user): - stmt = select(func.count(VerifyCode.id)).filter( + stmt = select(func.count(VerifyCode.id)).where( VerifyCode.user == user, VerifyCode.expiry_datetime > utc_now(), VerifyCode.code_used.is_(False), @@ -152,7 +152,7 @@ def count_user_verify_codes(user): def get_user_by_id(user_id=None): if user_id: - stmt = select(User).filter_by(id=user_id) + stmt = select(User).where(User.id == user_id) return db.session.execute(stmt).scalars().one() return get_users() @@ -163,13 +163,13 @@ def get_users(): def get_user_by_email(email): - stmt = select(User).filter(func.lower(User.email_address) == func.lower(email)) + stmt = select(User).where(func.lower(User.email_address) == func.lower(email)) return db.session.execute(stmt).scalars().one() def get_users_by_partial_email(email): email = escape_special_characters(email) - stmt = select(User).filter(User.email_address.ilike("%{}%".format(email))) + stmt = select(User).where(User.email_address.ilike("%{}%".format(email))) return db.session.execute(stmt).scalars().all() @@ -200,7 +200,7 @@ def get_user_and_accounts(user_id): # that we have put is functionally doing the same thing as before stmt = ( select(User) - .filter(User.id == user_id) + .where(User.id == user_id) .options( # eagerly load the user's services and organizations, and also the service's org and vice versa # (so we can see if the user knows about it) diff --git a/app/dao/webauthn_credential_dao.py b/app/dao/webauthn_credential_dao.py index b34d3c014..4c7a0c888 100644 --- a/app/dao/webauthn_credential_dao.py +++ b/app/dao/webauthn_credential_dao.py @@ -1,13 +1,16 @@ +from sqlalchemy import select + from app import db from app.dao.dao_utils import autocommit from app.models import WebauthnCredential def dao_get_webauthn_credential_by_user_and_id(user_id, webauthn_credential_id): - return WebauthnCredential.query.filter( + stmt = select(WebauthnCredential).where( WebauthnCredential.user_id == user_id, WebauthnCredential.id == webauthn_credential_id, - ).one() + ) + return db.session.execute(stmt).scalars().one() @autocommit diff --git a/app/delivery/send_to_providers.py b/app/delivery/send_to_providers.py index 07763823f..515d418e7 100644 --- a/app/delivery/send_to_providers.py +++ b/app/delivery/send_to_providers.py @@ -16,13 +16,17 @@ from app import ( from app.aws.s3 import get_personalisation_from_s3, get_phone_number_from_s3 from app.celery.test_key_tasks import send_email_response, send_sms_response from app.dao.email_branding_dao import dao_get_email_branding_by_id -from app.dao.notifications_dao import dao_update_notification +from app.dao.notifications_dao import ( + dao_update_notification, + update_notification_message_id, +) from app.dao.provider_details_dao import get_provider_details_by_notification_type from app.dao.service_sms_sender_dao import dao_get_sms_senders_by_service_id 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, utc_now +from notifications_utils.clients.redis import total_limit_cache_key from notifications_utils.template import ( HTMLEmailTemplate, PlainTextEmailTemplate, @@ -116,7 +120,8 @@ def send_sms_to_provider(notification): db.session.close() # no commit needed as no changes to objects have been made above message_id = provider.send_sms(**send_sms_kwargs) - current_app.logger.info(f"got message_id {message_id}") + + update_notification_message_id(notification.id, message_id) except Exception as e: n = notification msg = f"FAILED send to sms, job_id: {n.job_id} row_number {n.job_row_number} message_id {message_id}" @@ -128,10 +133,14 @@ def send_sms_to_provider(notification): else: # 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}" + 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) + + cache_key = total_limit_cache_key(service.id) + redis_store.incr(cache_key) + return message_id diff --git a/app/enums.py b/app/enums.py index a0dfbb467..37b3b6892 100644 --- a/app/enums.py +++ b/app/enums.py @@ -211,3 +211,4 @@ class StatisticsType(StrEnum): REQUESTED = "requested" DELIVERED = "delivered" FAILURE = "failure" + PENDING = "pending" diff --git a/app/models.py b/app/models.py index 6b008f64b..f78f630ea 100644 --- a/app/models.py +++ b/app/models.py @@ -5,7 +5,7 @@ from flask import current_app, url_for from sqlalchemy import CheckConstraint, Index, UniqueConstraint from sqlalchemy.dialects.postgresql import JSON, JSONB, UUID from sqlalchemy.ext.associationproxy import association_proxy -from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.ext.declarative import DeclarativeMeta, declared_attr from sqlalchemy.orm import validates from sqlalchemy.orm.collections import attribute_mapped_collection @@ -577,7 +577,16 @@ class Service(db.Model, Versioned): return self.inbound_number.number def get_default_sms_sender(self): - default_sms_sender = [x for x in self.service_sms_senders if x.is_default] + # notify-api-1513 let's try a minimalistic fix + # to see if we can get the right numbers back + default_sms_sender = [ + x + for x in self.service_sms_senders + if x.is_default and x.service_id == self.id + ] + current_app.logger.info( + f"#notify-api-1513 senders for service {self.name} are {self.service_sms_senders}" + ) return default_sms_sender[0].sms_sender def get_default_reply_to_email_address(self): @@ -1532,6 +1541,7 @@ class Notification(db.Model): provider_response = db.Column(db.Text, nullable=True) carrier = db.Column(db.Text, nullable=True) + message_id = db.Column(db.Text, nullable=True) # queue_name = db.Column(db.Text, nullable=True) @@ -1684,6 +1694,33 @@ class Notification(db.Model): else: return None + def serialize_for_redis(self, obj): + if isinstance(obj.__class__, DeclarativeMeta): + fields = {} + for column in obj.__table__.columns: + if column.name == "notification_status": + new_name = "status" + value = getattr(obj, new_name) + elif column.name == "created_at": + if isinstance(obj.created_at, str): + value = obj.created_at + else: + value = (obj.created_at.strftime("%Y-%m-%d %H:%M:%S"),) + elif column.name in ["sent_at", "completed_at"]: + value = None + elif column.name.endswith("_id"): + value = getattr(obj, column.name) + value = str(value) + else: + value = getattr(obj, column.name) + if column.name in ["message_id", "api_key_id"]: + pass # do nothing because we don't have the message id yet + else: + fields[column.name] = value + + return fields + raise ValueError("Provided object is not a SQLAlchemy instance") + def serialize_for_csv(self): serialized = { "row_number": ( diff --git a/app/notifications/process_notifications.py b/app/notifications/process_notifications.py index 4f5d8d06c..6b78ce753 100644 --- a/app/notifications/process_notifications.py +++ b/app/notifications/process_notifications.py @@ -1,3 +1,5 @@ +import json +import os import uuid from flask import current_app @@ -8,8 +10,10 @@ from app.config import QueueNames from app.dao.notifications_dao import ( dao_create_notification, dao_delete_notifications_by_id, + dao_notification_exists, + get_notification_by_id, ) -from app.enums import KeyType, NotificationStatus, NotificationType +from app.enums import NotificationStatus, NotificationType from app.errors import BadRequestError from app.models import Notification from app.utils import hilite, utc_now @@ -53,6 +57,10 @@ def check_placeholders(template_object): raise BadRequestError(fields=[{"template": message}], message=message) +def get_notification(notification_id): + return get_notification_by_id(notification_id) + + def persist_notification( *, template_id, @@ -133,21 +141,25 @@ def persist_notification( # if simulated create a Notification model to return but do not persist the Notification to the dB if not simulated: - current_app.logger.info("Firing dao_create_notification") - dao_create_notification(notification) - if key_type != KeyType.TEST and current_app.config["REDIS_ENABLED"]: - current_app.logger.info( - "Redis enabled, querying cache key for service id: {}".format( - service.id + if notification.notification_type == NotificationType.SMS: + # it's just too hard with redis and timing to test this here + if os.getenv("NOTIFY_ENVIRONMENT") == "test": + dao_create_notification(notification) + else: + redis_store.rpush( + "message_queue", + json.dumps(notification.serialize_for_redis(notification)), ) - ) + else: + dao_create_notification(notification) - current_app.logger.info( - f"{notification_type} {notification_id} created at {notification_created_at}" - ) return notification +def notification_exists(notification_id): + return dao_notification_exists(notification_id) + + def send_notification_to_queue_detached( key_type, notification_type, notification_id, queue=None ): @@ -162,7 +174,7 @@ def send_notification_to_queue_detached( deliver_task = provider_tasks.deliver_email try: - deliver_task.apply_async([str(notification_id)], queue=queue) + deliver_task.apply_async([str(notification_id)], queue=queue, countdown=60) except Exception: dao_delete_notifications_by_id(notification_id) raise diff --git a/app/notifications/rest.py b/app/notifications/rest.py index 43224f0e7..4e9adc2c6 100644 --- a/app/notifications/rest.py +++ b/app/notifications/rest.py @@ -12,7 +12,6 @@ from app.notifications.process_notifications import ( ) from app.notifications.validators import ( check_if_service_can_send_to_number, - check_rate_limiting, service_has_permission, validate_template, ) @@ -125,8 +124,6 @@ def send_notification(notification_type): else email_notification_schema ).load(request.get_json()) - check_rate_limiting(authenticated_service, api_user) - template, template_with_content = validate_template( template_id=notification_form["template"], personalisation=notification_form.get("personalisation", {}), diff --git a/app/notifications/validators.py b/app/notifications/validators.py index f0a7f2a8f..8358b3c8a 100644 --- a/app/notifications/validators.py +++ b/app/notifications/validators.py @@ -1,3 +1,6 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + from flask import current_app from sqlalchemy.orm.exc import NoResultFound @@ -6,17 +9,14 @@ from app.dao.notifications_dao import dao_get_notification_count_for_service from app.dao.service_email_reply_to_dao import dao_get_reply_to_by_id from app.dao.service_sms_sender_dao import dao_get_service_sms_senders_by_id from app.enums import KeyType, NotificationType, ServicePermissionType, TemplateType -from app.errors import BadRequestError, RateLimitError, TotalRequestsError +from app.errors import BadRequestError, TotalRequestsError from app.models import ServicePermission from app.notifications.process_notifications import create_content_for_notification from app.serialised_models import SerialisedTemplate from app.service.utils import service_allowed_to_send_to from app.utils import get_public_notify_type_text from notifications_utils import SMS_CHAR_COUNT_LIMIT -from notifications_utils.clients.redis import ( - rate_limit_cache_key, - total_limit_cache_key, -) +from notifications_utils.clients.redis import total_limit_cache_key from notifications_utils.recipients import ( get_international_phone_info, validate_and_format_email_address, @@ -24,31 +24,27 @@ from notifications_utils.recipients import ( ) -def check_service_over_api_rate_limit(service, api_key): - if ( - current_app.config["API_RATE_LIMIT_ENABLED"] - and current_app.config["REDIS_ENABLED"] - ): - cache_key = rate_limit_cache_key(service.id, api_key.key_type) - rate_limit = service.rate_limit - interval = 60 - if redis_store.exceeded_rate_limit(cache_key, rate_limit, interval): - current_app.logger.info( - "service {} has been rate limited for throughput".format(service.id) - ) - raise RateLimitError(rate_limit, interval, api_key.key_type) - - def check_service_over_total_message_limit(key_type, service): if key_type == KeyType.TEST or not current_app.config["REDIS_ENABLED"]: return 0 cache_key = total_limit_cache_key(service.id) service_stats = redis_store.get(cache_key) + + # TODO + # For now we are using calendar year + # Switch to using service agreement dates when the Agreement model is ready + # If the service stat has never been set before, compute the remaining seconds for 2025 + # and set it (all services) to expire on 12/31/2025. if service_stats is None: - # first message of the day, set the cache to 0 and the expiry to 24 hours + now_et = datetime.now(ZoneInfo("America/New_York")) + target_time = datetime( + 2025, 12, 31, 23, 59, 59, tzinfo=ZoneInfo("America/New_York") + ) + time_difference = target_time - now_et + seconds_difference = int(time_difference.total_seconds()) service_stats = 0 - redis_store.set(cache_key, service_stats, ex=86400) + redis_store.set(cache_key, service_stats, ex=seconds_difference) return service_stats if int(service_stats) >= service.total_message_limit: current_app.logger.warning( @@ -57,6 +53,7 @@ def check_service_over_total_message_limit(key_type, service): ) ) raise TotalRequestsError(service.total_message_limit) + return int(service_stats) @@ -77,11 +74,6 @@ def check_application_over_retention_limit(key_type, service): return int(total_stats) -def check_rate_limiting(service, api_key): - check_service_over_api_rate_limit(service, api_key) - check_application_over_retention_limit(api_key.key_type, service) - - def check_template_is_for_notification_type(notification_type, template_type): if notification_type != template_type: message = "{0} template is not suitable for {1} notification".format( diff --git a/app/service/rest.py b/app/service/rest.py index 7dd614058..98cb0e963 100644 --- a/app/service/rest.py +++ b/app/service/rest.py @@ -2,10 +2,12 @@ import itertools from datetime import datetime, timedelta from flask import Blueprint, current_app, jsonify, request +from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm.exc import NoResultFound from werkzeug.datastructures import MultiDict +from app import db, redis_store from app.aws.s3 import get_personalisation_from_s3, get_phone_number_from_s3 from app.config import QueueNames from app.dao import fact_notification_status_dao, notifications_dao @@ -107,6 +109,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 notifications_utils.clients.redis import total_limit_cache_key service_blueprint = Blueprint("service", __name__) @@ -230,9 +233,18 @@ def get_service_statistics_for_specific_days(service_id, start, days=1): 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) + total_notifications, results = dao_fetch_stats_for_service_from_days( + service_id, + start_date, + end_date, + ) - stats = get_specific_days_stats(results, start_date, days=days) + stats = get_specific_days_stats( + results, + start_date, + days=days, + total_notifications=total_notifications, + ) return stats @@ -259,12 +271,16 @@ def get_service_statistics_for_specific_days_by_user( 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( + total_notifications, 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) - + stats = get_specific_days_stats( + results, + start_date, + days=days, + total_notifications=total_notifications, + ) return stats @@ -312,7 +328,7 @@ def update_service(service_id): service.email_branding = ( None if not email_branding_id - else EmailBranding.query.get(email_branding_id) + else db.session.get(EmailBranding, email_branding_id) ) dao_update_service(service) @@ -419,14 +435,34 @@ def get_service_history(service_id): template_history_schema, ) - service_history = Service.get_history_model().query.filter_by(id=service_id).all() + service_history = ( + db.session.execute( + select(Service.get_history_model()).where( + Service.get_history_model().id == service_id + ) + ) + .scalars() + .all() + ) service_data = service_history_schema.dump(service_history, many=True) api_key_history = ( - ApiKey.get_history_model().query.filter_by(service_id=service_id).all() + db.session.execute( + select(ApiKey.get_history_model()).where( + ApiKey.get_history_model().service_id == service_id + ) + ) + .scalars() + .all() ) api_keys_data = api_key_history_schema.dump(api_key_history, many=True) - template_history = TemplateHistory.query.filter_by(service_id=service_id).all() + template_history = ( + db.session.execute( + select(TemplateHistory).where(TemplateHistory.service_id == service_id) + ) + .scalars() + .all() + ) template_data = template_history_schema.dump(template_history, many=True) data = { @@ -654,11 +690,16 @@ def get_single_month_notification_stats_by_user(service_id, user_id): 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( + total_notifications, 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) + stats = get_specific_days_stats( + results, + start_date, + end_date=end_date, + total_notifications=total_notifications, + ) return jsonify(stats) @@ -678,7 +719,9 @@ def get_single_month_notification_stats_for_service(service_id): 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) + __, 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) @@ -878,7 +921,7 @@ def verify_reply_to_email_address(service_id): template = dao_get_template_by_id( current_app.config["REPLY_TO_EMAIL_ADDRESS_VERIFICATION_TEMPLATE_ID"] ) - notify_service = Service.query.get(current_app.config["NOTIFY_SERVICE_ID"]) + notify_service = db.session.get(Service, current_app.config["NOTIFY_SERVICE_ID"]) saved_notification = persist_notification( template_id=template.id, template_version=template.version, @@ -1098,6 +1141,28 @@ def modify_service_data_retention(service_id, data_retention_id): return "", 204 +@service_blueprint.route("/get-service-message-ratio") +def get_service_message_ratio(): + service_id = request.args.get("service_id") + + my_service = dao_fetch_service_by_id(service_id) + + cache_key = total_limit_cache_key(service_id) + messages_sent = redis_store.get(cache_key) + if messages_sent is None: + messages_sent = 0 + current_app.logger.warning( + f"Messages sent was not being tracked for service {service_id}" + ) + else: + messages_sent = int(messages_sent) + + return { + "messages_sent": messages_sent, + "total_message_limit": my_service.total_message_limit, + }, 200 + + @service_blueprint.route("/monthly-data-by-service") def get_monthly_notification_data_by_service(): start_date = request.args.get("start_date") diff --git a/app/service/sender.py b/app/service/sender.py index 4b954f60b..a769dc4d9 100644 --- a/app/service/sender.py +++ b/app/service/sender.py @@ -1,5 +1,8 @@ +import json + from flask import current_app +from app import redis_store from app.config import QueueNames from app.dao.services_dao import ( dao_fetch_active_users_for_service, @@ -40,6 +43,15 @@ def send_notification_to_service_users( key_type=KeyType.NORMAL, reply_to_text=notify_service.get_default_reply_to_email_address(), ) + redis_store.set( + f"email-personalisation-{notification.id}", + json.dumps(personalisation), + ex=24 * 60 * 60, + ) + redis_store.set( + f"email-recipient-{notification.id}", notification.to, ex=24 * 60 * 60 + ) + send_notification_to_queue(notification, queue=QueueNames.NOTIFY) diff --git a/app/service/statistics.py b/app/service/statistics.py index a6b58e067..d6d776539 100644 --- a/app/service/statistics.py +++ b/app/service/statistics.py @@ -2,10 +2,16 @@ from collections import defaultdict from datetime import datetime from app.dao.date_util import get_months_for_financial_year -from app.enums import KeyType, NotificationStatus, StatisticsType, TemplateType +from app.enums import ( + KeyType, + NotificationStatus, + NotificationType, + StatisticsType, + TemplateType, +) -def format_statistics(statistics): +def format_statistics(statistics, total_notifications=None): # statistics come in a named tuple with uniqueness from 'notification_type', 'status' - however missing # statuses/notification types won't be represented and the status types need to be simplified/summed up # so we can return emails/sms * created, sent, and failed @@ -14,11 +20,27 @@ def format_statistics(statistics): # any row could be null, if the service either has no notifications in the notifications table, # or no historical data in the ft_notification_status table. if row.notification_type: - _update_statuses_from_row(counts[row.notification_type], row) + _update_statuses_from_row( + counts[row.notification_type], + row, + ) + + if NotificationType.SMS in counts and total_notifications is not None: + sms_dict = counts[NotificationType.SMS] + delivered_count = sms_dict[StatisticsType.DELIVERED] + failed_count = sms_dict[StatisticsType.FAILURE] + sms_dict[StatisticsType.PENDING] = calculate_pending_stats( + delivered_count, failed_count, total_notifications + ) return counts +def calculate_pending_stats(delivered_count, failed_count, total_notifications): + pending_count = total_notifications - (delivered_count + failed_count) + return max(0, pending_count) + + def format_admin_stats(statistics): counts = create_stats_dict() diff --git a/app/service_invite/rest.py b/app/service_invite/rest.py index 38bc1c404..8a338a77c 100644 --- a/app/service_invite/rest.py +++ b/app/service_invite/rest.py @@ -6,7 +6,7 @@ from urllib.parse import unquote from flask import Blueprint, current_app, jsonify, request from itsdangerous import BadData, SignatureExpired -from app import redis_store +from app import db, redis_store from app.config import QueueNames from app.dao.invited_user_dao import ( get_expired_invite_by_service_and_id, @@ -39,7 +39,7 @@ def _create_service_invite(invited_user, nonce, state): template = dao_get_template_by_id(template_id) - service = Service.query.get(current_app.config["NOTIFY_SERVICE_ID"]) + service = db.session.get(Service, current_app.config["NOTIFY_SERVICE_ID"]) # The raw permissions are in the form "a,b,c,d" # but need to be in the form ["a", "b", "c", "d"] @@ -54,7 +54,7 @@ def _create_service_invite(invited_user, nonce, state): data["invited_user_email"] = invited_user.email_address invite_redis_key = f"invite-data-{unquote(state)}" - redis_store.set(invite_redis_key, get_user_data_url_safe(data)) + redis_store.set(invite_redis_key, get_user_data_url_safe(data), ex=2 * 24 * 60 * 60) url = os.environ["LOGIN_DOT_GOV_REGISTRATION_URL"] @@ -67,7 +67,7 @@ def _create_service_invite(invited_user, nonce, state): "service_name": invited_user.service.name, "url": url, } - + created_at = utc_now() saved_notification = persist_notification( template_id=template.id, template_version=template.version, @@ -78,6 +78,7 @@ def _create_service_invite(invited_user, nonce, state): api_key_id=None, key_type=KeyType.NORMAL, reply_to_text=invited_user.from_user.email_address, + created_at=created_at, ) saved_notification.personalisation = personalisation redis_store.set( diff --git a/app/user/rest.py b/app/user/rest.py index f4f4db947..da86521ff 100644 --- a/app/user/rest.py +++ b/app/user/rest.py @@ -6,7 +6,7 @@ from flask import Blueprint, abort, current_app, jsonify, request from sqlalchemy.exc import IntegrityError from sqlalchemy.orm.exc import NoResultFound -from app import redis_store +from app import db, redis_store from app.config import QueueNames from app.dao.permissions_dao import permission_dao from app.dao.service_user_dao import dao_get_service_user, dao_update_service_user @@ -120,7 +120,7 @@ def update_user_attribute(user_id): reply_to = get_sms_reply_to_for_notify_service(recipient, template) else: return jsonify(data=user_to_update.serialize()), 200 - service = Service.query.get(current_app.config["NOTIFY_SERVICE_ID"]) + service = db.session.get(Service, current_app.config["NOTIFY_SERVICE_ID"]) personalisation = { "name": user_to_update.name, "servicemanagername": updated_by.name, @@ -393,7 +393,7 @@ def send_user_confirm_new_email(user_id): template = dao_get_template_by_id( current_app.config["CHANGE_EMAIL_CONFIRMATION_TEMPLATE_ID"] ) - service = Service.query.get(current_app.config["NOTIFY_SERVICE_ID"]) + service = db.session.get(Service, current_app.config["NOTIFY_SERVICE_ID"]) personalisation = { "name": user_to_send_to.name, "url": _create_confirmation_url( @@ -434,7 +434,7 @@ def send_new_user_email_verification(user_id): template = dao_get_template_by_id( current_app.config["NEW_USER_EMAIL_VERIFICATION_TEMPLATE_ID"] ) - service = Service.query.get(current_app.config["NOTIFY_SERVICE_ID"]) + service = db.session.get(Service, current_app.config["NOTIFY_SERVICE_ID"]) current_app.logger.info("template.id is {}".format(template.id)) current_app.logger.info("service.id is {}".format(service.id)) @@ -487,7 +487,7 @@ def send_already_registered_email(user_id): template = dao_get_template_by_id( current_app.config["ALREADY_REGISTERED_EMAIL_TEMPLATE_ID"] ) - service = Service.query.get(current_app.config["NOTIFY_SERVICE_ID"]) + service = db.session.get(Service, current_app.config["NOTIFY_SERVICE_ID"]) current_app.logger.info("template.id is {}".format(template.id)) current_app.logger.info("service.id is {}".format(service.id)) diff --git a/app/v2/notifications/post_notifications.py b/app/v2/notifications/post_notifications.py index a5ad17646..a8dc894c7 100644 --- a/app/v2/notifications/post_notifications.py +++ b/app/v2/notifications/post_notifications.py @@ -18,7 +18,6 @@ from app.notifications.process_notifications import ( from app.notifications.validators import ( check_if_service_can_send_files_by_email, check_is_message_too_long, - check_rate_limiting, check_service_email_reply_to_id, check_service_has_permission, check_service_sms_sender_id, @@ -54,8 +53,6 @@ def post_notification(notification_type): check_service_has_permission(notification_type, authenticated_service.permissions) - check_rate_limiting(authenticated_service, api_user) - template, template_with_content = validate_template( form["template_id"], form.get("personalisation", {}), diff --git a/application.py b/application.py index 25885fc16..0b1256667 100644 --- a/application.py +++ b/application.py @@ -2,9 +2,12 @@ from __future__ import print_function from flask import Flask +from werkzeug.serving import WSGIRequestHandler from app import create_app +WSGIRequestHandler.version_string = lambda self: "SecureServer" + application = Flask("app") create_app(application) diff --git a/deploy-config/sandbox.yml b/deploy-config/sandbox.yml index d94339837..afaf40c52 100644 --- a/deploy-config/sandbox.yml +++ b/deploy-config/sandbox.yml @@ -9,5 +9,10 @@ admin_base_url: https://notify-sandbox.app.cloud.gov redis_enabled: 1 default_toll_free_number: "+18885989205" ADMIN_CLIENT_SECRET: sandbox-notify-secret-key +API_HOST_NAME: https://notify-api-sandbox.app.cloud.gov DANGEROUS_SALT: sandbox-notify-salt +LOGIN_DOT_GOV_REGISTRATION_URL: https://idp.int.identitysandbox.gov/openid_connect/authorize?acr_values=http%3A%2F%2Fidmanagement.gov%2Fns%2Fassurance%2Fial%2F1&client_id=urn:gov:gsa:openidconnect.profiles:sp:sso:gsa:test_notify_gov&nonce=NONCE&prompt=select_account&redirect_uri=https://notify-sandbox.app.cloud.gov/set-up-your-profile&response_type=code&scope=openid+email&state=STATE +NEW_RELIC_LICENSE_KEY: "" +NOTIFY_E2E_TEST_EMAIL: fake.user@example.com +NOTIFY_E2E_TEST_PASSWORD: "don't write secrets to the sample file" SECRET_KEY: sandbox-notify-secret-key diff --git a/docs/adrs/0010-adr-celery-pool-support-best-practice.md b/docs/adrs/0010-adr-celery-pool-support-best-practice.md new file mode 100644 index 000000000..4c63f6d08 --- /dev/null +++ b/docs/adrs/0010-adr-celery-pool-support-best-practice.md @@ -0,0 +1,27 @@ +# Make best use of celery worker pools + +Status: Accepted +Date: 7 January 2025 + +### Context +Our API application started with initial celery pool support of 'prefork' (the default) and concurrency of 4. We continuously encountered instability, which we initially attributed to a resource leak. As a result of this we added the configuration `worker-max-tasks-per-child=500` which is a best practice. When we ran a load test of 25000 simulated messages, however, we continued to see stability issues, amounting to a crash of the app after 4 hours requiring a restage. Based on running `cf app notify-api-production` and observing that `cpu entitlement` was off the charts at 10000% to 12000% for the works, and after doing some further reading, we came to the conclusion that perhaps `prefork` pool support is not the best type of pool support for the API application. + +The problem with `prefork` is that each process has a tendency to hang onto the CPU allocated to it, even if it is not being used. Our application is not computationally intensive and largely consists of downloading strings from S3, parsing the strings, and sending them out as SMS messages. Based on the determination that our app is likely I/O bound, we elected to do an experiment where we changed pool support to `threads` and increased concurrency to `10`. The expectation is that memory usage will decrease and CPU usage will decrease and the app will not become unavailable. + +### Decision + +We decided to try to the 'threads' pool support with increased concurrency. + +### Consequences + +We saw an immediate decrease in CPU usage of about 70% with no adverse consequences. + +### Author +@kenkehl + +### Stakeholders +@ccostino +@stvnrlly + +### Next Steps +- Run an after-hours load test with production configured to --pool=threads and --concurrency=10 (concurrency can be cautiously increased once we know it works) diff --git a/docs/adrs/0011-adr-delivery-receipts-updates.md b/docs/adrs/0011-adr-delivery-receipts-updates.md new file mode 100644 index 000000000..a42ea4223 --- /dev/null +++ b/docs/adrs/0011-adr-delivery-receipts-updates.md @@ -0,0 +1,31 @@ +# Optimize processing of delivery receipts + +Status: Accepted +Date: 22 January 2025 + +### Context +Our original effort to get delivery receipts for text messages was very object oriented and conformed to other patterns in the app. After an individual message was sent, we would kick off a new task on a delay, and this task would go search the cloudwatch logs for the given phone number. +On paper this looked good, but when one customer did a big send of 25k messages, we realized suddenly this was a bad idea. We overloaded the AWS api call and got massive throttling as a result. Although we ultimately did get most of the delivery receipts, it took hours and the logs were filled with errors. + +In refactoring this, there were two possible approaches we considered: + +1. Batch updates in the db (up to 1000 messages at a time). This involved running update queries with case statements and there is some theoretical limit on how large these statements can get and still be efficient. + +2. bulk_update_mappings(). This would be a raw updating similar to COPY where we could do millions of rows at a time. + +### Decision + +We decided to try to use batch updates. Even though they don't theoretically scale to the same level as bulk_update_mappings(), our app has a potential problem with using bulk_update_mappings(). In order for it to work, we would need to know the "id" for each notification, which is the primary key into the notifications table. We do NOT know the "id" when we process the delivery receipts. We do know the "message_id", but in order to get the "id" we would either have to a select query, or we would have to maintain some mapping in redis, etc. + +It is not clear, given the extra work necessary, that bulk_update_mappings() would be greatly superior to batch updates for our purposes. And batch updates currently allow us to scale at least 100x above where we are now. + +### Consequences + +Batch updates greatly cleaned up the logs (no more errors for throttling) and reduced CPU consumption. It was a very positive change. + +### Author +@kenkehl + +### Stakeholders +@ccostino +@stvnrlly diff --git a/docs/all.md b/docs/all.md index ccde4ede9..a4097194b 100644 --- a/docs/all.md +++ b/docs/all.md @@ -443,22 +443,44 @@ Rules for use: - Delete the apps and routes shown in `cf apps` by running `cf delete APP_NAME -r` - Delete the space deployer you created by following the instructions within `terraform/sandbox/secrets.auto.tfvars` -### Deploying to the sandbox +### Setting up the sandbox infrastructure If this is the first time you have used Terraform in this repository, you will first have to hook your copy of Terraform up to our remote state. Follow [Retrieving existing bucket credentials](https://github.com/GSA/notifications-api/tree/main/terraform#retrieving-existing-bucket-credentials). :anchor: The Admin app depends upon the API app, so set up the API first. 1. Set up services: - ```bash - $ cd terraform/sandbox - $ ../create_service_account.sh -s notify-sandbox -u -terraform -m > secrets.auto.tfvars - $ terraform init - $ terraform plan - $ terraform apply - ``` - Check [Terraform troubleshooting](https://github.com/GSA/notifications-api/tree/main/terraform#troubleshooting) if you encounter problems. -1. Change back to the project root directory: `cd ../..` + ```bash + $ cd terraform/sandbox + $ ../create_service_account.sh -s notify-sandbox -u -terraform -m > secrets.auto.tfvars + $ terraform init + $ terraform plan + $ terraform apply + ``` + Check [Terraform troubleshooting](https://github.com/GSA/notifications-api/tree/main/terraform#troubleshooting) if you encounter problems. + +Note that you'll have to do this for both the API and the Admin. Once this is complete we shouldn't have to do it again (unless we're setting up a new sandbox environment). + +### Deploying to the sandbox + +To deploy either the API or the Admin apps to the sandbox, the process is largely the same, but the Admin requires a bit of additional work. + +#### Deploying the API to the sandbox + +1. Make sure you are in the API project's root directory. +1. Authenticate with cloud.gov in the command line: `cf login -a api.fr.cloud.gov --sso` +1. Run `./scripts/deploy_to_sandbox.sh` from the project root directory. + +At this point your target org and space will change with cloud.gov to be the `notify-sandbox` environment and the application will be pushed for deployment. + +The script does a few things to make sure the deployment flows smoothly with miniminal work on your part: + +* Sets the target org and space in cloud.gov for you. +* Creates a `requirements.txt` file for the Python dependencies so that the deployment picks up on the dependencies properly. +* Pushes the application with the correct environment variables set based on what is supplied by the `deploy-config/sandbox.yml` file. + +#### Deploying the Admin to the sandbox + 1. Start a poetry shell as a shortcut to load `.env` file variables by running `poetry shell`. (You'll have to restart this any time you change the file.) 1. Output requirements.txt file: `poetry export --without-hashes --format=requirements.txt > requirements.txt` 1. Ensure you are using the correct CloudFoundry target diff --git a/loadtest_10k.csv b/loadtest_10k.csv new file mode 100644 index 000000000..86b1b5ac1 --- /dev/null +++ b/loadtest_10k.csv @@ -0,0 +1,10001 @@ +phone number ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 ++14254147755 ++14254147167 diff --git a/manifest.yml b/manifest.yml index a8a3e7f2b..0763a1911 100644 --- a/manifest.yml +++ b/manifest.yml @@ -26,7 +26,7 @@ applications: - type: worker instances: ((worker_instances)) memory: ((worker_memory)) - command: newrelic-admin run-program celery -A run_celery.notify_celery worker --loglevel=INFO --concurrency=4 + command: newrelic-admin run-program celery -A run_celery.notify_celery worker --loglevel=INFO --pool=threads --concurrency=10 --prefetch-multiplier=2 - type: scheduler instances: 1 memory: ((scheduler_memory)) diff --git a/migrations/versions/0413_add_message_id.py b/migrations/versions/0413_add_message_id.py new file mode 100644 index 000000000..00aeaafc2 --- /dev/null +++ b/migrations/versions/0413_add_message_id.py @@ -0,0 +1,28 @@ +""" + +Revision ID: 0413_add_message_id +Revises: 412_remove_priority +Create Date: 2023-12-11 11:35:22.873930 + +""" + +import sqlalchemy as sa +from alembic import op + +revision = "0413_add_message_id" +down_revision = "0412_remove_priority" + + +def upgrade(): + op.add_column("notifications", sa.Column("message_id", sa.Text)) + op.create_index( + "ix_notifications_message_id", + "notifications", + ["message_id"], + unique=False, + ) + + +def downgrade(): + op.drop_index("ix_notifications_message_id", table_name="notifications") + op.drop_column("notifications", "message_id") diff --git a/migrations/versions/0414_change_total_message_limit.py b/migrations/versions/0414_change_total_message_limit.py new file mode 100644 index 000000000..8a3d9b3e2 --- /dev/null +++ b/migrations/versions/0414_change_total_message_limit.py @@ -0,0 +1,26 @@ +""" + +Revision ID: 0414_change_total_message_limit +Revises: 413_add_message_id +Create Date: 2025-01-23 11:35:22.873930 + +""" + +import sqlalchemy as sa +from alembic import op + +down_revision = "0413_add_message_id" +revision = "0414_change_total_message_limit" + + +def upgrade(): + # TODO This needs updating when the agreement model is ready. We only want free tier at 100k + op.execute( + "UPDATE services set total_message_limit=100000 where total_message_limit=250000" + ) + + +def downgrade(): + op.execute( + "UPDATE services set total_message_limit=250000 where total_message_limit=100000" + ) diff --git a/notifications_utils/clients/redis/redis_client.py b/notifications_utils/clients/redis/redis_client.py index 1723dd2c1..d96f967a2 100644 --- a/notifications_utils/clients/redis/redis_client.py +++ b/notifications_utils/clients/redis/redis_client.py @@ -38,6 +38,9 @@ class RedisClient: active = False scripts = {} + def pipeline(self): + return self.redis_store.pipeline() + def init_app(self, app): self.active = app.config.get("REDIS_ENABLED") if self.active: @@ -156,6 +159,22 @@ class RedisClient: return None + def rpush(self, key, value): + if self.active: + self.redis_store.rpush(key, value) + + def lpop(self, key): + if self.active: + return self.redis_store.lpop(key) + + def llen(self, key): + if self.active: + return self.redis_store.llen(key) + + def ltrim(self, key, start, end): + if self.active: + return self.redis_store.ltrim(key, start, end) + def delete(self, *keys, raise_exception=False): keys = [prepare_value(k) for k in keys] if self.active: diff --git a/notifications_utils/logging.py b/notifications_utils/logging.py index 0a13555d4..a15d00169 100644 --- a/notifications_utils/logging.py +++ b/notifications_utils/logging.py @@ -70,10 +70,12 @@ def init_app(app): for logger_instance, handler in product(loggers, handlers): logger_instance.addHandler(handler) logger_instance.setLevel(loglevel) + logger_instance.propagate = False warning_loggers = [logging.getLogger("boto3"), logging.getLogger("s3transfer")] for logger_instance, handler in product(warning_loggers, handlers): logger_instance.addHandler(handler) logger_instance.setLevel(logging.WARNING) + logger_instance.propagate = False # Suppress specific loggers to prevent leaking sensitive info logging.getLogger("boto3").setLevel(logging.ERROR) diff --git a/notifications_utils/request_helper.py b/notifications_utils/request_helper.py index 48776e69a..d5375065f 100644 --- a/notifications_utils/request_helper.py +++ b/notifications_utils/request_helper.py @@ -76,6 +76,11 @@ class ResponseHeaderMiddleware(object): if SPAN_ID_HEADER.lower() not in lower_existing_header_names: headers.append((SPAN_ID_HEADER, str(req.span_id))) + headers = [ + (key, value) + for key, value in headers + if key.lower() not in ["server", "last-modified"] + ] return start_response(status, headers, exc_info) return self._app(environ, rewrite_response_headers) diff --git a/notifications_utils/s3.py b/notifications_utils/s3.py index 0a01f7493..0cf7c4da7 100644 --- a/notifications_utils/s3.py +++ b/notifications_utils/s3.py @@ -13,14 +13,30 @@ AWS_CLIENT_CONFIG = Config( s3={ "addressing_style": "virtual", }, + max_pool_connections=50, use_fips_endpoint=True, ) +# Global variable +noti_s3_resource = None + default_access_key_id = os.environ.get("AWS_ACCESS_KEY_ID") default_secret_access_key = os.environ.get("AWS_SECRET_ACCESS_KEY") default_region = os.environ.get("AWS_REGION") +def get_s3_resource(): + global noti_s3_resource + if noti_s3_resource is None: + session = Session( + aws_access_key_id=os.environ.get("AWS_ACCESS_KEY_ID"), + aws_secret_access_key=os.environ.get("AWS_SECRET_ACCESS_KEY"), + region_name=os.environ.get("AWS_REGION"), + ) + noti_s3_resource = session.resource("s3", config=AWS_CLIENT_CONFIG) + return noti_s3_resource + + def s3upload( filedata, region, @@ -32,12 +48,7 @@ def s3upload( access_key=default_access_key_id, secret_key=default_secret_access_key, ): - session = Session( - aws_access_key_id=access_key, - aws_secret_access_key=secret_key, - region_name=region, - ) - _s3 = session.resource("s3", config=AWS_CLIENT_CONFIG) + _s3 = get_s3_resource() key = _s3.Object(bucket_name, file_location) @@ -73,12 +84,7 @@ def s3download( secret_key=default_secret_access_key, ): try: - session = Session( - aws_access_key_id=access_key, - aws_secret_access_key=secret_key, - region_name=region, - ) - s3 = session.resource("s3", config=AWS_CLIENT_CONFIG) + s3 = get_s3_resource() key = s3.Object(bucket_name, filename) return key.get()["Body"] except botocore.exceptions.ClientError as error: diff --git a/poetry.lock b/poetry.lock index debdf9ed9..fadc6cde7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -1910,13 +1910,13 @@ trio = ["async_generator", "trio"] [[package]] name = "jinja2" -version = "3.1.4" +version = "3.1.5" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" files = [ - {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, - {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, + {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, + {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, ] [package.dependencies] @@ -2829,14 +2829,18 @@ version = "1.3.0" description = "TLS (SSL) sockets, key generation, encryption, decryption, signing, verification and KDFs using the OS crypto libraries. Does not require a compiler, and relies on the OS for patching. Works on Windows, OS X and Linux/BSD." optional = false python-versions = "*" -files = [ - {file = "oscrypto-1.3.0-py2.py3-none-any.whl", hash = "sha256:2b2f1d2d42ec152ca90ccb5682f3e051fb55986e1b170ebde472b133713e7085"}, - {file = "oscrypto-1.3.0.tar.gz", hash = "sha256:6f5fef59cb5b3708321db7cca56aed8ad7e662853351e7991fcf60ec606d47a4"}, -] +files = [] +develop = false [package.dependencies] asn1crypto = ">=1.5.1" +[package.source] +type = "git" +url = "https://github.com/wbond/oscrypto.git" +reference = "1547f53" +resolved_reference = "1547f535001ba568b239b8797465536759c742a3" + [[package]] name = "packageurl-python" version = "0.16.0" @@ -4947,4 +4951,4 @@ propcache = ">=0.2.0" [metadata] lock-version = "2.0" python-versions = "^3.12.2" -content-hash = "cf18ae74630e47eec18cc6c5fea9e554476809d20589d82c54a8d761bb2c3de0" +content-hash = "81a109693e74d2ffa3be7098e629050f25090c6a08bab57056b9a4a35283ea6f" diff --git a/pyproject.toml b/pyproject.toml index 99858c09e..d29ff84f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ marshmallow = "==3.22.0" marshmallow-sqlalchemy = "==1.0.0" newrelic = "*" notifications-python-client = "==10.0.0" -oscrypto = "==1.3.0" +oscrypto = { git = "https://github.com/wbond/oscrypto.git", rev = "1547f53" } packaging = "==24.1" poetry-dotenv-plugin = "==0.2.0" psycopg2-binary = "==2.9.9" @@ -74,7 +74,7 @@ six = "^1.16.0" urllib3 = "^2.2.2" webencodings = "^0.5.1" itsdangerous = "^2.2.0" -jinja2 = "^3.1.4" +jinja2 = "^3.1.5" redis = "^5.0.8" requests = "^2.32.3" diff --git a/scripts/deploy_to_sandbox.sh b/scripts/deploy_to_sandbox.sh new file mode 100755 index 000000000..683e875b1 --- /dev/null +++ b/scripts/deploy_to_sandbox.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +# Create a requirements.txt file so dependencies are properly managed with the +# deploy. This will overwrite any existing requirements.txt file to make sure +# it is always up-to-date. +poetry export --without-hashes --format=requirements.txt > requirements.txt + +# Target the notify-sandbox space and deploy to cloud.gov with a cf push. +# All environment variables are accounted for in the deploy-config/sandbox.yml +# file, no need to add any of your own or source a .env* file. + +# If this errors out because you need to be logged in, login first with this: +# cf login -a api.fr.cloud.gov --sso +cf target -o gsa-tts-benefits-studio -s notify-sandbox +cf push -f manifest.yml --vars-file deploy-config/sandbox.yml --strategy rolling diff --git a/terraform/demo/main.tf b/terraform/demo/main.tf index ea0f259e4..1a9e091db 100644 --- a/terraform/demo/main.tf +++ b/terraform/demo/main.tf @@ -18,7 +18,7 @@ module "database" { 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" + rds_plan_name = "small-psql" } module "redis-v70" { diff --git a/terraform/production/main.tf b/terraform/production/main.tf index e3bd10a26..9543fdd86 100644 --- a/terraform/production/main.tf +++ b/terraform/production/main.tf @@ -18,7 +18,7 @@ module "database" { 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" + rds_plan_name = "medium-gp-psql-redundant" } module "redis-v70" { diff --git a/terraform/staging/main.tf b/terraform/staging/main.tf index 4fdbf9e38..d59b063ea 100644 --- a/terraform/staging/main.tf +++ b/terraform/staging/main.tf @@ -18,7 +18,7 @@ module "database" { 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" + rds_plan_name = "small-psql" } module "redis-v70" { diff --git a/tests/__init__.py b/tests/__init__.py index eeb1c2ae2..6ea1ba94b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,7 +2,9 @@ import uuid from flask import current_app from notifications_python_client.authentication import create_jwt_token +from sqlalchemy import select +from app import db from app.dao.api_key_dao import save_model_api_key from app.dao.services_dao import dao_fetch_service_by_id from app.enums import KeyType @@ -11,7 +13,15 @@ from app.models import ApiKey def create_service_authorization_header(service_id, key_type=KeyType.NORMAL): client_id = str(service_id) - secrets = ApiKey.query.filter_by(service_id=service_id, key_type=key_type).all() + secrets = ( + db.session.execute( + select(ApiKey).where( + ApiKey.service_id == service_id, ApiKey.key_type == key_type + ) + ) + .scalars() + .all() + ) if secrets: secret = secrets[0].secret diff --git a/tests/app/aws/test_s3.py b/tests/app/aws/test_s3.py index b5fe7348d..de26f5760 100644 --- a/tests/app/aws/test_s3.py +++ b/tests/app/aws/test_s3.py @@ -219,6 +219,20 @@ def test_get_s3_file_makes_correct_call(notify_api, mocker): 2, "5555555552", ), + ( + # simulate file saved with utf8withbom + "\\ufeffPHONE NUMBER\n", + "eee", + 2, + "5555555552", + ), + ( + # simulate file saved without utf8withbom + "\\PHONE NUMBER\n", + "eee", + 2, + "5555555552", + ), ], ) def test_get_phone_number_from_s3( diff --git a/tests/app/celery/test_nightly_tasks.py b/tests/app/celery/test_nightly_tasks.py index 3a0526622..87e18cfac 100644 --- a/tests/app/celery/test_nightly_tasks.py +++ b/tests/app/celery/test_nightly_tasks.py @@ -3,8 +3,10 @@ from unittest.mock import ANY, call import pytest from freezegun import freeze_time +from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError +from app import db from app.celery import nightly_tasks from app.celery.nightly_tasks import ( _delete_notifications_older_than_retention_by_type, @@ -230,7 +232,7 @@ def test_save_daily_notification_processing_time( save_daily_notification_processing_time(date_provided) - persisted_to_db = FactProcessingTime.query.all() + persisted_to_db = db.session.execute(select(FactProcessingTime)).scalars().all() assert len(persisted_to_db) == 1 assert persisted_to_db[0].local_date == date(2021, 1, 17) assert persisted_to_db[0].messages_total == 2 @@ -269,7 +271,7 @@ def test_save_daily_notification_processing_time_when_in_est( save_daily_notification_processing_time(date_provided) - persisted_to_db = FactProcessingTime.query.all() + persisted_to_db = db.session.execute(select(FactProcessingTime)).scalars().all() assert len(persisted_to_db) == 1 assert persisted_to_db[0].local_date == date(2021, 4, 17) assert persisted_to_db[0].messages_total == 2 diff --git a/tests/app/celery/test_process_ses_receipts_tasks.py b/tests/app/celery/test_process_ses_receipts_tasks.py index 226394eeb..77dfc68a4 100644 --- a/tests/app/celery/test_process_ses_receipts_tasks.py +++ b/tests/app/celery/test_process_ses_receipts_tasks.py @@ -2,8 +2,9 @@ import json from unittest.mock import ANY from freezegun import freeze_time +from sqlalchemy import select -from app import encryption +from app import db, encryption from app.celery.process_ses_receipts_tasks import ( process_ses_results, remove_emails_from_bounce, @@ -168,7 +169,7 @@ def test_process_ses_results_in_complaint(sample_email_template, mocker): ) process_ses_results(response=ses_complaint_callback()) assert mocked.call_count == 0 - complaints = Complaint.query.all() + complaints = db.session.execute(select(Complaint)).scalars().all() assert len(complaints) == 1 assert complaints[0].notification_id == notification.id @@ -420,7 +421,7 @@ def test_ses_callback_should_send_on_complaint_to_user_callback_api( assert send_mock.call_count == 1 assert encryption.decrypt(send_mock.call_args[0][0][0]) == { "complaint_date": "2018-06-05T13:59:58.000000Z", - "complaint_id": str(Complaint.query.one().id), + "complaint_id": str(db.session.execute(select(Complaint)).scalars().one().id), "notification_id": str(notification.id), "reference": None, "service_callback_api_bearer_token": "some_super_secret", diff --git a/tests/app/celery/test_provider_tasks.py b/tests/app/celery/test_provider_tasks.py index a22a3fb93..e16e9c54b 100644 --- a/tests/app/celery/test_provider_tasks.py +++ b/tests/app/celery/test_provider_tasks.py @@ -1,4 +1,5 @@ import json +from unittest.mock import ANY import pytest from botocore.exceptions import ClientError @@ -6,11 +7,7 @@ from celery.exceptions import MaxRetriesExceededError import app from app.celery import provider_tasks -from app.celery.provider_tasks import ( - check_sms_delivery_receipt, - deliver_email, - deliver_sms, -) +from app.celery.provider_tasks import deliver_email, deliver_sms from app.clients.email import EmailClientNonRetryableException from app.clients.email.aws_ses import ( AwsSesClientException, @@ -26,110 +23,10 @@ def test_should_have_decorated_tasks_functions(): assert deliver_email.__wrapped__.__name__ == "deliver_email" -def test_should_check_delivery_receipts_success(sample_notification, mocker): - mocker.patch("app.delivery.send_to_providers.send_sms_to_provider") - mocker.patch( - "app.celery.provider_tasks.aws_cloudwatch_client.is_localstack", - return_value=False, - ) - mocker.patch( - "app.celery.provider_tasks.aws_cloudwatch_client.check_sms", - return_value=("success", "okay", "AT&T"), - ) - mock_sanitize = mocker.patch( - "app.celery.provider_tasks.sanitize_successful_notification_by_id" - ) - check_sms_delivery_receipt( - "message_id", sample_notification.id, "2024-10-20 00:00:00+0:00" - ) - # This call should be made if the message was successfully delivered - mock_sanitize.assert_called_once() - - -def test_should_check_delivery_receipts_failure(sample_notification, mocker): - mocker.patch("app.delivery.send_to_providers.send_sms_to_provider") - mocker.patch( - "app.celery.provider_tasks.aws_cloudwatch_client.is_localstack", - return_value=False, - ) - mock_update = mocker.patch( - "app.celery.provider_tasks.update_notification_status_by_id" - ) - mocker.patch( - "app.celery.provider_tasks.aws_cloudwatch_client.check_sms", - return_value=("failure", "not okay", "AT&T"), - ) - mock_sanitize = mocker.patch( - "app.celery.provider_tasks.sanitize_successful_notification_by_id" - ) - check_sms_delivery_receipt( - "message_id", sample_notification.id, "2024-10-20 00:00:00+0:00" - ) - mock_sanitize.assert_not_called() - mock_update.assert_called_once() - - -def test_should_check_delivery_receipts_client_error(sample_notification, mocker): - mocker.patch("app.delivery.send_to_providers.send_sms_to_provider") - mocker.patch( - "app.celery.provider_tasks.aws_cloudwatch_client.is_localstack", - return_value=False, - ) - mock_update = mocker.patch( - "app.celery.provider_tasks.update_notification_status_by_id" - ) - error_response = {"Error": {"Code": "SomeCode", "Message": "Some Message"}} - operation_name = "SomeOperation" - mocker.patch( - "app.celery.provider_tasks.aws_cloudwatch_client.check_sms", - side_effect=ClientError(error_response, operation_name), - ) - mock_sanitize = mocker.patch( - "app.celery.provider_tasks.sanitize_successful_notification_by_id" - ) - try: - check_sms_delivery_receipt( - "message_id", sample_notification.id, "2024-10-20 00:00:00+0:00" - ) - - assert 1 == 0 - except ClientError: - mock_sanitize.assert_not_called() - mock_update.assert_called_once() - - -def test_should_check_delivery_receipts_ntfe(sample_notification, mocker): - mocker.patch("app.delivery.send_to_providers.send_sms_to_provider") - mocker.patch( - "app.celery.provider_tasks.aws_cloudwatch_client.is_localstack", - return_value=False, - ) - mock_update = mocker.patch( - "app.celery.provider_tasks.update_notification_status_by_id" - ) - mocker.patch( - "app.celery.provider_tasks.aws_cloudwatch_client.check_sms", - side_effect=NotificationTechnicalFailureException(), - ) - mock_sanitize = mocker.patch( - "app.celery.provider_tasks.sanitize_successful_notification_by_id" - ) - try: - check_sms_delivery_receipt( - "message_id", sample_notification.id, "2024-10-20 00:00:00+0:00" - ) - - assert 1 == 0 - except NotificationTechnicalFailureException: - mock_sanitize.assert_not_called() - mock_update.assert_called_once() - - def test_should_call_send_sms_to_provider_from_deliver_sms_task( sample_notification, mocker ): mocker.patch("app.delivery.send_to_providers.send_sms_to_provider") - mocker.patch("app.celery.provider_tasks.check_sms_delivery_receipt") deliver_sms(sample_notification.id) app.delivery.send_to_providers.send_sms_to_provider.assert_called_with( @@ -148,7 +45,7 @@ def test_should_add_to_retry_queue_if_notification_not_found_in_deliver_sms_task deliver_sms(notification_id) app.delivery.send_to_providers.send_sms_to_provider.assert_not_called() app.celery.provider_tasks.deliver_sms.retry.assert_called_with( - queue="retry-tasks", countdown=0 + queue="retry-tasks", countdown=0, expires=ANY ) @@ -208,7 +105,7 @@ def test_should_go_into_technical_error_if_exceeds_retries_on_deliver_sms_task( assert str(sample_notification.id) in str(e.value) provider_tasks.deliver_sms.retry.assert_called_with( - queue="retry-tasks", countdown=0 + queue="retry-tasks", countdown=0, expires=ANY ) assert sample_notification.status == NotificationStatus.TEMPORARY_FAILURE @@ -240,7 +137,7 @@ def test_should_add_to_retry_queue_if_notification_not_found_in_deliver_email_ta deliver_email(notification_id) app.delivery.send_to_providers.send_email_to_provider.assert_not_called() app.celery.provider_tasks.deliver_email.retry.assert_called_with( - queue="retry-tasks" + queue="retry-tasks", expires=ANY ) @@ -268,7 +165,9 @@ def test_should_go_into_technical_error_if_exceeds_retries_on_deliver_email_task deliver_email(sample_notification.id) assert str(sample_notification.id) in str(e.value) - provider_tasks.deliver_email.retry.assert_called_with(queue="retry-tasks") + provider_tasks.deliver_email.retry.assert_called_with( + queue="retry-tasks", expires=ANY + ) assert sample_notification.status == NotificationStatus.TECHNICAL_FAILURE diff --git a/tests/app/celery/test_reporting_tasks.py b/tests/app/celery/test_reporting_tasks.py index a32f68fc3..8d13e398c 100644 --- a/tests/app/celery/test_reporting_tasks.py +++ b/tests/app/celery/test_reporting_tasks.py @@ -4,7 +4,9 @@ from uuid import UUID import pytest from freezegun import freeze_time +from sqlalchemy import func, select +from app import db from app.celery.reporting_tasks import ( create_nightly_billing, create_nightly_billing_for_day, @@ -101,7 +103,6 @@ def test_create_nightly_notification_status_triggers_relevant_tasks( mock_celery = mocker.patch( "app.celery.reporting_tasks.create_nightly_notification_status_for_service_and_day" ).apply_async - for notification_type in NotificationType: template = create_template(sample_service, template_type=notification_type) create_notification(template=template, created_at=notification_date) @@ -132,11 +133,11 @@ def test_create_nightly_billing_for_day_checks_history( status=NotificationStatus.DELIVERED, ) - records = FactBilling.query.all() + records = _get_fact_billing_records() assert len(records) == 0 create_nightly_billing_for_day(str(yesterday.date())) - records = FactBilling.query.all() + records = _get_fact_billing_records() assert len(records) == 1 record = records[0] @@ -144,6 +145,11 @@ def test_create_nightly_billing_for_day_checks_history( assert record.notifications_sent == 2 +def _get_fact_billing_records(): + stmt = select(FactBilling) + return db.session.execute(stmt).scalars().all() + + @pytest.mark.parametrize( "second_rate, records_num, billable_units, multiplier", [(1.0, 1, 2, [1]), (2.0, 2, 1, [1, 2])], @@ -181,11 +187,15 @@ def test_create_nightly_billing_for_day_sms_rate_multiplier( billable_units=1, ) - records = FactBilling.query.all() + records = _get_fact_billing_records() assert len(records) == 0 create_nightly_billing_for_day(str(yesterday.date())) - records = FactBilling.query.order_by("rate_multiplier").all() + records = ( + db.session.execute(select(FactBilling).order_by("rate_multiplier")) + .scalars() + .all() + ) assert len(records) == records_num for i, record in enumerate(records): @@ -221,11 +231,15 @@ def test_create_nightly_billing_for_day_different_templates( billable_units=0, ) - records = FactBilling.query.all() + records = _get_fact_billing_records() assert len(records) == 0 create_nightly_billing_for_day(str(yesterday.date())) - records = FactBilling.query.order_by("rate_multiplier").all() + records = ( + db.session.execute(select(FactBilling).order_by("rate_multiplier")) + .scalars() + .all() + ) assert len(records) == 2 multiplier = [0, 1] billable_units = [0, 1] @@ -265,11 +279,15 @@ def test_create_nightly_billing_for_day_same_sent_by( billable_units=1, ) - records = FactBilling.query.all() + records = _get_fact_billing_records() assert len(records) == 0 create_nightly_billing_for_day(str(yesterday.date())) - records = FactBilling.query.order_by("rate_multiplier").all() + records = ( + db.session.execute(select(FactBilling).order_by("rate_multiplier")) + .scalars() + .all() + ) assert len(records) == 1 for _, record in enumerate(records): @@ -296,11 +314,11 @@ def test_create_nightly_billing_for_day_null_sent_by_sms( billable_units=1, ) - records = FactBilling.query.all() + records = _get_fact_billing_records() assert len(records) == 0 create_nightly_billing_for_day(str(yesterday.date())) - records = FactBilling.query.all() + records = _get_fact_billing_records() assert len(records) == 1 record = records[0] @@ -356,12 +374,19 @@ def test_create_nightly_billing_for_day_use_BST( rate_multiplier=1.0, billable_units=4, ) - - assert Notification.query.count() == 3 - assert FactBilling.query.count() == 0 + stmt = select(func.count()).select_from(Notification) + count = db.session.execute(stmt).scalar() or 0 + assert count == 3 + stmt = select(func.count()).select_from(FactBilling) + count = db.session.execute(stmt).scalar() or 0 + assert count == 0 create_nightly_billing_for_day("2018-03-25") - records = FactBilling.query.order_by(FactBilling.local_date).all() + records = ( + db.session.execute(select(FactBilling).order_by(FactBilling.local_date)) + .scalars() + .all() + ) assert len(records) == 1 assert records[0].local_date == date(2018, 3, 25) @@ -384,11 +409,15 @@ def test_create_nightly_billing_for_day_update_when_record_exists( billable_units=1, ) - records = FactBilling.query.all() + records = _get_fact_billing_records() assert len(records) == 0 create_nightly_billing_for_day("2018-01-14") - records = FactBilling.query.order_by(FactBilling.local_date).all() + records = ( + db.session.execute(select(FactBilling).order_by(FactBilling.local_date)) + .scalars() + .all() + ) assert len(records) == 1 assert records[0].local_date == date(2018, 1, 14) @@ -454,7 +483,7 @@ def test_create_nightly_notification_status_for_service_and_day(notify_db_sessio create_notification(template=first_template) create_notification_history(template=second_template) - assert len(FactNotificationStatus.query.all()) == 0 + assert len(db.session.execute(select(FactNotificationStatus)).scalars().all()) == 0 create_nightly_notification_status_for_service_and_day( str(process_day), @@ -467,10 +496,16 @@ def test_create_nightly_notification_status_for_service_and_day(notify_db_sessio NotificationType.EMAIL, ) - new_fact_data = FactNotificationStatus.query.order_by( - FactNotificationStatus.notification_type, - FactNotificationStatus.notification_status, - ).all() + new_fact_data = ( + db.session.execute( + select(FactNotificationStatus).order_by( + FactNotificationStatus.notification_type, + FactNotificationStatus.notification_status, + ) + ) + .scalars() + .all() + ) assert len(new_fact_data) == 4 @@ -530,7 +565,7 @@ def test_create_nightly_notification_status_for_service_and_day_overwrites_old_d NotificationType.SMS, ) - new_fact_data = FactNotificationStatus.query.all() + new_fact_data = db.session.execute(select(FactNotificationStatus)).scalars().all() assert len(new_fact_data) == 1 assert new_fact_data[0].notification_count == 1 @@ -545,9 +580,15 @@ def test_create_nightly_notification_status_for_service_and_day_overwrites_old_d NotificationType.SMS, ) - updated_fact_data = FactNotificationStatus.query.order_by( - FactNotificationStatus.notification_status - ).all() + updated_fact_data = ( + db.session.execute( + select(FactNotificationStatus).order_by( + FactNotificationStatus.notification_status + ) + ) + .scalars() + .all() + ) assert len(updated_fact_data) == 2 assert updated_fact_data[0].notification_count == 1 @@ -590,9 +631,13 @@ def test_create_nightly_notification_status_for_service_and_day_respects_bst( NotificationType.SMS, ) - noti_status = FactNotificationStatus.query.order_by( - FactNotificationStatus.local_date - ).all() + noti_status = ( + db.session.execute( + select(FactNotificationStatus).order_by(FactNotificationStatus.local_date) + ) + .scalars() + .all() + ) assert len(noti_status) == 1 assert noti_status[0].local_date == date(2019, 4, 1) diff --git a/tests/app/celery/test_scheduled_tasks.py b/tests/app/celery/test_scheduled_tasks.py index 90a29f5ed..76395832e 100644 --- a/tests/app/celery/test_scheduled_tasks.py +++ b/tests/app/celery/test_scheduled_tasks.py @@ -1,17 +1,20 @@ +import json from collections import namedtuple from datetime import timedelta from unittest import mock -from unittest.mock import ANY, call +from unittest.mock import ANY, MagicMock, call import pytest from app.celery import scheduled_tasks from app.celery.scheduled_tasks import ( + batch_insert_notifications, check_for_missing_rows_in_completed_jobs, check_for_services_with_high_failure_rates_or_sending_to_tv_numbers, check_job_status, delete_verify_codes, expire_or_delete_invitations, + process_delivery_receipts, replay_created_notifications, run_scheduled_jobs, ) @@ -23,6 +26,8 @@ from notifications_utils.clients.zendesk.zendesk_client import NotifySupportTick from tests.app import load_example_csv from tests.app.db import create_job, create_notification, create_template +CHECK_JOB_STATUS_TOO_OLD_MINUTES = 241 + def test_should_call_delete_codes_on_delete_verify_codes_task( notify_db_session, mocker @@ -108,8 +113,9 @@ def test_check_job_status_task_calls_process_incomplete_jobs(mocker, sample_temp job = create_job( template=sample_template, notification_count=3, - created_at=utc_now() - timedelta(minutes=31), - processing_started=utc_now() - timedelta(minutes=31), + created_at=utc_now() - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), + processing_started=utc_now() + - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), job_status=JobStatus.IN_PROGRESS, ) create_notification(template=sample_template, job=job) @@ -125,9 +131,10 @@ def test_check_job_status_task_calls_process_incomplete_jobs_when_scheduled_job_ job = create_job( template=sample_template, notification_count=3, - created_at=utc_now() - timedelta(hours=2), - scheduled_for=utc_now() - timedelta(minutes=31), - processing_started=utc_now() - timedelta(minutes=31), + created_at=utc_now() - timedelta(hours=5), + scheduled_for=utc_now() - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), + processing_started=utc_now() + - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), job_status=JobStatus.IN_PROGRESS, ) check_job_status() @@ -142,8 +149,8 @@ def test_check_job_status_task_calls_process_incomplete_jobs_for_pending_schedul job = create_job( template=sample_template, notification_count=3, - created_at=utc_now() - timedelta(hours=2), - scheduled_for=utc_now() - timedelta(minutes=31), + created_at=utc_now() - timedelta(hours=5), + scheduled_for=utc_now() - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), job_status=JobStatus.PENDING, ) @@ -175,17 +182,19 @@ def test_check_job_status_task_calls_process_incomplete_jobs_for_multiple_jobs( job = create_job( template=sample_template, notification_count=3, - created_at=utc_now() - timedelta(hours=2), - scheduled_for=utc_now() - timedelta(minutes=31), - processing_started=utc_now() - timedelta(minutes=31), + created_at=utc_now() - timedelta(hours=5), + scheduled_for=utc_now() - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), + processing_started=utc_now() + - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), job_status=JobStatus.IN_PROGRESS, ) job_2 = create_job( template=sample_template, notification_count=3, - created_at=utc_now() - timedelta(hours=2), - scheduled_for=utc_now() - timedelta(minutes=31), - processing_started=utc_now() - timedelta(minutes=31), + created_at=utc_now() - timedelta(hours=5), + scheduled_for=utc_now() - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), + processing_started=utc_now() + - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), job_status=JobStatus.IN_PROGRESS, ) check_job_status() @@ -200,23 +209,24 @@ def test_check_job_status_task_only_sends_old_tasks(mocker, sample_template): job = create_job( template=sample_template, notification_count=3, - created_at=utc_now() - timedelta(hours=2), - scheduled_for=utc_now() - timedelta(minutes=31), - processing_started=utc_now() - timedelta(minutes=31), + created_at=utc_now() - timedelta(hours=5), + scheduled_for=utc_now() - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), + processing_started=utc_now() + - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), job_status=JobStatus.IN_PROGRESS, ) create_job( template=sample_template, notification_count=3, - created_at=utc_now() - timedelta(minutes=31), - processing_started=utc_now() - timedelta(minutes=29), + created_at=utc_now() - timedelta(minutes=300), + processing_started=utc_now() - timedelta(minutes=239), job_status=JobStatus.IN_PROGRESS, ) create_job( template=sample_template, notification_count=3, - created_at=utc_now() - timedelta(minutes=50), - scheduled_for=utc_now() - timedelta(minutes=29), + created_at=utc_now() - timedelta(minutes=300), + scheduled_for=utc_now() - timedelta(minutes=239), job_status=JobStatus.PENDING, ) check_job_status() @@ -230,16 +240,17 @@ def test_check_job_status_task_sets_jobs_to_error(mocker, sample_template): job = create_job( template=sample_template, notification_count=3, - created_at=utc_now() - timedelta(hours=2), - scheduled_for=utc_now() - timedelta(minutes=31), - processing_started=utc_now() - timedelta(minutes=31), + created_at=utc_now() - timedelta(hours=5), + scheduled_for=utc_now() - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), + processing_started=utc_now() + - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), job_status=JobStatus.IN_PROGRESS, ) job_2 = create_job( template=sample_template, notification_count=3, - created_at=utc_now() - timedelta(minutes=31), - processing_started=utc_now() - timedelta(minutes=29), + created_at=utc_now() - timedelta(minutes=300), + processing_started=utc_now() - timedelta(minutes=239), job_status=JobStatus.IN_PROGRESS, ) check_job_status() @@ -300,10 +311,10 @@ def test_replay_created_notifications(notify_db_session, sample_service, mocker) replay_created_notifications() email_delivery_queue.assert_called_once_with( - [str(old_email.id)], queue="send-email-tasks" + [str(old_email.id)], queue="send-email-tasks", countdown=60 ) sms_delivery_queue.assert_called_once_with( - [str(old_sms.id)], queue="send-sms-tasks" + [str(old_sms.id)], queue="send-sms-tasks", countdown=60 ) @@ -311,16 +322,18 @@ def test_check_job_status_task_does_not_raise_error(sample_template): create_job( template=sample_template, notification_count=3, - created_at=utc_now() - timedelta(hours=2), - scheduled_for=utc_now() - timedelta(minutes=31), - processing_started=utc_now() - timedelta(minutes=31), + created_at=utc_now() - timedelta(hours=5), + scheduled_for=utc_now() - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), + processing_started=utc_now() + - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), job_status=JobStatus.FINISHED, ) create_job( template=sample_template, notification_count=3, - created_at=utc_now() - timedelta(minutes=31), - processing_started=utc_now() - timedelta(minutes=31), + created_at=utc_now() - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), + processing_started=utc_now() + - timedelta(minutes=CHECK_JOB_STATUS_TOO_OLD_MINUTES), job_status=JobStatus.FINISHED, ) @@ -415,6 +428,7 @@ def test_check_for_missing_rows_in_completed_jobs_calls_save_email( ), {}, queue="database-tasks", + expires=ANY, ) @@ -512,3 +526,101 @@ def test_check_for_services_with_high_failure_rates_or_sending_to_tv_numbers( technical_ticket=True, ) mock_send_ticket_to_zendesk.assert_called_once() + + +def test_batch_insert_with_valid_notifications(mocker): + mocker.patch("app.celery.scheduled_tasks.dao_batch_insert_notifications") + rs = MagicMock() + mocker.patch("app.celery.scheduled_tasks.redis_store", rs) + notifications = [ + {"id": 1, "notification_status": "pending"}, + {"id": 2, "notification_status": "pending"}, + ] + serialized_notifications = [json.dumps(n).encode("utf-8") for n in notifications] + + pipeline_mock = MagicMock() + + rs.pipeline.return_value.__enter__.return_value = pipeline_mock + rs.llen.return_value = len(notifications) + rs.lpop.side_effect = serialized_notifications + + batch_insert_notifications() + + rs.llen.assert_called_once_with("message_queue") + rs.lpop.assert_called_with("message_queue") + + +def test_batch_insert_with_expired_notifications(mocker): + expired_time = utc_now() - timedelta(minutes=2) + mocker.patch( + "app.celery.scheduled_tasks.dao_batch_insert_notifications", + side_effect=Exception("DB Error"), + ) + rs = MagicMock() + mocker.patch("app.celery.scheduled_tasks.redis_store", rs) + notifications = [ + { + "id": 1, + "notification_status": "pending", + "created_at": utc_now().isoformat(), + }, + { + "id": 2, + "notification_status": "pending", + "created_at": expired_time.isoformat(), + }, + ] + serialized_notifications = [json.dumps(n).encode("utf-8") for n in notifications] + + pipeline_mock = MagicMock() + + rs.pipeline.return_value.__enter__.return_value = pipeline_mock + rs.llen.return_value = len(notifications) + rs.lpop.side_effect = serialized_notifications + + batch_insert_notifications() + + rs.llen.assert_called_once_with("message_queue") + rs.rpush.assert_called_once() + requeued_notification = json.loads(rs.rpush.call_args[0][1]) + assert requeued_notification["id"] == 1 + + +def test_batch_insert_with_malformed_notifications(mocker): + rs = MagicMock() + mocker.patch("app.celery.scheduled_tasks.redis_store", rs) + malformed_data = b"not_a_valid_json" + pipeline_mock = MagicMock() + + rs.pipeline.return_value.__enter__.return_value = pipeline_mock + rs.llen.return_value = 1 + rs.lpop.side_effect = [malformed_data] + + with pytest.raises(json.JSONDecodeError): + batch_insert_notifications() + + rs.llen.assert_called_once_with("message_queue") + rs.rpush.assert_not_called() + + +def test_process_delivery_receipts_success(mocker): + dao_update_mock = mocker.patch( + "app.celery.scheduled_tasks.dao_update_delivery_receipts" + ) + cloudwatch_mock = mocker.patch("app.celery.scheduled_tasks.AwsCloudwatchClient") + cloudwatch_mock.return_value.check_delivery_receipts.return_value = ( + range(2000), + range(500), + ) + current_app_mock = mocker.patch("app.celery.scheduled_tasks.current_app") + current_app_mock.return_value = MagicMock() + processor = MagicMock() + processor.process_delivery_receipts = process_delivery_receipts + processor.retry = MagicMock() + + processor.process_delivery_receipts() + assert dao_update_mock.call_count == 3 + dao_update_mock.assert_any_call(list(range(1000)), True) + dao_update_mock.assert_any_call(list(range(1000, 2000)), True) + dao_update_mock.assert_any_call(list(range(500)), False) + processor.retry.assert_not_called() diff --git a/tests/app/celery/test_tasks.py b/tests/app/celery/test_tasks.py index 5cd0a8c74..fac121047 100644 --- a/tests/app/celery/test_tasks.py +++ b/tests/app/celery/test_tasks.py @@ -1,16 +1,17 @@ import json import uuid from datetime import datetime, timedelta -from unittest.mock import MagicMock, Mock, call +from unittest.mock import ANY, MagicMock, Mock, call import pytest import requests_mock from celery.exceptions import Retry from freezegun import freeze_time from requests import RequestException +from sqlalchemy import func, select from sqlalchemy.exc import SQLAlchemyError -from app import encryption +from app import db, encryption from app.celery import provider_tasks, tasks from app.celery.tasks import ( __total_sending_limits_for_job_exceeded, @@ -115,6 +116,7 @@ def test_should_process_sms_job(sample_job, mocker): (str(sample_job.service_id), "uuid", "something_encrypted"), {}, queue="database-tasks", + expires=ANY, ) job = jobs_dao.dao_get_job_by_id(sample_job.id) assert job.job_status == JobStatus.FINISHED @@ -135,6 +137,7 @@ def test_should_process_sms_job_with_sender_id(sample_job, mocker, fake_uuid): (str(sample_job.service_id), "uuid", "something_encrypted"), {"sender_id": fake_uuid}, queue="database-tasks", + expires=ANY, ) @@ -179,6 +182,7 @@ def test_should_process_job_if_send_limits_are_not_exceeded( ), {}, queue="database-tasks", + expires=ANY, ) @@ -237,6 +241,7 @@ def test_should_process_email_job(email_job_with_placeholders, mocker): ), {}, queue="database-tasks", + expires=ANY, ) job = jobs_dao.dao_get_job_by_id(email_job_with_placeholders.id) assert job.job_status == JobStatus.FINISHED @@ -262,6 +267,7 @@ def test_should_process_email_job_with_sender_id( (str(email_job_with_placeholders.service_id), "uuid", "something_encrypted"), {"sender_id": fake_uuid}, queue="database-tasks", + expires=ANY, ) @@ -351,6 +357,7 @@ def test_process_row_sends_letter_task( ), {}, queue=expected_queue, + expires=ANY, ) @@ -387,6 +394,7 @@ def test_process_row_when_sender_id_is_provided(mocker, fake_uuid): ), {"sender_id": fake_uuid}, queue="database-tasks", + expires=ANY, ) @@ -412,7 +420,7 @@ def test_should_send_template_to_correct_sms_task_and_persist( encryption.encrypt(notification), ) - persisted_notification = Notification.query.one() + persisted_notification = _get_notification_query_one() assert persisted_notification.to == "1" assert persisted_notification.template_id == sample_template_with_placeholders.id assert ( @@ -427,10 +435,15 @@ def test_should_send_template_to_correct_sms_task_and_persist( assert persisted_notification.personalisation == {} assert persisted_notification.notification_type == NotificationType.SMS mocked_deliver_sms.assert_called_once_with( - [str(persisted_notification.id)], queue="send-sms-tasks" + [str(persisted_notification.id)], queue="send-sms-tasks", countdown=60 ) +def _get_notification_query_one(): + stmt = select(Notification) + return db.session.execute(stmt).scalars().one() + + def test_should_save_sms_if_restricted_service_and_valid_number( notify_db_session, mocker ): @@ -451,7 +464,7 @@ def test_should_save_sms_if_restricted_service_and_valid_number( encrypt_notification, ) - persisted_notification = Notification.query.one() + persisted_notification = _get_notification_query_one() assert persisted_notification.to == "1" assert persisted_notification.template_id == template.id assert persisted_notification.template_version == template.version @@ -463,7 +476,7 @@ def test_should_save_sms_if_restricted_service_and_valid_number( assert not persisted_notification.personalisation assert persisted_notification.notification_type == NotificationType.SMS provider_tasks.deliver_sms.apply_async.assert_called_once_with( - [str(persisted_notification.id)], queue="send-sms-tasks" + [str(persisted_notification.id)], queue="send-sms-tasks", countdown=60 ) @@ -490,7 +503,7 @@ def test_save_email_should_save_default_email_reply_to_text_on_notification( encryption.encrypt(notification), ) - persisted_notification = Notification.query.one() + persisted_notification = _get_notification_query_one() assert persisted_notification.reply_to_text == "reply_to@digital.fake.gov" @@ -510,7 +523,7 @@ def test_save_sms_should_save_default_sms_sender_notification_reply_to_text_on( encryption.encrypt(notification), ) - persisted_notification = Notification.query.one() + persisted_notification = _get_notification_query_one() assert persisted_notification.reply_to_text == "12345" @@ -531,7 +544,17 @@ def test_should_not_save_sms_if_restricted_service_and_invalid_number( encryption.encrypt(notification), ) assert provider_tasks.deliver_sms.apply_async.called is False - assert Notification.query.count() == 0 + assert _get_notification_query_count() == 0 + + +def _get_notification_query_all(): + stmt = select(Notification) + return db.session.execute(stmt).scalars().all() + + +def _get_notification_query_count(): + stmt = select(func.count()).select_from(Notification) + return db.session.execute(stmt).scalar() or 0 def test_should_not_save_email_if_restricted_service_and_invalid_email_address( @@ -553,7 +576,7 @@ def test_should_not_save_email_if_restricted_service_and_invalid_email_address( encryption.encrypt(notification), ) - assert Notification.query.count() == 0 + assert _get_notification_query_count() == 0 def test_should_save_sms_template_to_and_persist_with_job_id(sample_job, mocker): @@ -572,7 +595,7 @@ def test_should_save_sms_template_to_and_persist_with_job_id(sample_job, mocker) notification_id, encryption.encrypt(notification), ) - persisted_notification = Notification.query.one() + persisted_notification = _get_notification_query_one() assert persisted_notification.to == "1" assert persisted_notification.job_id == sample_job.id assert persisted_notification.template_id == sample_job.template.id @@ -586,14 +609,14 @@ def test_should_save_sms_template_to_and_persist_with_job_id(sample_job, mocker) assert persisted_notification.notification_type == NotificationType.SMS provider_tasks.deliver_sms.apply_async.assert_called_once_with( - [str(persisted_notification.id)], queue="send-sms-tasks" + [str(persisted_notification.id)], queue="send-sms-tasks", countdown=60 ) def test_should_not_save_sms_if_team_key_and_recipient_not_in_team( notify_db_session, mocker ): - assert Notification.query.count() == 0 + assert _get_notification_query_count() == 0 user = create_user(mobile_number="2028675309") service = create_service(user=user, restricted=True) template = create_template(service=service) @@ -611,7 +634,7 @@ def test_should_not_save_sms_if_team_key_and_recipient_not_in_team( encryption.encrypt(notification), ) assert provider_tasks.deliver_sms.apply_async.called is False - assert Notification.query.count() == 0 + assert _get_notification_query_count() == 0 def test_should_use_email_template_and_persist( @@ -637,7 +660,7 @@ def test_should_use_email_template_and_persist( encryption.encrypt(notification), ) - persisted_notification = Notification.query.one() + persisted_notification = _get_notification_query_one() assert persisted_notification.to == "1" assert ( persisted_notification.template_id == sample_email_template_with_placeholders.id @@ -684,7 +707,7 @@ def test_save_email_should_use_template_version_from_job_not_latest( encryption.encrypt(notification), ) - persisted_notification = Notification.query.one() + persisted_notification = _get_notification_query_one() assert persisted_notification.to == "1" assert persisted_notification.template_id == sample_email_template.id assert persisted_notification.template_version == version_on_notification @@ -713,7 +736,7 @@ def test_should_use_email_template_subject_placeholders( notification_id, encryption.encrypt(notification), ) - persisted_notification = Notification.query.one() + persisted_notification = _get_notification_query_one() assert persisted_notification.to == "1" assert ( persisted_notification.template_id == sample_email_template_with_placeholders.id @@ -754,7 +777,7 @@ def test_save_email_uses_the_reply_to_text_when_provided(sample_email_template, encryption.encrypt(notification), sender_id=other_email_reply_to.id, ) - persisted_notification = Notification.query.one() + persisted_notification = _get_notification_query_one() assert persisted_notification.notification_type == NotificationType.EMAIL assert persisted_notification.reply_to_text == "other@example.com" @@ -779,7 +802,7 @@ def test_save_email_uses_the_default_reply_to_text_if_sender_id_is_none( encryption.encrypt(notification), sender_id=None, ) - persisted_notification = Notification.query.one() + persisted_notification = _get_notification_query_one() assert persisted_notification.notification_type == NotificationType.EMAIL assert persisted_notification.reply_to_text == "default@example.com" @@ -798,7 +821,7 @@ def test_should_use_email_template_and_persist_without_personalisation( notification_id, encryption.encrypt(notification), ) - persisted_notification = Notification.query.one() + persisted_notification = _get_notification_query_one() assert persisted_notification.to == "1" assert persisted_notification.template_id == sample_email_template.id assert persisted_notification.created_at >= now @@ -834,9 +857,11 @@ def test_save_sms_should_go_to_retry_queue_if_database_errors(sample_template, m encryption.encrypt(notification), ) assert provider_tasks.deliver_sms.apply_async.called is False - tasks.save_sms.retry.assert_called_with(exc=expected_exception, queue="retry-tasks") + tasks.save_sms.retry.assert_called_with( + exc=expected_exception, queue="retry-tasks", expires=ANY + ) - assert Notification.query.count() == 0 + assert _get_notification_query_count() == 0 def test_save_email_should_go_to_retry_queue_if_database_errors( @@ -863,10 +888,10 @@ def test_save_email_should_go_to_retry_queue_if_database_errors( ) assert not provider_tasks.deliver_email.apply_async.called tasks.save_email.retry.assert_called_with( - exc=expected_exception, queue="retry-tasks" + exc=expected_exception, queue="retry-tasks", expires=ANY ) - assert Notification.query.count() == 0 + assert _get_notification_query_count() == 0 def test_save_email_does_not_send_duplicate_and_does_not_put_in_retry_queue( @@ -888,7 +913,7 @@ def test_save_email_does_not_send_duplicate_and_does_not_put_in_retry_queue( notification_id, encryption.encrypt(json), ) - assert Notification.query.count() == 1 + assert _get_notification_query_count() == 1 assert not deliver_email.called assert not retry.called @@ -912,7 +937,7 @@ def test_save_sms_does_not_send_duplicate_and_does_not_put_in_retry_queue( notification_id, encryption.encrypt(json), ) - assert Notification.query.count() == 1 + assert _get_notification_query_count() == 1 assert not deliver_sms.called assert not retry.called @@ -924,14 +949,14 @@ def test_save_sms_uses_sms_sender_reply_to_text(mocker, notify_db_session): notification = _notification_json(template, to="2028675301") mocker.patch("app.celery.provider_tasks.deliver_sms.apply_async") - notification_id = uuid.uuid4() + notification_id = str(uuid.uuid4()) save_sms( service.id, notification_id, encryption.encrypt(notification), ) - persisted_notification = Notification.query.one() + persisted_notification = _get_notification_query_one() assert persisted_notification.reply_to_text == "+12028675309" @@ -957,7 +982,7 @@ def test_save_sms_uses_non_default_sms_sender_reply_to_text_if_provided( sender_id=new_sender.id, ) - persisted_notification = Notification.query.one() + persisted_notification = _get_notification_query_one() assert persisted_notification.reply_to_text == "new-sender" @@ -1167,11 +1192,18 @@ def test_process_incomplete_job_sms(mocker, sample_template): create_notification(sample_template, job, 0) create_notification(sample_template, job, 1) - assert Notification.query.filter(Notification.job_id == job.id).count() == 2 + stmt = ( + select(func.count()) + .select_from(Notification) + .where(Notification.job_id == job.id) + ) + count = db.session.execute(stmt).scalar() + assert count == 2 process_incomplete_job(str(job.id)) - completed_job = Job.query.filter(Job.id == job.id).one() + stmt = select(Job).where(Job.id == job.id) + completed_job = db.session.execute(stmt).scalars().one() assert completed_job.job_status == JobStatus.FINISHED @@ -1207,11 +1239,17 @@ def test_process_incomplete_job_with_notifications_all_sent(mocker, sample_templ create_notification(sample_template, job, 8) create_notification(sample_template, job, 9) - assert Notification.query.filter(Notification.job_id == job.id).count() == 10 + stmt = ( + select(func.count()) + .select_from(Notification) + .where(Notification.job_id == job.id) + ) + assert db.session.execute(stmt).scalar() == 10 process_incomplete_job(str(job.id)) - completed_job = Job.query.filter(Job.id == job.id).one() + stmt = select(Job).where(Job.id == job.id) + completed_job = db.session.execute(stmt).scalars().one() assert completed_job.job_status == JobStatus.FINISHED @@ -1239,7 +1277,12 @@ def test_process_incomplete_jobs_sms(mocker, sample_template): create_notification(sample_template, job, 1) create_notification(sample_template, job, 2) - assert Notification.query.filter(Notification.job_id == job.id).count() == 3 + stmt = ( + select(func.count()) + .select_from(Notification) + .where(Notification.job_id == job.id) + ) + assert db.session.execute(stmt).scalar() == 3 job2 = create_job( template=sample_template, @@ -1256,13 +1299,21 @@ def test_process_incomplete_jobs_sms(mocker, sample_template): create_notification(sample_template, job2, 3) create_notification(sample_template, job2, 4) - assert Notification.query.filter(Notification.job_id == job2.id).count() == 5 + stmt = ( + select(func.count()) + .select_from(Notification) + .where(Notification.job_id == job2.id) + ) + + assert db.session.execute(stmt).scalar() == 5 jobs = [job.id, job2.id] process_incomplete_jobs(jobs) - completed_job = Job.query.filter(Job.id == job.id).one() - completed_job2 = Job.query.filter(Job.id == job2.id).one() + stmt = select(Job).where(Job.id == job.id) + completed_job = db.session.execute(stmt).scalars().one() + stmt = select(Job).where(Job.id == job2.id) + completed_job2 = db.session.execute(stmt).scalars().one() assert completed_job.job_status == JobStatus.FINISHED @@ -1288,12 +1339,16 @@ def test_process_incomplete_jobs_no_notifications_added(mocker, sample_template) processing_started=utc_now() - timedelta(minutes=31), job_status=JobStatus.ERROR, ) - - assert Notification.query.filter(Notification.job_id == job.id).count() == 0 + stmt = ( + select(func.count()) + .select_from(Notification) + .where(Notification.job_id == job.id) + ) + assert db.session.execute(stmt).scalar() == 0 process_incomplete_job(job.id) - - completed_job = Job.query.filter(Job.id == job.id).one() + stmt = select(Job).where(Job.id == job.id) + completed_job = db.session.execute(stmt).scalars().one() assert completed_job.job_status == JobStatus.FINISHED @@ -1349,11 +1404,17 @@ def test_process_incomplete_job_email(mocker, sample_email_template): create_notification(sample_email_template, job, 0) create_notification(sample_email_template, job, 1) - assert Notification.query.filter(Notification.job_id == job.id).count() == 2 + stmt = ( + select(func.count()) + .select_from(Notification) + .where(Notification.job_id == job.id) + ) + assert db.session.execute(stmt).scalar() == 2 process_incomplete_job(str(job.id)) - completed_job = Job.query.filter(Job.id == job.id).one() + stmt = select(Job).where(Job.id == job.id) + completed_job = db.session.execute(stmt).scalars().one() assert completed_job.job_status == JobStatus.FINISHED @@ -1435,12 +1496,12 @@ def test_save_api_email_or_sms(mocker, sample_service, notification_type): encrypted = encryption.encrypt(data) - assert len(Notification.query.all()) == 0 + assert len(_get_notification_query_all()) == 0 if notification_type == NotificationType.EMAIL: save_api_email(encrypted_notification=encrypted) else: save_api_sms(encrypted_notification=encrypted) - notifications = Notification.query.all() + notifications = _get_notification_query_all() assert len(notifications) == 1 assert str(notifications[0].id) == data["id"] assert notifications[0].created_at == datetime(2020, 3, 25, 14, 30) @@ -1488,20 +1549,20 @@ def test_save_api_email_dont_retry_if_notification_already_exists( expected_queue = QueueNames.SEND_SMS encrypted = encryption.encrypt(data) - assert len(Notification.query.all()) == 0 + assert len(_get_notification_query_all()) == 0 if notification_type == NotificationType.EMAIL: save_api_email(encrypted_notification=encrypted) else: save_api_sms(encrypted_notification=encrypted) - notifications = Notification.query.all() + notifications = _get_notification_query_all() assert len(notifications) == 1 # call the task again with the same notification if notification_type == NotificationType.EMAIL: save_api_email(encrypted_notification=encrypted) else: save_api_sms(encrypted_notification=encrypted) - notifications = Notification.query.all() + notifications = _get_notification_query_all() assert len(notifications) == 1 assert str(notifications[0].id) == data["id"] assert notifications[0].created_at == datetime(2020, 3, 25, 14, 30) @@ -1565,7 +1626,7 @@ def test_save_tasks_use_cached_service_and_template( ] # But we save 2 notifications and enqueue 2 tasks - assert len(Notification.query.all()) == 2 + assert len(_get_notification_query_all()) == 2 assert len(delivery_mock.call_args_list) == 2 @@ -1626,14 +1687,14 @@ def test_save_api_tasks_use_cache( } ) - assert len(Notification.query.all()) == 0 + assert len(_get_notification_query_all()) == 0 for _ in range(3): task_function(encrypted_notification=create_encrypted_notification()) assert service_dict_mock.call_args_list == [call(str(template.service_id))] - assert len(Notification.query.all()) == 3 + assert len(_get_notification_query_all()) == 3 assert len(mock_provider_task.call_args_list) == 3 diff --git a/tests/app/clients/test_aws_cloudwatch.py b/tests/app/clients/test_aws_cloudwatch.py index b9529037b..7a0379454 100644 --- a/tests/app/clients/test_aws_cloudwatch.py +++ b/tests/app/clients/test_aws_cloudwatch.py @@ -1,8 +1,9 @@ +import json + import pytest from flask import current_app from app import aws_cloudwatch_client -from app.utils import utc_now def test_check_sms_no_event_error_condition(notify_api, mocker): @@ -74,51 +75,6 @@ def test_warn_if_dev_is_opted_out(response, notify_id, expected_message): assert result == expected_message -def test_check_sms_success(notify_api, mocker): - aws_cloudwatch_client.init_app(current_app) - boto_mock = mocker.patch.object(aws_cloudwatch_client, "_client", create=True) - boto_mock.filter_log_events.side_effect = side_effect - mocker.patch.dict( - "os.environ", - {"SES_DOMAIN_ARN": "arn:aws:ses:us-west-2:12345:identity/ses-xxx.xxx.xxx.xxx"}, - ) - - message_id = "succeed" - notification_id = "ccc" - created_at = utc_now() - with notify_api.app_context(): - aws_cloudwatch_client.check_sms(message_id, notification_id, created_at) - - # We check the 'success' log group first and if we find the message_id, we are done, so there is only 1 call - assert boto_mock.filter_log_events.call_count == 1 - mock_call = str(boto_mock.filter_log_events.mock_calls[0]) - assert "Failure" not in mock_call - assert "succeed" in mock_call - assert "notification.messageId" in mock_call - - -def test_check_sms_failure(notify_api, mocker): - aws_cloudwatch_client.init_app(current_app) - boto_mock = mocker.patch.object(aws_cloudwatch_client, "_client", create=True) - boto_mock.filter_log_events.side_effect = side_effect - mocker.patch.dict( - "os.environ", - {"SES_DOMAIN_ARN": "arn:aws:ses:us-west-2:12345:identity/ses-xxx.xxx.xxx.xxx"}, - ) - message_id = "fail" - notification_id = "bbb" - created_at = utc_now() - with notify_api.app_context(): - aws_cloudwatch_client.check_sms(message_id, notification_id, created_at) - - # We check the 'success' log group and find nothing, so we then check the 'fail' log group -- two calls. - assert boto_mock.filter_log_events.call_count == 2 - mock_call = str(boto_mock.filter_log_events.mock_calls[1]) - assert "Failure" in mock_call - assert "fail" in mock_call - assert "notification.messageId" in mock_call - - def test_extract_account_number_gov_cloud(): domain_arn = "arn:aws-us-gov:ses:us-gov-west-1:12345:identity/ses-abc.xxx.xxx.xxx" actual_account_number = aws_cloudwatch_client._extract_account_number(domain_arn) @@ -133,3 +89,65 @@ def test_extract_account_number_gov_staging(): assert len(actual_account_number) == 6 expected_account_number = "12345" assert actual_account_number[4] == expected_account_number + + +def test_check_delivery_receipts(): + pass + + +def test_aws_value_or_default(): + event = { + "delivery": {"phoneCarrier": "AT&T"}, + "notification": {"timestamp": "2024-01-01T:12:00:00Z"}, + } + assert ( + aws_cloudwatch_client._aws_value_or_default(event, "delivery", "phoneCarrier") + == "AT&T" + ) + assert ( + aws_cloudwatch_client._aws_value_or_default( + event, "delivery", "providerResponse" + ) + == "" + ) + assert ( + aws_cloudwatch_client._aws_value_or_default(event, "notification", "timestamp") + == "2024-01-01T:12:00:00Z" + ) + assert ( + aws_cloudwatch_client._aws_value_or_default(event, "nonexistent", "field") == "" + ) + + +def test_event_to_db_format_with_missing_fields(): + event = { + "notification": {"messageId": "12345"}, + "status": "UNKNOWN", + "delivery": {}, + } + result = aws_cloudwatch_client.event_to_db_format(event) + assert result == { + "notification.messageId": "12345", + "status": "UNKNOWN", + "delivery.phoneCarrier": "", + "delivery.providerResponse": "", + "@timestamp": "", + } + + +def test_event_to_db_format_with_string_input(): + event = json.dumps( + { + "notification": {"messageId": "67890", "timestamp": "2024-01-01T14:00:00Z"}, + "status": "FAILED", + "delivery": {"phoneCarrier": "Verizon", "providerResponse": "Error"}, + } + ) + result = aws_cloudwatch_client.event_to_db_format(event) + assert result == { + "notification.messageId": "67890", + "status": "FAILED", + "delivery.phoneCarrier": "Verizon", + "delivery.providerResponse": "Error", + "@timestamp": "2024-01-01T14:00:00Z", + } diff --git a/tests/app/conftest.py b/tests/app/conftest.py index 25e9f3f08..d402aa8cb 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -6,6 +6,7 @@ import pytest import pytz import requests_mock from flask import current_app, url_for +from sqlalchemy import delete, select from sqlalchemy.orm.session import make_transient from app import db @@ -100,9 +101,10 @@ def create_sample_notification( if job is None and api_key is None: # we didn't specify in test - lets create it - api_key = ApiKey.query.filter( + stmt = select(ApiKey).where( ApiKey.service == template.service, ApiKey.key_type == key_type - ).first() + ) + api_key = db.session.execute(stmt).scalars().first() if not api_key: api_key = create_api_key(template.service, key_type=key_type) @@ -222,12 +224,13 @@ def sample_service(sample_user): data = { "name": service_name, "message_limit": 1000, - "total_message_limit": 250000, + "total_message_limit": 100000, "restricted": False, "email_from": email_from, "created_by": sample_user, } - service = Service.query.filter_by(name=service_name).first() + stmt = select(Service).where(Service.name == service_name) + service = db.session.execute(stmt).scalars().first() if not service: service = Service(**data) dao_create_service(service, sample_user, service_permissions=None) @@ -442,9 +445,10 @@ def sample_notification(notify_db_session): service = create_service(check_if_service_exists=True) template = create_template(service=service) - api_key = ApiKey.query.filter( + stmt = select(ApiKey).where( ApiKey.service == template.service, ApiKey.key_type == KeyType.NORMAL - ).first() + ) + api_key = db.session.execute(stmt).scalars().first() if not api_key: api_key = create_api_key(template.service, key_type=KeyType.NORMAL) @@ -595,9 +599,12 @@ def sample_user_service_permission(sample_user): permission = PermissionType.MANAGE_SETTINGS data = {"user": sample_user, "service": service, "permission": permission} - p_model = Permission.query.filter_by( - user=sample_user, service=service, permission=permission - ).first() + stmt = select(Permission).where( + Permission.user == sample_user, + Permission.service == service, + Permission.permission == permission, + ) + p_model = db.session.execute(stmt).scalars().first() if not p_model: p_model = Permission(**data) db.session.add(p_model) @@ -612,12 +619,14 @@ def fake_uuid(): @pytest.fixture(scope="function") def ses_provider(): - return ProviderDetails.query.filter_by(identifier="ses").one() + stmt = select(ProviderDetails).where(ProviderDetails.identifier == "ses") + return db.session.execute(stmt).scalars().one() @pytest.fixture(scope="function") def sns_provider(): - return ProviderDetails.query.filter_by(identifier="sns").one() + stmt = select(ProviderDetails).where(ProviderDetails.identifier == "sns") + return db.session.execute(stmt).scalars().one() @pytest.fixture(scope="function") @@ -796,7 +805,7 @@ def mou_signed_templates(notify_service): def create_custom_template( service, user, template_config_name, template_type, content="", subject=None ): - template = Template.query.get(current_app.config[template_config_name]) + template = db.session.get(Template, current_app.config[template_config_name]) if not template: data = { "id": current_app.config[template_config_name], @@ -817,7 +826,7 @@ def create_custom_template( @pytest.fixture def notify_service(notify_db_session, sample_user): - service = Service.query.get(current_app.config["NOTIFY_SERVICE_ID"]) + service = db.session.get(Service, current_app.config["NOTIFY_SERVICE_ID"]) if not service: service = Service( name="Notify Service", @@ -906,8 +915,12 @@ def restore_provider_details(notify_db_session): Note: This doesn't technically require notify_db_session (only notify_db), but kept as a requirement to encourage good usage - if you're modifying ProviderDetails' state then it's good to clear down the rest of the DB too """ - existing_provider_details = ProviderDetails.query.all() - existing_provider_details_history = ProviderDetailsHistory.query.all() + existing_provider_details = ( + db.session.execute(select(ProviderDetails)).scalars().all() + ) + existing_provider_details_history = ( + db.session.execute(select(ProviderDetailsHistory)).scalars().all() + ) # make transient removes the objects from the session - since we'll want to delete them later for epd in existing_provider_details: make_transient(epd) @@ -917,8 +930,9 @@ def restore_provider_details(notify_db_session): yield # also delete these as they depend on provider_details - ProviderDetails.query.delete() - ProviderDetailsHistory.query.delete() + db.session.execute(delete(ProviderDetails)) + db.session.execute(delete(ProviderDetailsHistory)) + db.session.commit() notify_db_session.commit() notify_db_session.add_all(existing_provider_details) notify_db_session.add_all(existing_provider_details_history) diff --git a/tests/app/dao/notification_dao/test_notification_dao.py b/tests/app/dao/notification_dao/test_notification_dao.py index e2ac10032..1a145538a 100644 --- a/tests/app/dao/notification_dao/test_notification_dao.py +++ b/tests/app/dao/notification_dao/test_notification_dao.py @@ -1,6 +1,7 @@ import uuid from datetime import date, datetime, timedelta from functools import partial +from unittest.mock import ANY, MagicMock, patch import pytest from freezegun import freeze_time @@ -10,6 +11,7 @@ from sqlalchemy.orm.exc import NoResultFound from app import db from app.dao.notifications_dao import ( + dao_close_out_delivery_receipts, dao_create_notification, dao_delete_notifications_by_id, dao_get_last_notification_added_for_job_id, @@ -19,6 +21,7 @@ from app.dao.notifications_dao import ( dao_get_notification_history_by_reference, dao_get_notifications_by_recipient_or_reference, dao_timeout_notifications, + dao_update_delivery_receipts, dao_update_notification, dao_update_notifications_by_reference, get_notification_by_id, @@ -27,6 +30,7 @@ from app.dao.notifications_dao import ( get_notifications_for_service, get_service_ids_with_notifications_on_date, notifications_not_yet_sent, + sanitize_successful_notification_by_id, update_notification_status_by_id, update_notification_status_by_reference, ) @@ -952,6 +956,8 @@ def test_should_return_notifications_including_one_offs_by_default( assert len(include_one_offs_by_default) == 2 +# TODO this test seems a little bogus. Why are we messing with the pagination object +# based on a flag? def test_should_not_count_pages_when_given_a_flag(sample_user, sample_template): create_notification(sample_template) notification = create_notification(sample_template) @@ -960,7 +966,9 @@ def test_should_not_count_pages_when_given_a_flag(sample_user, sample_template): sample_template.service_id, count_pages=False, page_size=1 ) assert len(pagination.items) == 1 - assert pagination.total is None + # In the original test this was set to None, but pagination has completely changed + # in sqlalchemy 2 so updating the test to what it delivers. + assert pagination.total == 2 assert pagination.items[0].id == notification.id @@ -1996,6 +2004,51 @@ def test_notifications_not_yet_sent_return_no_rows(sample_service, notification_ assert len(results) == 0 +def test_update_delivery_receipts(mocker): + mock_session = mocker.patch("app.dao.notifications_dao.db.session") + receipts = [ + '{"notification.messageId": "msg1", "delivery.phoneCarrier": "carrier1", "delivery.providerResponse": "resp1", "@timestamp": "2024-01-01T12:00:00"}', # noqa + '{"notification.messageId": "msg2", "delivery.phoneCarrier": "carrier2", "delivery.providerResponse": "resp2", "@timestamp": "2024-01-01T13:00:00"}', # noqa + ] + delivered = True + mock_update = MagicMock() + mock_where = MagicMock() + mock_values = MagicMock() + mock_update.where.return_value = mock_where + mock_where.values.return_value = mock_values + + mock_session.execute.return_value = None + with patch("app.dao.notifications_dao.update", return_value=mock_update): + dao_update_delivery_receipts(receipts, delivered) + mock_update.where.assert_called_once() + mock_where.values.assert_called_once() + mock_session.execute.assert_called_once_with(mock_values) + mock_session.commit.assert_called_once() + + args, kwargs = mock_where.values.call_args + assert "carrier" in kwargs + assert "status" in kwargs + assert "sent_at" in kwargs + assert "provider_response" in kwargs + + +def test_close_out_delivery_receipts(mocker): + mock_session = mocker.patch("app.dao.notifications_dao.db.session") + mock_update = MagicMock() + mock_where = MagicMock() + mock_values = MagicMock() + mock_update.where.return_value = mock_where + mock_where.values.return_value = mock_values + + mock_session.execute.return_value = None + with patch("app.dao.notifications_dao.update", return_value=mock_update): + dao_close_out_delivery_receipts() + mock_update.where.assert_called_once() + mock_where.values.assert_called_once() + mock_session.execute.assert_called_once_with(mock_values) + mock_session.commit.assert_called_once() + + @pytest.mark.parametrize( "created_at_utc,date_to_check,expected_count", [ @@ -2042,3 +2095,30 @@ def test_get_service_ids_with_notifications_on_date_checks_ft_status( ) == 1 ) + + +def test_sanitize_successful_notification_by_id(): + notification_id = "12345" + carrier = "CarrierX" + provider_response = "Success" + + mock_session = MagicMock() + mock_text = MagicMock() + with patch("app.dao.notifications_dao.db.session", mock_session), patch( + "app.dao.notifications_dao.text", mock_text + ): + sanitize_successful_notification_by_id( + notification_id, carrier, provider_response + ) + mock_text.assert_called_once_with( + "\n update notifications set provider_response=:response, carrier=:carrier,\n notification_status='delivered', sent_at=:sent_at, \"to\"='1', normalised_to='1'\n where id=:notification_id\n " # noqa + ) + mock_session.execute.assert_called_once_with( + mock_text.return_value, + { + "notification_id": notification_id, + "carrier": carrier, + "response": provider_response, + "sent_at": ANY, + }, + ) diff --git a/tests/app/dao/notification_dao/test_notification_dao_delete_notifications.py b/tests/app/dao/notification_dao/test_notification_dao_delete_notifications.py index e22721216..144a2e636 100644 --- a/tests/app/dao/notification_dao/test_notification_dao_delete_notifications.py +++ b/tests/app/dao/notification_dao/test_notification_dao_delete_notifications.py @@ -2,7 +2,9 @@ import uuid from datetime import datetime, timedelta from freezegun import freeze_time +from sqlalchemy import func, select +from app import db from app.dao.notifications_dao import ( insert_notification_history_delete_notifications, move_notifications_to_notification_history, @@ -40,12 +42,27 @@ def test_move_notifications_does_nothing_if_notification_history_row_already_exi 1, ) - assert Notification.query.count() == 0 - history = NotificationHistory.query.all() + assert _get_notification_count() == 0 + history = _get_notification_history_query_all() assert len(history) == 1 assert history[0].status == NotificationStatus.DELIVERED +def _get_notification_query_all(): + stmt = select(Notification) + return db.session.execute(stmt).scalars().all() + + +def _get_notification_history_query_all(): + stmt = select(NotificationHistory) + return db.session.execute(stmt).scalars().all() + + +def _get_notification_count(): + stmt = select(func.count()).select_from(Notification) + return db.session.execute(stmt).scalar() or 0 + + def test_move_notifications_only_moves_notifications_older_than_provided_timestamp( sample_template, ): @@ -69,8 +86,18 @@ def test_move_notifications_only_moves_notifications_older_than_provided_timesta ) assert result == 1 - assert Notification.query.one().id == new_notification.id - assert NotificationHistory.query.one().id == old_notification_id + assert _get_notification_query_one().id == new_notification.id + assert _get_notification_history_query_one().id == old_notification_id + + +def _get_notification_query_one(): + stmt = select(Notification) + return db.session.execute(stmt).scalars().one() + + +def _get_notification_history_query_one(): + stmt = select(NotificationHistory) + return db.session.execute(stmt).scalars().one() def test_move_notifications_keeps_calling_until_no_more_to_delete_and_then_returns_total_deleted( @@ -116,7 +143,9 @@ def test_move_notifications_only_moves_for_given_notification_type(sample_servic ) assert result == 1 assert {x.notification_type for x in Notification.query} == {NotificationType.EMAIL} - assert NotificationHistory.query.one().notification_type == NotificationType.SMS + assert ( + _get_notification_history_query_one().notification_type == NotificationType.SMS + ) def test_move_notifications_only_moves_for_given_service(notify_db_session): @@ -139,8 +168,8 @@ def test_move_notifications_only_moves_for_given_service(notify_db_session): ) assert result == 1 - assert NotificationHistory.query.one().service_id == service.id - assert Notification.query.one().service_id == other_service.id + assert _get_notification_history_query_one().service_id == service.id + assert _get_notification_query_one().service_id == other_service.id def test_move_notifications_just_deletes_test_key_notifications(sample_template): @@ -170,14 +199,17 @@ def test_move_notifications_just_deletes_test_key_notifications(sample_template) assert result == 2 - assert Notification.query.count() == 0 - assert NotificationHistory.query.count() == 2 - assert ( - NotificationHistory.query.filter( - NotificationHistory.key_type == KeyType.TEST - ).count() - == 0 + assert _get_notification_count() == 0 + stmt = select(func.count()).select_from(NotificationHistory) + count = db.session.execute(stmt).scalar() or 0 + assert count == 2 + stmt = ( + select(func.count()) + .select_from(NotificationHistory) + .where(NotificationHistory.key_type == KeyType.TEST) ) + count = db.session.execute(stmt).scalar() or 0 + assert count == 0 @freeze_time("2020-03-20 14:00") @@ -248,8 +280,8 @@ def test_insert_notification_history_delete_notifications(sample_email_template) timestamp_to_delete_backwards_from=utc_now() - timedelta(days=1), ) assert del_count == 8 - notifications = Notification.query.all() - history_rows = NotificationHistory.query.all() + notifications = _get_notification_query_all() + history_rows = _get_notification_history_query_all() assert len(history_rows) == 8 assert ids_to_move == sorted([x.id for x in history_rows]) assert len(notifications) == 3 @@ -283,8 +315,8 @@ def test_insert_notification_history_delete_notifications_more_notifications_tha ) assert del_count == 1 - notifications = Notification.query.all() - history_rows = NotificationHistory.query.all() + notifications = _get_notification_query_all() + history_rows = _get_notification_history_query_all() assert len(history_rows) == 1 assert len(notifications) == 2 @@ -314,8 +346,8 @@ def test_insert_notification_history_delete_notifications_only_insert_delete_for ) assert del_count == 1 - notifications = Notification.query.all() - history_rows = NotificationHistory.query.all() + notifications = _get_notification_query_all() + history_rows = _get_notification_history_query_all() assert len(notifications) == 1 assert len(history_rows) == 1 assert notifications[0].id == notification_to_stay.id @@ -351,8 +383,8 @@ def test_insert_notification_history_delete_notifications_insert_for_key_type( ) assert del_count == 2 - notifications = Notification.query.all() - history_rows = NotificationHistory.query.all() + notifications = _get_notification_query_all() + history_rows = _get_notification_history_query_all() assert len(notifications) == 1 assert with_test_key.id == notifications[0].id assert len(history_rows) == 2 diff --git a/tests/app/dao/test_annual_billing_dao.py b/tests/app/dao/test_annual_billing_dao.py index e23fc7ddb..72a7d3a3a 100644 --- a/tests/app/dao/test_annual_billing_dao.py +++ b/tests/app/dao/test_annual_billing_dao.py @@ -1,6 +1,8 @@ import pytest from freezegun import freeze_time +from sqlalchemy import select +from app import db from app.dao.annual_billing_dao import ( dao_create_or_update_annual_billing_for_year, dao_get_all_free_sms_fragment_limit, @@ -89,7 +91,7 @@ def test_set_default_free_allowance_for_service( set_default_free_allowance_for_service(service=service, year_start=year) - annual_billing = AnnualBilling.query.all() + annual_billing = db.session.execute(select(AnnualBilling)).scalars().all() assert len(annual_billing) == 1 assert annual_billing[0].service_id == service.id @@ -111,7 +113,7 @@ def test_set_default_free_allowance_for_service_using_correct_year( @freeze_time("2021-04-01 14:02:00") def test_set_default_free_allowance_for_service_updates_existing_year(sample_service): set_default_free_allowance_for_service(service=sample_service, year_start=None) - annual_billing = AnnualBilling.query.all() + annual_billing = db.session.execute(select(AnnualBilling)).scalars().all() assert not sample_service.organization_type assert len(annual_billing) == 1 assert annual_billing[0].service_id == sample_service.id @@ -120,7 +122,7 @@ def test_set_default_free_allowance_for_service_updates_existing_year(sample_ser sample_service.organization_type = OrganizationType.FEDERAL set_default_free_allowance_for_service(service=sample_service, year_start=None) - annual_billing = AnnualBilling.query.all() + annual_billing = db.session.execute(select(AnnualBilling)).scalars().all() assert len(annual_billing) == 1 assert annual_billing[0].service_id == sample_service.id assert annual_billing[0].free_sms_fragment_limit == 150000 diff --git a/tests/app/dao/test_api_key_dao.py b/tests/app/dao/test_api_key_dao.py index f63391143..448d56081 100644 --- a/tests/app/dao/test_api_key_dao.py +++ b/tests/app/dao/test_api_key_dao.py @@ -1,9 +1,11 @@ from datetime import timedelta import pytest +from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm.exc import NoResultFound +from app import db from app.dao.api_key_dao import ( expire_api_key, get_model_api_keys, @@ -32,7 +34,9 @@ def test_save_api_key_should_create_new_api_key_and_history(sample_service): assert all_api_keys[0] == api_key assert api_key.version == 1 - all_history = api_key.get_history_model().query.all() + all_history = ( + db.session.execute(select(api_key.get_history_model())).scalars().all() + ) assert len(all_history) == 1 assert all_history[0].id == api_key.id assert all_history[0].version == api_key.version @@ -49,7 +53,9 @@ def test_expire_api_key_should_update_the_api_key_and_create_history_record( assert all_api_keys[0].id == sample_api_key.id assert all_api_keys[0].service_id == sample_api_key.service_id - all_history = sample_api_key.get_history_model().query.all() + all_history = ( + db.session.execute(select(sample_api_key.get_history_model())).scalars().all() + ) assert len(all_history) == 2 assert all_history[0].id == sample_api_key.id assert all_history[1].id == sample_api_key.id @@ -121,15 +127,20 @@ def test_save_api_key_can_create_key_with_same_name_if_other_is_expired(sample_s } ) save_model_api_key(api_key) - keys = ApiKey.query.all() + keys = db.session.execute(select(ApiKey)).scalars().all() assert len(keys) == 2 def test_save_api_key_should_not_create_new_service_history(sample_service): from app.models import Service - assert Service.query.count() == 1 - assert Service.get_history_model().query.count() == 1 + stmt = select(func.count()).select_from(Service) + count = db.session.execute(stmt).scalar() or 0 + assert count == 1 + + stmt = select(func.count()).select_from(Service.get_history_model()) + count = db.session.execute(stmt).scalar() or 0 + assert count == 1 api_key = ApiKey( **{ @@ -141,7 +152,9 @@ def test_save_api_key_should_not_create_new_service_history(sample_service): ) save_model_api_key(api_key) - assert Service.get_history_model().query.count() == 1 + stmt = select(func.count()).select_from(Service.get_history_model()) + count = db.session.execute(stmt).scalar() or 0 + assert count == 1 @pytest.mark.parametrize("days_old, expected_length", [(5, 1), (8, 0)]) diff --git a/tests/app/dao/test_email_branding_dao.py b/tests/app/dao/test_email_branding_dao.py index 9e428b345..db2a71077 100644 --- a/tests/app/dao/test_email_branding_dao.py +++ b/tests/app/dao/test_email_branding_dao.py @@ -1,3 +1,6 @@ +from sqlalchemy import select + +from app import db from app.dao.email_branding_dao import ( dao_get_email_branding_by_id, dao_get_email_branding_by_name, @@ -27,14 +30,14 @@ def test_update_email_branding(notify_db_session): updated_name = "new name" create_email_branding() - email_branding = EmailBranding.query.all() + email_branding = db.session.execute(select(EmailBranding)).scalars().all() assert len(email_branding) == 1 assert email_branding[0].name != updated_name dao_update_email_branding(email_branding[0], name=updated_name) - email_branding = EmailBranding.query.all() + email_branding = db.session.execute(select(EmailBranding)).scalars().all() assert len(email_branding) == 1 assert email_branding[0].name == updated_name @@ -42,5 +45,5 @@ def test_update_email_branding(notify_db_session): def test_email_branding_has_no_domain(notify_db_session): create_email_branding() - email_branding = EmailBranding.query.all() + email_branding = db.session.execute(select(EmailBranding)).scalars().all() assert not hasattr(email_branding, "domain") diff --git a/tests/app/dao/test_events_dao.py b/tests/app/dao/test_events_dao.py index 2647aafcb..963a43aef 100644 --- a/tests/app/dao/test_events_dao.py +++ b/tests/app/dao/test_events_dao.py @@ -1,9 +1,14 @@ +from sqlalchemy import func, select + +from app import db from app.dao.events_dao import dao_create_event from app.models import Event def test_create_event(notify_db_session): - assert Event.query.count() == 0 + stmt = select(func.count()).select_from(Event) + count = db.session.execute(stmt).scalar() or 0 + assert count == 0 data = { "event_type": "sucessful_login", "data": {"something": "random", "in_fact": "could be anything"}, @@ -12,6 +17,8 @@ def test_create_event(notify_db_session): event = Event(**data) dao_create_event(event) - assert Event.query.count() == 1 - event_from_db = Event.query.first() + stmt = select(func.count()).select_from(Event) + count = db.session.execute(stmt).scalar() or 0 + assert count == 1 + event_from_db = db.session.execute(select(Event)).scalars().first() assert event == event_from_db diff --git a/tests/app/dao/test_fact_notification_status_dao.py b/tests/app/dao/test_fact_notification_status_dao.py index fd97496e3..5b9a7d695 100644 --- a/tests/app/dao/test_fact_notification_status_dao.py +++ b/tests/app/dao/test_fact_notification_status_dao.py @@ -1130,7 +1130,10 @@ def test_update_fact_notification_status_respects_gmt_bst( stmt = ( select(func.count()) .select_from(FactNotificationStatus) - .filter_by(service_id=sample_service.id, local_date=process_day) + .where( + FactNotificationStatus.service_id == sample_service.id, + FactNotificationStatus.local_date == process_day, + ) ) result = db.session.execute(stmt) assert result.rowcount == expected_count diff --git a/tests/app/dao/test_fact_processing_time_dao.py b/tests/app/dao/test_fact_processing_time_dao.py index 1409abe2c..52178da95 100644 --- a/tests/app/dao/test_fact_processing_time_dao.py +++ b/tests/app/dao/test_fact_processing_time_dao.py @@ -1,7 +1,9 @@ from datetime import datetime from freezegun import freeze_time +from sqlalchemy import select +from app import db from app.dao import fact_processing_time_dao from app.dao.fact_processing_time_dao import ( get_processing_time_percentage_for_date_range, @@ -19,7 +21,7 @@ def test_insert_update_processing_time(notify_db_session): fact_processing_time_dao.insert_update_processing_time(data) - result = FactProcessingTime.query.all() + result = db.session.execute(select(FactProcessingTime)).scalars().all() assert len(result) == 1 assert result[0].local_date == datetime(2021, 2, 22).date() @@ -36,7 +38,7 @@ def test_insert_update_processing_time(notify_db_session): with freeze_time("2021-02-23 13:23:33"): fact_processing_time_dao.insert_update_processing_time(data) - result = FactProcessingTime.query.all() + result = db.session.execute(select(FactProcessingTime)).scalars().all() assert len(result) == 1 assert result[0].local_date == datetime(2021, 2, 22).date() @@ -77,7 +79,6 @@ def test_get_processing_time_percentage_for_date_range_handles_zero_cases( ) results = get_processing_time_percentage_for_date_range("2021-02-21", "2021-02-22") - assert len(results) == 2 assert results[0].date == "2021-02-21" assert results[0].messages_total == 0 diff --git a/tests/app/dao/test_inbound_numbers_dao.py b/tests/app/dao/test_inbound_numbers_dao.py index efb1e376c..e7a8c93be 100644 --- a/tests/app/dao/test_inbound_numbers_dao.py +++ b/tests/app/dao/test_inbound_numbers_dao.py @@ -37,7 +37,7 @@ def test_set_service_id_on_inbound_number(notify_db_session, sample_inbound_numb dao_set_inbound_number_to_service(service.id, numbers[0]) - stmt = select(InboundNumber).filter(InboundNumber.service_id == service.id) + stmt = select(InboundNumber).where(InboundNumber.service_id == service.id) res = db.session.execute(stmt).scalars().all() assert len(res) == 1 diff --git a/tests/app/dao/test_inbound_sms_dao.py b/tests/app/dao/test_inbound_sms_dao.py index 39cdb2f53..1c9b039fa 100644 --- a/tests/app/dao/test_inbound_sms_dao.py +++ b/tests/app/dao/test_inbound_sms_dao.py @@ -254,7 +254,7 @@ def test_dao_get_paginated_inbound_sms_for_service_for_public_api(sample_service inbound_sms.service.id ) - assert inbound_sms == inbound_from_db[0] + assert inbound_sms == inbound_from_db.items[0] def test_dao_get_paginated_inbound_sms_for_service_for_public_api_return_only_for_service( @@ -268,8 +268,8 @@ def test_dao_get_paginated_inbound_sms_for_service_for_public_api_return_only_fo inbound_sms.service.id ) - assert inbound_sms in inbound_from_db - assert another_inbound_sms not in inbound_from_db + assert inbound_sms in inbound_from_db.items + assert another_inbound_sms not in inbound_from_db.items def test_dao_get_paginated_inbound_sms_for_service_for_public_api_no_inbound_sms_returns_empty_list( @@ -279,7 +279,7 @@ def test_dao_get_paginated_inbound_sms_for_service_for_public_api_no_inbound_sms sample_service.id ) - assert inbound_from_db == [] + assert inbound_from_db.has_next() is False def test_dao_get_paginated_inbound_sms_for_service_for_public_api_page_size_returns_correct_size( @@ -299,7 +299,7 @@ def test_dao_get_paginated_inbound_sms_for_service_for_public_api_page_size_retu sample_service.id, older_than=reversed_inbound_sms[1].id, page_size=2 ) - assert len(inbound_from_db) == 2 + assert inbound_from_db.total == 2 def test_dao_get_paginated_inbound_sms_for_service_for_public_api_older_than_returns_correct_list( @@ -320,8 +320,7 @@ def test_dao_get_paginated_inbound_sms_for_service_for_public_api_older_than_ret ) expected_inbound_sms = reversed_inbound_sms[2:] - - assert expected_inbound_sms == inbound_from_db + assert expected_inbound_sms == inbound_from_db.items def test_dao_get_paginated_inbound_sms_for_service_for_public_api_older_than_end_returns_empty_list( @@ -338,8 +337,7 @@ def test_dao_get_paginated_inbound_sms_for_service_for_public_api_older_than_end inbound_from_db = dao_get_paginated_inbound_sms_for_service_for_public_api( sample_service.id, older_than=reversed_inbound_sms[1].id, page_size=2 ) - - assert inbound_from_db == [] + assert inbound_from_db.items == [] def test_most_recent_inbound_sms_only_returns_most_recent_for_each_number( diff --git a/tests/app/dao/test_invited_user_dao.py b/tests/app/dao/test_invited_user_dao.py index da52e52e7..656dec568 100644 --- a/tests/app/dao/test_invited_user_dao.py +++ b/tests/app/dao/test_invited_user_dao.py @@ -2,6 +2,7 @@ import uuid from datetime import timedelta import pytest +from sqlalchemy import func, select from sqlalchemy.orm.exc import NoResultFound from app import db @@ -18,8 +19,13 @@ from app.utils import utc_now from tests.app.db import create_invited_user +def _get_invited_user_count(): + stmt = select(func.count()).select_from(InvitedUser) + return db.session.execute(stmt).scalar() or 0 + + def test_create_invited_user(notify_db_session, sample_service): - assert InvitedUser.query.count() == 0 + assert _get_invited_user_count() == 0 email_address = "invited_user@service.gov.uk" invite_from = sample_service.users[0] @@ -34,7 +40,7 @@ def test_create_invited_user(notify_db_session, sample_service): invited_user = InvitedUser(**data) save_invited_user(invited_user) - assert InvitedUser.query.count() == 1 + assert _get_invited_user_count() == 1 assert invited_user.email_address == email_address assert invited_user.from_user == invite_from permissions = invited_user.get_permissions() @@ -47,7 +53,7 @@ def test_create_invited_user(notify_db_session, sample_service): def test_create_invited_user_sets_default_folder_permissions_of_empty_list( sample_service, ): - assert InvitedUser.query.count() == 0 + assert _get_invited_user_count() == 0 invite_from = sample_service.users[0] data = { @@ -60,7 +66,7 @@ def test_create_invited_user_sets_default_folder_permissions_of_empty_list( invited_user = InvitedUser(**data) save_invited_user(invited_user) - assert InvitedUser.query.count() == 1 + assert _get_invited_user_count() == 1 assert invited_user.folder_permissions == [] @@ -108,13 +114,13 @@ def test_get_invited_users_for_service_that_has_no_invites( def test_save_invited_user_sets_status_to_cancelled( notify_db_session, sample_invited_user ): - assert InvitedUser.query.count() == 1 - saved = InvitedUser.query.get(sample_invited_user.id) + assert _get_invited_user_count() == 1 + saved = db.session.get(InvitedUser, sample_invited_user.id) assert saved.status == InvitedUserStatus.PENDING saved.status = InvitedUserStatus.CANCELLED save_invited_user(saved) - assert InvitedUser.query.count() == 1 - cancelled_invited_user = InvitedUser.query.get(sample_invited_user.id) + assert _get_invited_user_count() == 1 + cancelled_invited_user = db.session.get(InvitedUser, sample_invited_user.id) assert cancelled_invited_user.status == InvitedUserStatus.CANCELLED @@ -123,23 +129,17 @@ def test_should_delete_all_invitations_more_than_one_day_old( ): make_invitation(sample_user, sample_service, age=timedelta(hours=48)) make_invitation(sample_user, sample_service, age=timedelta(hours=48)) - assert ( - len( - InvitedUser.query.filter( - InvitedUser.status != InvitedUserStatus.EXPIRED - ).all() - ) - == 2 - ) + stmt = select(InvitedUser).where(InvitedUser.status != InvitedUserStatus.EXPIRED) + result = db.session.execute(stmt).scalars().all() + assert len(result) == 2 expire_invitations_created_more_than_two_days_ago() - assert ( - len( - InvitedUser.query.filter( - InvitedUser.status != InvitedUserStatus.EXPIRED - ).all() - ) - == 0 + stmt = ( + select(func.count()) + .select_from(InvitedUser) + .where(InvitedUser.status != InvitedUserStatus.EXPIRED) ) + count = db.session.execute(stmt).scalar() or 0 + assert count == 0 def test_should_not_delete_invitations_less_than_two_days_old( @@ -160,35 +160,28 @@ def test_should_not_delete_invitations_less_than_two_days_old( email_address="expired@1.com", ) - assert ( - len( - InvitedUser.query.filter( - InvitedUser.status != InvitedUserStatus.EXPIRED - ).all() - ) - == 2 + stmt = ( + select(func.count()) + .select_from(InvitedUser) + .where(InvitedUser.status != InvitedUserStatus.EXPIRED) ) + count = db.session.execute(stmt).scalar() or 0 + assert count == 2 expire_invitations_created_more_than_two_days_ago() - assert ( - len( - InvitedUser.query.filter( - InvitedUser.status != InvitedUserStatus.EXPIRED - ).all() - ) - == 1 - ) - assert ( - InvitedUser.query.filter(InvitedUser.status != InvitedUserStatus.EXPIRED) - .first() - .email_address - == "valid@2.com" - ) - assert ( - InvitedUser.query.filter(InvitedUser.status == InvitedUserStatus.EXPIRED) - .first() - .email_address - == "expired@1.com" + stmt = ( + select(func.count()) + .select_from(InvitedUser) + .where(InvitedUser.status != InvitedUserStatus.EXPIRED) ) + count = db.session.execute(stmt).scalar() or 0 + assert count == 1 + stmt = select(InvitedUser).where(InvitedUser.status != InvitedUserStatus.EXPIRED) + invited_user = db.session.execute(stmt).scalars().first() + assert invited_user.email_address == "valid@2.com" + stmt = select(InvitedUser).where(InvitedUser.status == InvitedUserStatus.EXPIRED) + invited_user = db.session.execute(stmt).scalars().first() + + assert invited_user.email_address == "expired@1.com" def make_invitation(user, service, age=None, email_address="test@test.com"): diff --git a/tests/app/dao/test_organization_dao.py b/tests/app/dao/test_organization_dao.py index fb2e01d85..773c14bd6 100644 --- a/tests/app/dao/test_organization_dao.py +++ b/tests/app/dao/test_organization_dao.py @@ -180,8 +180,9 @@ def test_update_organization_updates_the_service_org_type_if_org_type_is_provide assert sample_organization.organization_type == OrganizationType.FEDERAL assert sample_service.organization_type == OrganizationType.FEDERAL - stmt = select(Service.get_history_model()).filter_by( - id=sample_service.id, version=2 + stmt = select(Service.get_history_model()).where( + Service.get_history_model().id == sample_service.id, + Service.get_history_model().version == 2, ) assert ( db.session.execute(stmt).scalars().one().organization_type @@ -234,8 +235,9 @@ def test_add_service_to_organization(sample_service, sample_organization): assert sample_organization.services[0].id == sample_service.id assert sample_service.organization_type == sample_organization.organization_type - stmt = select(Service.get_history_model()).filter_by( - id=sample_service.id, version=2 + stmt = select(Service.get_history_model()).where( + Service.get_history_model().id == sample_service.id, + Service.get_history_model().version == 2, ) assert ( db.session.execute(stmt).scalars().one().organization_type diff --git a/tests/app/dao/test_provider_details_dao.py b/tests/app/dao/test_provider_details_dao.py index fd8f4a43d..84c4b2238 100644 --- a/tests/app/dao/test_provider_details_dao.py +++ b/tests/app/dao/test_provider_details_dao.py @@ -2,8 +2,9 @@ from datetime import datetime, timedelta import pytest from freezegun import freeze_time +from sqlalchemy import delete, select, update -from app import notification_provider_clients +from app import db, notification_provider_clients from app.dao.provider_details_dao import ( _get_sms_providers_for_update, dao_get_provider_stats, @@ -65,17 +66,19 @@ def test_can_get_email_providers(notify_db_session): def test_should_not_error_if_any_provider_in_code_not_in_database( restore_provider_details, ): - ProviderDetails.query.filter_by(identifier="sns").delete() + stmt = delete(ProviderDetails).where(ProviderDetails.identifier == "sns") + db.session.execute(stmt) + db.session.commit() assert notification_provider_clients.get_sms_client("sns") @freeze_time("2000-01-01T00:00:00") def test_update_adds_history(restore_provider_details): - ses = ProviderDetails.query.filter(ProviderDetails.identifier == "ses").one() - ses_history = ProviderDetailsHistory.query.filter( - ProviderDetailsHistory.id == ses.id - ).one() + stmt = select(ProviderDetails).where(ProviderDetails.identifier == "ses") + ses = db.session.execute(stmt).scalars().one() + stmt = select(ProviderDetailsHistory).where(ProviderDetailsHistory.id == ses.id) + ses_history = db.session.execute(stmt).scalars().one() assert ses.version == 1 assert ses_history.version == 1 @@ -88,11 +91,12 @@ def test_update_adds_history(restore_provider_details): assert not ses.active assert ses.updated_at == datetime(2000, 1, 1, 0, 0, 0) - ses_history = ( - ProviderDetailsHistory.query.filter(ProviderDetailsHistory.id == ses.id) + stmt = ( + select(ProviderDetailsHistory) + .where(ProviderDetailsHistory.id == ses.id) .order_by(ProviderDetailsHistory.version) - .all() ) + ses_history = db.session.execute(stmt).scalars().all() assert ses_history[0].active assert ses_history[0].version == 1 @@ -130,9 +134,13 @@ def test_get_alternative_sms_provider_fails_if_unrecognised(): @freeze_time("2016-01-01 01:00") def test_get_sms_providers_for_update_returns_providers(restore_provider_details): - ProviderDetails.query.filter(ProviderDetails.identifier == "sns").update( - {"updated_at": None} + stmt = ( + update(ProviderDetails) + .where(ProviderDetails.identifier == "sns") + .values({"updated_at": None}) ) + db.session.execute(stmt) + db.session.commit() resp = _get_sms_providers_for_update(timedelta(hours=1)) @@ -144,9 +152,13 @@ def test_get_sms_providers_for_update_returns_nothing_if_recent_updates( restore_provider_details, ): fifty_nine_minutes_ago = datetime(2016, 1, 1, 0, 1) - ProviderDetails.query.filter(ProviderDetails.identifier == "sns").update( - {"updated_at": fifty_nine_minutes_ago} + stmt = ( + update(ProviderDetails) + .where(ProviderDetails.identifier == "sns") + .values({"updated_at": fifty_nine_minutes_ago}) ) + db.session.execute(stmt) + db.session.commit() resp = _get_sms_providers_for_update(timedelta(hours=1)) diff --git a/tests/app/dao/test_service_callback_api_dao.py b/tests/app/dao/test_service_callback_api_dao.py index ac7fe2b46..30b1567bd 100644 --- a/tests/app/dao/test_service_callback_api_dao.py +++ b/tests/app/dao/test_service_callback_api_dao.py @@ -1,9 +1,10 @@ import uuid import pytest +from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError -from app import encryption +from app import db, encryption from app.dao.service_callback_api_dao import ( get_service_callback_api, get_service_delivery_status_callback_api_for_service, @@ -25,7 +26,7 @@ def test_save_service_callback_api(sample_service): save_service_callback_api(service_callback_api) - results = ServiceCallbackApi.query.all() + results = db.session.execute(select(ServiceCallbackApi)).scalars().all() assert len(results) == 1 callback_api = results[0] assert callback_api.id is not None @@ -37,7 +38,13 @@ def test_save_service_callback_api(sample_service): assert callback_api.updated_at is None versioned = ( - ServiceCallbackApi.get_history_model().query.filter_by(id=callback_api.id).one() + db.session.execute( + select(ServiceCallbackApi.get_history_model()).where( + ServiceCallbackApi.get_history_model().id == callback_api.id + ) + ) + .scalars() + .one() ) assert versioned.id == callback_api.id assert versioned.service_id == sample_service.id @@ -97,7 +104,13 @@ def test_update_service_callback_can_add_two_api_of_different_types(sample_servi callback_type=CallbackType.COMPLAINT, ) save_service_callback_api(complaint) - results = ServiceCallbackApi.query.order_by(ServiceCallbackApi.callback_type).all() + results = ( + db.session.execute( + select(ServiceCallbackApi).order_by(ServiceCallbackApi.callback_type) + ) + .scalars() + .all() + ) assert len(results) == 2 callbacks = [complaint.serialize(), delivery_status.serialize()] @@ -114,7 +127,7 @@ def test_update_service_callback_api(sample_service): ) save_service_callback_api(service_callback_api) - results = ServiceCallbackApi.query.all() + results = db.session.execute(select(ServiceCallbackApi)).scalars().all() assert len(results) == 1 saved_callback_api = results[0] @@ -123,7 +136,7 @@ def test_update_service_callback_api(sample_service): updated_by_id=sample_service.users[0].id, url="https://some_service/changed_url", ) - updated_results = ServiceCallbackApi.query.all() + updated_results = db.session.execute(select(ServiceCallbackApi)).scalars().all() assert len(updated_results) == 1 updated = updated_results[0] assert updated.id is not None @@ -135,8 +148,12 @@ def test_update_service_callback_api(sample_service): assert updated.updated_at is not None versioned_results = ( - ServiceCallbackApi.get_history_model() - .query.filter_by(id=saved_callback_api.id) + db.session.execute( + select(ServiceCallbackApi.get_history_model()).where( + ServiceCallbackApi.get_history_model().id == saved_callback_api.id + ) + ) + .scalars() .all() ) assert len(versioned_results) == 2 diff --git a/tests/app/dao/test_service_data_retention_dao.py b/tests/app/dao/test_service_data_retention_dao.py index 98f5d9f17..2aabd9fa7 100644 --- a/tests/app/dao/test_service_data_retention_dao.py +++ b/tests/app/dao/test_service_data_retention_dao.py @@ -1,8 +1,10 @@ import uuid import pytest +from sqlalchemy import select from sqlalchemy.exc import IntegrityError +from app import db from app.dao.service_data_retention_dao import ( fetch_service_data_retention, fetch_service_data_retention_by_id, @@ -97,7 +99,7 @@ def test_insert_service_data_retention(sample_service): days_of_retention=3, ) - results = ServiceDataRetention.query.all() + results = db.session.execute(select(ServiceDataRetention)).scalars().all() assert len(results) == 1 assert results[0].service_id == sample_service.id assert results[0].notification_type == NotificationType.EMAIL @@ -131,7 +133,7 @@ def test_update_service_data_retention(sample_service): days_of_retention=5, ) assert updated_count == 1 - results = ServiceDataRetention.query.all() + results = db.session.execute(select(ServiceDataRetention)).scalars().all() assert len(results) == 1 assert results[0].id == data_retention.id assert results[0].service_id == sample_service.id @@ -150,7 +152,7 @@ def test_update_service_data_retention_does_not_update_if_row_does_not_exist( days_of_retention=5, ) assert updated_count == 0 - assert len(ServiceDataRetention.query.all()) == 0 + assert len(db.session.execute(select(ServiceDataRetention)).scalars().all()) == 0 def test_update_service_data_retention_does_not_update_row_if_data_retention_is_for_different_service( diff --git a/tests/app/dao/test_service_email_reply_to_dao.py b/tests/app/dao/test_service_email_reply_to_dao.py index 851ecb870..c6ee1089b 100644 --- a/tests/app/dao/test_service_email_reply_to_dao.py +++ b/tests/app/dao/test_service_email_reply_to_dao.py @@ -1,8 +1,10 @@ import uuid import pytest +from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError +from app import db from app.dao.service_email_reply_to_dao import ( add_reply_to_email_address_for_service, archive_reply_to_email_address, @@ -186,7 +188,7 @@ def test_update_reply_to_email_address(sample_service): email_address="change_address@email.com", is_default=True, ) - updated_reply_to = ServiceEmailReplyTo.query.get(first_reply_to.id) + updated_reply_to = db.session.get(ServiceEmailReplyTo, first_reply_to.id) assert updated_reply_to.email_address == "change_address@email.com" assert updated_reply_to.updated_at @@ -206,7 +208,7 @@ def test_update_reply_to_email_address_set_updated_to_default(sample_service): is_default=True, ) - results = ServiceEmailReplyTo.query.all() + results = db.session.execute(select(ServiceEmailReplyTo)).scalars().all() assert len(results) == 2 for x in results: if x.email_address == "change_address@email.com": diff --git a/tests/app/dao/test_service_guest_list_dao.py b/tests/app/dao/test_service_guest_list_dao.py index 870c78bd8..021f42319 100644 --- a/tests/app/dao/test_service_guest_list_dao.py +++ b/tests/app/dao/test_service_guest_list_dao.py @@ -1,5 +1,8 @@ import uuid +from sqlalchemy import func, select + +from app import db from app.dao.service_guest_list_dao import ( dao_add_and_commit_guest_list_contacts, dao_fetch_service_guest_list, @@ -27,7 +30,8 @@ def test_add_and_commit_guest_list_contacts_saves_data(sample_service): dao_add_and_commit_guest_list_contacts([guest_list]) - db_contents = ServiceGuestList.query.all() + stmt = select(ServiceGuestList) + db_contents = db.session.execute(stmt).scalars().all() assert len(db_contents) == 1 assert db_contents[0].id == guest_list.id @@ -60,4 +64,6 @@ def test_remove_service_guest_list_does_not_commit( # since dao_remove_service_guest_list doesn't commit, we can still rollback its changes notify_db_session.rollback() - assert ServiceGuestList.query.count() == 1 + stmt = select(func.count()).select_from(ServiceGuestList) + count = db.session.execute(stmt).scalar() or 0 + assert count == 1 diff --git a/tests/app/dao/test_service_inbound_api_dao.py b/tests/app/dao/test_service_inbound_api_dao.py index 0a489062b..c0a4a4245 100644 --- a/tests/app/dao/test_service_inbound_api_dao.py +++ b/tests/app/dao/test_service_inbound_api_dao.py @@ -1,9 +1,10 @@ import uuid import pytest +from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError -from app import encryption +from app import db, encryption from app.dao.service_inbound_api_dao import ( get_service_inbound_api, get_service_inbound_api_for_service, @@ -24,7 +25,7 @@ def test_save_service_inbound_api(sample_service): save_service_inbound_api(service_inbound_api) - results = ServiceInboundApi.query.all() + results = db.session.execute(select(ServiceInboundApi)).scalars().all() assert len(results) == 1 inbound_api = results[0] assert inbound_api.id is not None @@ -36,7 +37,13 @@ def test_save_service_inbound_api(sample_service): assert inbound_api.updated_at is None versioned = ( - ServiceInboundApi.get_history_model().query.filter_by(id=inbound_api.id).one() + db.session.execute( + select(ServiceInboundApi.get_history_model()).where( + ServiceInboundApi.get_history_model().id == inbound_api.id + ) + ) + .scalars() + .one() ) assert versioned.id == inbound_api.id assert versioned.service_id == sample_service.id @@ -68,7 +75,7 @@ def test_update_service_inbound_api(sample_service): ) save_service_inbound_api(service_inbound_api) - results = ServiceInboundApi.query.all() + results = db.session.execute(select(ServiceInboundApi)).scalars().all() assert len(results) == 1 saved_inbound_api = results[0] @@ -77,7 +84,7 @@ def test_update_service_inbound_api(sample_service): updated_by_id=sample_service.users[0].id, url="https://some_service/changed_url", ) - updated_results = ServiceInboundApi.query.all() + updated_results = db.session.execute(select(ServiceInboundApi)).scalars().all() assert len(updated_results) == 1 updated = updated_results[0] assert updated.id is not None @@ -89,8 +96,12 @@ def test_update_service_inbound_api(sample_service): assert updated.updated_at is not None versioned_results = ( - ServiceInboundApi.get_history_model() - .query.filter_by(id=saved_inbound_api.id) + db.session.execute( + select(ServiceInboundApi.get_history_model()).where( + ServiceInboundApi.get_history_model().id == saved_inbound_api.id + ) + ) + .scalars() .all() ) assert len(versioned_results) == 2 diff --git a/tests/app/dao/test_service_sms_sender_dao.py b/tests/app/dao/test_service_sms_sender_dao.py index 10bfd21f4..21853e61f 100644 --- a/tests/app/dao/test_service_sms_sender_dao.py +++ b/tests/app/dao/test_service_sms_sender_dao.py @@ -126,7 +126,7 @@ def test_dao_add_sms_sender_for_service_switches_default(notify_db_session): def test_dao_update_service_sms_sender(notify_db_session): service = create_service() - stmt = select(ServiceSmsSender).filter_by(service_id=service.id) + stmt = select(ServiceSmsSender).where(ServiceSmsSender.service_id == service.id) service_sms_senders = db.session.execute(stmt).scalars().all() assert len(service_sms_senders) == 1 sms_sender_to_update = service_sms_senders[0] @@ -137,7 +137,7 @@ def test_dao_update_service_sms_sender(notify_db_session): is_default=True, sms_sender="updated", ) - stmt = select(ServiceSmsSender).filter_by(service_id=service.id) + stmt = select(ServiceSmsSender).where(ServiceSmsSender.service_id == service.id) sms_senders = db.session.execute(stmt).scalars().all() assert len(sms_senders) == 1 assert sms_senders[0].is_default @@ -159,7 +159,7 @@ def test_dao_update_service_sms_sender_switches_default(notify_db_session): is_default=True, sms_sender="updated", ) - stmt = select(ServiceSmsSender).filter_by(service_id=service.id) + stmt = select(ServiceSmsSender).where(ServiceSmsSender.service_id == service.id) sms_senders = db.session.execute(stmt).scalars().all() expected = {("testing", False), ("updated", True)} @@ -191,7 +191,7 @@ def test_update_existing_sms_sender_with_inbound_number(notify_db_session): service = create_service() inbound_number = create_inbound_number(number="12345", service_id=service.id) - stmt = select(ServiceSmsSender).filter_by(service_id=service.id) + stmt = select(ServiceSmsSender).where(ServiceSmsSender.service_id == service.id) existing_sms_sender = db.session.execute(stmt).scalars().one() sms_sender = update_existing_sms_sender_with_inbound_number( service_sms_sender=existing_sms_sender, @@ -208,7 +208,7 @@ def test_update_existing_sms_sender_with_inbound_number_raises_exception_if_inbo notify_db_session, ): service = create_service() - stmt = select(ServiceSmsSender).filter_by(service_id=service.id) + stmt = select(ServiceSmsSender).where(ServiceSmsSender.service_id == service.id) existing_sms_sender = db.session.execute(stmt).scalars().one() with pytest.raises(expected_exception=SQLAlchemyError): update_existing_sms_sender_with_inbound_number( diff --git a/tests/app/dao/test_services_dao.py b/tests/app/dao/test_services_dao.py index 61fe99419..d319ffcaf 100644 --- a/tests/app/dao/test_services_dao.py +++ b/tests/app/dao/test_services_dao.py @@ -107,7 +107,7 @@ def _get_first_service(): def _get_service_by_id(service_id): - stmt = select(Service).filter(Service.id == service_id) + stmt = select(Service).where(Service.id == service_id) service = db.session.execute(stmt).scalars().one() return service @@ -746,9 +746,13 @@ def test_update_service_creates_a_history_record_with_current_data(notify_db_ses service_from_db = _get_first_service() assert service_from_db.version == 2 - stmt = select(Service.get_history_model()).filter_by(name="service_name") + stmt = select(Service.get_history_model()).where( + Service.get_history_model().name == "service_name" + ) assert db.session.execute(stmt).scalars().one().version == 1 - stmt = select(Service.get_history_model()).filter_by(name="updated_service_name") + stmt = select(Service.get_history_model()).where( + Service.get_history_model().name == "updated_service_name" + ) assert db.session.execute(stmt).scalars().one().version == 2 @@ -819,7 +823,7 @@ def test_update_service_permission_creates_a_history_record_with_current_data( stmt = ( select(Service.get_history_model()) - .filter_by(name="service_name") + .where(Service.get_history_model().name == "service_name") .order_by("version") ) history = db.session.execute(stmt).scalars().all() @@ -920,7 +924,9 @@ def test_add_existing_user_to_another_service_doesnot_change_old_permissions( dao_create_service(service_one, user) assert user.id == service_one.users[0].id - stmt = select(Permission).filter_by(service=service_one, user=user) + stmt = select(Permission).where( + Permission.service == service_one, Permission.user == user + ) test_user_permissions = db.session.execute(stmt).all() assert len(test_user_permissions) == 7 @@ -941,10 +947,14 @@ def test_add_existing_user_to_another_service_doesnot_change_old_permissions( dao_create_service(service_two, other_user) assert other_user.id == service_two.users[0].id - stmt = select(Permission).filter_by(service=service_two, user=other_user) + stmt = select(Permission).where( + Permission.service == service_two, Permission.user == other_user + ) other_user_permissions = db.session.execute(stmt).all() assert len(other_user_permissions) == 7 - stmt = select(Permission).filter_by(service=service_one, user=other_user) + stmt = select(Permission).where( + Permission.service == service_one, Permission.user == other_user + ) other_user_service_one_permissions = db.session.execute(stmt).all() assert len(other_user_service_one_permissions) == 0 @@ -955,11 +965,15 @@ def test_add_existing_user_to_another_service_doesnot_change_old_permissions( permissions.append(Permission(permission=p)) dao_add_user_to_service(service_one, other_user, permissions=permissions) - stmt = select(Permission).filter_by(service=service_one, user=other_user) + stmt = select(Permission).where( + Permission.service == service_one, Permission.user == other_user + ) other_user_service_one_permissions = db.session.execute(stmt).all() assert len(other_user_service_one_permissions) == 2 - stmt = select(Permission).filter_by(service=service_two, user=other_user) + stmt = select(Permission).where( + Permission.service == service_two, Permission.user == other_user + ) other_user_service_two_permissions = db.session.execute(stmt).all() assert len(other_user_service_two_permissions) == 7 @@ -1638,11 +1652,13 @@ _this_date = utc_now() - timedelta(days=4) StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 0, + StatisticsType.PENDING: 0, }, TemplateType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 2, + StatisticsType.PENDING: 2, }, }, (_this_date.date() + timedelta(days=1)).strftime("%Y-%m-%d"): { @@ -1650,11 +1666,13 @@ _this_date = utc_now() - timedelta(days=4) StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 0, + StatisticsType.PENDING: 0, }, TemplateType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 1, + StatisticsType.PENDING: 0, }, }, (_this_date.date() + timedelta(days=2)).strftime("%Y-%m-%d"): { @@ -1662,11 +1680,13 @@ _this_date = utc_now() - timedelta(days=4) StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 0, + StatisticsType.PENDING: 0, }, TemplateType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 1, + StatisticsType.PENDING: 0, }, }, (_this_date.date() + timedelta(days=3)).strftime("%Y-%m-%d"): { @@ -1674,11 +1694,13 @@ _this_date = utc_now() - timedelta(days=4) StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 0, + StatisticsType.PENDING: 0, }, TemplateType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 0, + StatisticsType.PENDING: 0, }, }, (_this_date.date() + timedelta(days=4)).strftime("%Y-%m-%d"): { @@ -1686,11 +1708,13 @@ _this_date = utc_now() - timedelta(days=4) StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 0, + StatisticsType.PENDING: 0, }, TemplateType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 1, + StatisticsType.PENDING: 0, }, }, }, @@ -1713,11 +1737,13 @@ _this_date = utc_now() - timedelta(days=4) StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 0, + StatisticsType.PENDING: 0, }, TemplateType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 2, + StatisticsType.PENDING: 2, }, }, (_this_date.date() + timedelta(days=1)).strftime("%Y-%m-%d"): { @@ -1725,11 +1751,13 @@ _this_date = utc_now() - timedelta(days=4) StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 0, + StatisticsType.PENDING: 0, }, TemplateType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 1, + StatisticsType.PENDING: 0, }, }, (_this_date.date() + timedelta(days=2)).strftime("%Y-%m-%d"): { @@ -1737,11 +1765,13 @@ _this_date = utc_now() - timedelta(days=4) StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 0, + StatisticsType.PENDING: 0, }, TemplateType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 1, + StatisticsType.PENDING: 0, }, }, (_this_date.date() + timedelta(days=3)).strftime("%Y-%m-%d"): { @@ -1749,11 +1779,13 @@ _this_date = utc_now() - timedelta(days=4) StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 0, + StatisticsType.PENDING: 0, }, TemplateType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 0, + StatisticsType.PENDING: 0, }, }, (_this_date.date() + timedelta(days=4)).strftime("%Y-%m-%d"): { @@ -1761,11 +1793,13 @@ _this_date = utc_now() - timedelta(days=4) StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 0, + StatisticsType.PENDING: 0, }, TemplateType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, StatisticsType.REQUESTED: 1, + StatisticsType.PENDING: 0, }, }, }, @@ -1786,5 +1820,21 @@ def test_get_specific_days(data, start_date, days, end_date, expected, is_error) 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) + + total_notifications = None + + date_key = _this_date.date().strftime("%Y-%m-%d") + if expected and date_key in expected: + sms_stats = expected[date_key].get(TemplateType.SMS, {}) + requested = sms_stats.get(StatisticsType.REQUESTED, 0) + if requested > 0: + total_notifications = {_this_date: requested} + + results = get_specific_days_stats( + new_data, + start_date, + days, + end_date, + total_notifications=total_notifications, + ) assert results == expected diff --git a/tests/app/dao/test_templates_dao.py b/tests/app/dao/test_templates_dao.py index 734a29c0a..e37248de7 100644 --- a/tests/app/dao/test_templates_dao.py +++ b/tests/app/dao/test_templates_dao.py @@ -334,9 +334,9 @@ def test_update_template_creates_a_history_record_with_current_data( assert template_from_db.version == 2 - stmt = select(TemplateHistory).filter_by(name="Sample Template") + stmt = select(TemplateHistory).where(TemplateHistory.name == "Sample Template") assert db.session.execute(stmt).scalars().one().version == 1 - stmt = select(TemplateHistory).filter_by(name="new name") + stmt = select(TemplateHistory).where(TemplateHistory.name == "new name") assert db.session.execute(stmt).scalars().one().version == 2 diff --git a/tests/app/dao/test_users_dao.py b/tests/app/dao/test_users_dao.py index 8f9f21fe3..a07d6308a 100644 --- a/tests/app/dao/test_users_dao.py +++ b/tests/app/dao/test_users_dao.py @@ -74,12 +74,12 @@ def test_create_user(notify_db_session, phone_number, expected_phone_number): stmt = select(func.count(User.id)) assert db.session.execute(stmt).scalar() == 1 stmt = select(User) - user_query = db.session.execute(stmt).scalars().first() - assert user_query.email_address == email - assert user_query.id == user.id - assert user_query.mobile_number == expected_phone_number - assert user_query.email_access_validated_at == utc_now() - assert not user_query.platform_admin + user = db.session.execute(stmt).scalars().first() + assert user.email_address == email + assert user.id == user.id + assert user.mobile_number == expected_phone_number + assert user.email_access_validated_at == utc_now() + assert not user.platform_admin def test_get_all_users(notify_db_session): diff --git a/tests/app/db.py b/tests/app/db.py index b62f99b4e..4177c6b05 100644 --- a/tests/app/db.py +++ b/tests/app/db.py @@ -2,6 +2,8 @@ import random import uuid from datetime import datetime, timedelta +from sqlalchemy import select + from app import db from app.dao import fact_processing_time_dao from app.dao.email_branding_dao import dao_create_email_branding @@ -90,7 +92,8 @@ def create_user( "state": state, "platform_admin": platform_admin, } - user = User.query.filter_by(email_address=email).first() + stmt = select(User).where(User.email_address == email) + user = db.session.execute(stmt).scalars().first() if not user: user = User(**data) save_model_user(user, validated_email_access=True) @@ -118,7 +121,7 @@ def create_service( email_from=None, prefix_sms=True, message_limit=1000, - total_message_limit=250000, + total_message_limit=100000, organization_type=OrganizationType.FEDERAL, check_if_service_exists=False, go_live_user=None, @@ -130,7 +133,8 @@ def create_service( billing_reference=None, ): if check_if_service_exists: - service = Service.query.filter_by(name=service_name).first() + stmt = select(Service).where(Service.name == service_name) + service = db.session.execute(stmt).scalars().first() if (not check_if_service_exists) or (check_if_service_exists and not service): service = Service( name=service_name, @@ -175,7 +179,8 @@ def create_service( def create_service_with_inbound_number(inbound_number="1234567", *args, **kwargs): service = create_service(*args, **kwargs) - sms_sender = ServiceSmsSender.query.filter_by(service_id=service.id).first() + stmt = select(ServiceSmsSender).where(ServiceSmsSender.service_id == service.id) + sms_sender = db.session.execute(stmt).scalars().first() inbound = create_inbound_number(number=inbound_number, service_id=service.id) update_existing_sms_sender_with_inbound_number( service_sms_sender=sms_sender, @@ -189,7 +194,8 @@ def create_service_with_inbound_number(inbound_number="1234567", *args, **kwargs def create_service_with_defined_sms_sender(sms_sender_value="1234567", *args, **kwargs): service = create_service(*args, **kwargs) - sms_sender = ServiceSmsSender.query.filter_by(service_id=service.id).first() + stmt = select(ServiceSmsSender).where(ServiceSmsSender.service_id == service.id) + sms_sender = db.session.execute(stmt).scalars().first() dao_update_service_sms_sender( service_id=service.id, service_sms_sender_id=sms_sender.id, @@ -286,9 +292,10 @@ def create_notification( if not one_off and (job is None and api_key is None): # we did not specify in test - lets create it - api_key = ApiKey.query.filter( + stmt = select(ApiKey).where( ApiKey.service == template.service, ApiKey.key_type == key_type - ).first() + ) + api_key = db.session.execute(stmt).scalars().first() if not api_key: api_key = create_api_key(template.service, key_type=key_type) @@ -432,7 +439,7 @@ def create_service_permission(service_id, permission=ServicePermissionType.EMAIL permission, ) - service_permissions = ServicePermission.query.all() + service_permissions = db.session.execute(select(ServicePermission)).scalars().all() return service_permissions diff --git a/tests/app/delivery/test_send_to_providers.py b/tests/app/delivery/test_send_to_providers.py index 20b0f7186..c7f404324 100644 --- a/tests/app/delivery/test_send_to_providers.py +++ b/tests/app/delivery/test_send_to_providers.py @@ -5,9 +5,10 @@ from unittest.mock import ANY import pytest from flask import current_app from requests import HTTPError +from sqlalchemy import select import app -from app import aws_sns_client, notification_provider_clients +from app import aws_sns_client, db, notification_provider_clients from app.cloudfoundry_config import cloud_config from app.dao import notifications_dao from app.dao.provider_details_dao import get_provider_details_by_identifier @@ -93,6 +94,7 @@ def test_should_send_personalised_template_to_correct_sms_provider_and_persist( mock_s3 = mocker.patch("app.delivery.send_to_providers.get_phone_number_from_s3") mock_s3.return_value = "2028675309" + mocker.patch("app.delivery.send_to_providers.update_notification_message_id") mock_personalisation = mocker.patch( "app.delivery.send_to_providers.get_personalisation_from_s3" ) @@ -108,7 +110,13 @@ def test_should_send_personalised_template_to_correct_sms_provider_and_persist( international=False, ) - notification = Notification.query.filter_by(id=db_notification.id).one() + notification = ( + db.session.execute( + select(Notification).where(Notification.id == db_notification.id) + ) + .scalars() + .one() + ) assert notification.status == NotificationStatus.SENDING assert notification.sent_at <= utc_now() @@ -152,7 +160,13 @@ def test_should_send_personalised_template_to_correct_email_provider_and_persist in app.aws_ses_client.send_email.call_args[1]["html_body"] ) - notification = Notification.query.filter_by(id=db_notification.id).one() + notification = ( + db.session.execute( + select(Notification).where(Notification.id == db_notification.id) + ) + .scalars() + .one() + ) assert notification.status == NotificationStatus.SENDING assert notification.sent_at <= utc_now() assert notification.sent_by == "ses" @@ -188,7 +202,7 @@ def test_should_not_send_email_message_when_service_is_inactive_notifcation_is_i assert str(sample_notification.id) in str(e.value) send_mock.assert_not_called() assert ( - Notification.query.get(sample_notification.id).status + db.session.get(Notification, sample_notification.id).status == NotificationStatus.TECHNICAL_FAILURE ) @@ -212,7 +226,7 @@ def test_should_not_send_sms_message_when_service_is_inactive_notification_is_in assert str(sample_notification.id) in str(e.value) send_mock.assert_not_called() assert ( - Notification.query.get(sample_notification.id).status + db.session.get(Notification, sample_notification.id).status == NotificationStatus.TECHNICAL_FAILURE ) @@ -233,6 +247,7 @@ def test_send_sms_should_use_template_version_from_notification_not_latest( mock_s3 = mocker.patch("app.delivery.send_to_providers.get_phone_number_from_s3") mock_s3.return_value = "2028675309" + mocker.patch("app.delivery.send_to_providers.update_notification_message_id") mock_s3_p = mocker.patch( "app.delivery.send_to_providers.get_personalisation_from_s3" ) @@ -327,6 +342,7 @@ def test_should_send_sms_with_downgraded_content(notify_db_session, mocker): # ī, grapes, tabs, zero width space and ellipsis are not # ó isn't in GSM, but it is in the welsh alphabet so will still be sent + mocker.patch("app.delivery.send_to_providers.update_notification_message_id") mocker.patch("app.delivery.send_to_providers.redis_store", return_value=None) mocker.patch( "app.delivery.send_to_providers.get_sender_numbers", return_value=["testing"] @@ -365,6 +381,7 @@ def test_send_sms_should_use_service_sms_sender( mocker.patch("app.delivery.send_to_providers.redis_store", return_value=None) mocker.patch("app.aws_sns_client.send_sms") + mocker.patch("app.delivery.send_to_providers.update_notification_message_id") sms_sender = create_service_sms_sender( service=sample_service, sms_sender="123456", is_default=False @@ -405,6 +422,8 @@ def test_send_email_to_provider_should_not_send_to_provider_when_status_is_not_c ) mocker.patch("app.aws_ses_client.send_email") mocker.patch("app.delivery.send_to_providers.send_email_response") + + mocker.patch("app.delivery.send_to_providers.update_notification_message_id") mock_phone = mocker.patch("app.delivery.send_to_providers.get_phone_number_from_s3") mock_phone.return_value = "15555555555" @@ -627,6 +646,10 @@ def test_should_update_billable_units_and_status_according_to_research_mode_and_ ): mocker.patch("app.delivery.send_to_providers.redis_store", return_value=None) + mocker.patch( + "app.delivery.send_to_providers.update_notification_message_id", + return_value=None, + ) mocker.patch( "app.delivery.send_to_providers.get_sender_numbers", return_value=["testing"] ) @@ -637,6 +660,11 @@ def test_should_update_billable_units_and_status_according_to_research_mode_and_ key_type=key_type, reply_to_text="testing", ) + + mocker.patch( + "app.delivery.send_to_providers.update_notification_message_id", + return_value=None, + ) mocker.patch("app.aws_sns_client.send_sms") mocker.patch( "app.delivery.send_to_providers.send_sms_response", @@ -647,6 +675,8 @@ def test_should_update_billable_units_and_status_according_to_research_mode_and_ sample_template.service.research_mode = True mock_phone = mocker.patch("app.delivery.send_to_providers.get_phone_number_from_s3") + + mocker.patch("app.delivery.send_to_providers.update_notification_message_id") mock_phone.return_value = "15555555555" mock_personalisation = mocker.patch( @@ -670,6 +700,8 @@ def test_should_set_notification_billable_units_and_reduces_provider_priority_if assert sample_notification.sent_by is None mock_phone = mocker.patch("app.delivery.send_to_providers.get_phone_number_from_s3") + + mocker.patch("app.delivery.send_to_providers.update_notification_message_id") mock_phone.return_value = "15555555555" mock_personalisation = mocker.patch( @@ -705,8 +737,14 @@ def test_should_send_sms_to_international_providers( ) mock_s3 = mocker.patch("app.delivery.send_to_providers.get_phone_number_from_s3") + + mocker.patch("app.delivery.send_to_providers.update_notification_message_id") mock_s3.return_value = "601117224412" + mocker.patch( + "app.delivery.send_to_providers.update_notification_message_id", + return_value=None, + ) mock_personalisation = mocker.patch( "app.delivery.send_to_providers.get_personalisation_from_s3" ) @@ -744,6 +782,11 @@ def test_should_handle_sms_sender_and_prefix_message( mocker.patch("app.delivery.send_to_providers.redis_store", return_value=None) mocker.patch("app.aws_sns_client.send_sms") + + mocker.patch( + "app.delivery.send_to_providers.update_notification_message_id", + return_value=None, + ) service = create_service_with_defined_sms_sender( sms_sender_value=sms_sender, prefix_sms=prefix_sms ) @@ -803,6 +846,11 @@ def test_send_sms_to_provider_should_use_normalised_to(mocker, client, sample_te mocker.patch( "app.delivery.send_to_providers.get_sender_numbers", return_value=["testing"] ) + + mocker.patch( + "app.delivery.send_to_providers.update_notification_message_id", + return_value=None, + ) send_mock = mocker.patch("app.aws_sns_client.send_sms") notification = create_notification( template=sample_template, @@ -866,6 +914,11 @@ def test_send_sms_to_provider_should_return_template_if_found_in_redis( mocker.patch( "app.delivery.send_to_providers.get_sender_numbers", return_value=["testing"] ) + + mocker.patch( + "app.delivery.send_to_providers.update_notification_message_id", + return_value=None, + ) from app.schemas import service_schema, template_schema service_dict = service_schema.dump(sample_template.service) diff --git a/tests/app/email_branding/test_rest.py b/tests/app/email_branding/test_rest.py index b406ec8be..179ff35e3 100644 --- a/tests/app/email_branding/test_rest.py +++ b/tests/app/email_branding/test_rest.py @@ -1,5 +1,7 @@ import pytest +from sqlalchemy import select +from app import db from app.enums import BrandType from app.models import EmailBranding from tests.app.db import create_email_branding @@ -198,7 +200,7 @@ def test_post_update_email_branding_updates_field( email_branding_id=email_branding_id, ) - email_branding = EmailBranding.query.all() + email_branding = db.session.execute(select(EmailBranding)).scalars().all() assert len(email_branding) == 1 assert str(email_branding[0].id) == email_branding_id @@ -231,7 +233,7 @@ def test_post_update_email_branding_updates_field_with_text( email_branding_id=email_branding_id, ) - email_branding = EmailBranding.query.all() + email_branding = db.session.execute(select(EmailBranding)).scalars().all() assert len(email_branding) == 1 assert str(email_branding[0].id) == email_branding_id diff --git a/tests/app/notifications/test_notifications_ses_callback.py b/tests/app/notifications/test_notifications_ses_callback.py index ec61004d6..c7d32eda2 100644 --- a/tests/app/notifications/test_notifications_ses_callback.py +++ b/tests/app/notifications/test_notifications_ses_callback.py @@ -1,7 +1,9 @@ import pytest from flask import json +from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError +from app import db from app.celery.process_ses_receipts_tasks import ( check_and_queue_callback_task, handle_complaint, @@ -35,7 +37,7 @@ def test_ses_callback_should_not_set_status_once_status_is_delivered( def test_process_ses_results_in_complaint(sample_email_template): notification = create_notification(template=sample_email_template, reference="ref1") handle_complaint(json.loads(ses_complaint_callback()["Message"])) - complaints = Complaint.query.all() + complaints = db.session.execute(select(Complaint)).scalars().all() assert len(complaints) == 1 assert complaints[0].notification_id == notification.id @@ -43,7 +45,7 @@ def test_process_ses_results_in_complaint(sample_email_template): def test_handle_complaint_does_not_raise_exception_if_reference_is_missing(notify_api): response = json.loads(ses_complaint_callback_malformed_message_id()["Message"]) handle_complaint(response) - assert len(Complaint.query.all()) == 0 + assert len(db.session.execute(select(Complaint)).scalars().all()) == 0 def test_handle_complaint_does_raise_exception_if_notification_not_found(notify_api): @@ -57,7 +59,7 @@ def test_process_ses_results_in_complaint_if_notification_history_does_not_exist ): notification = create_notification(template=sample_email_template, reference="ref1") handle_complaint(json.loads(ses_complaint_callback()["Message"])) - complaints = Complaint.query.all() + complaints = db.session.execute(select(Complaint)).scalars().all() assert len(complaints) == 1 assert complaints[0].notification_id == notification.id @@ -69,7 +71,7 @@ def test_process_ses_results_in_complaint_if_notification_does_not_exist( template=sample_email_template, reference="ref1" ) handle_complaint(json.loads(ses_complaint_callback()["Message"])) - complaints = Complaint.query.all() + complaints = db.session.execute(select(Complaint)).scalars().all() assert len(complaints) == 1 assert complaints[0].notification_id == notification.id @@ -80,7 +82,7 @@ def test_process_ses_results_in_complaint_save_complaint_with_null_complaint_typ notification = create_notification(template=sample_email_template, reference="ref1") msg = json.loads(ses_complaint_callback_with_missing_complaint_type()["Message"]) handle_complaint(msg) - complaints = Complaint.query.all() + complaints = db.session.execute(select(Complaint)).scalars().all() assert len(complaints) == 1 assert complaints[0].notification_id == notification.id assert not complaints[0].complaint_type diff --git a/tests/app/notifications/test_process_notification.py b/tests/app/notifications/test_process_notification.py index d7caf5bb1..d62e8549c 100644 --- a/tests/app/notifications/test_process_notification.py +++ b/tests/app/notifications/test_process_notification.py @@ -5,8 +5,10 @@ from collections import namedtuple import pytest from boto3.exceptions import Boto3Error from freezegun import freeze_time +from sqlalchemy import func, select from sqlalchemy.exc import SQLAlchemyError +from app import db from app.enums import KeyType, NotificationType, ServicePermissionType, TemplateType from app.errors import BadRequestError from app.models import Notification, NotificationHistory @@ -67,12 +69,22 @@ def test_create_content_for_notification_allows_additional_personalisation( ) +def _get_notification_query_count(): + stmt = select(func.count()).select_from(Notification) + return db.session.execute(stmt).scalar() or 0 + + +def _get_notification_history_query_count(): + stmt = select(func.count()).select_from(NotificationHistory) + return db.session.execute(stmt).scalar() or 0 + + @freeze_time("2016-01-01 11:09:00.061258") def test_persist_notification_creates_and_save_to_db( sample_template, sample_api_key, sample_job ): - assert Notification.query.count() == 0 - assert NotificationHistory.query.count() == 0 + assert _get_notification_query_count() == 0 + assert _get_notification_history_query_count() == 0 notification = persist_notification( template_id=sample_template.id, template_version=sample_template.version, @@ -88,9 +100,9 @@ def test_persist_notification_creates_and_save_to_db( reply_to_text=sample_template.service.get_default_sms_sender(), ) - assert Notification.query.get(notification.id) is not None + assert db.session.get(Notification, notification.id) is not None - notification_from_db = Notification.query.one() + notification_from_db = db.session.execute(select(Notification)).scalars().one() assert notification_from_db.id == notification.id assert notification_from_db.template_id == notification.template_id @@ -114,8 +126,8 @@ def test_persist_notification_creates_and_save_to_db( def test_persist_notification_throws_exception_when_missing_template(sample_api_key): - assert Notification.query.count() == 0 - assert NotificationHistory.query.count() == 0 + assert _get_notification_query_count() == 0 + assert _get_notification_history_query_count() == 0 with pytest.raises(SQLAlchemyError): persist_notification( template_id=None, @@ -127,14 +139,14 @@ def test_persist_notification_throws_exception_when_missing_template(sample_api_ api_key_id=sample_api_key.id, key_type=sample_api_key.key_type, ) - assert Notification.query.count() == 0 - assert NotificationHistory.query.count() == 0 + assert _get_notification_query_count() == 0 + assert _get_notification_history_query_count() == 0 @freeze_time("2016-01-01 11:09:00.061258") def test_persist_notification_with_optionals(sample_job, sample_api_key): - assert Notification.query.count() == 0 - assert NotificationHistory.query.count() == 0 + assert _get_notification_query_count() == 0 + assert _get_notification_history_query_count() == 0 n_id = uuid.uuid4() created_at = datetime.datetime(2016, 11, 11, 16, 8, 18) persist_notification( @@ -153,9 +165,10 @@ def test_persist_notification_with_optionals(sample_job, sample_api_key): notification_id=n_id, created_by_id=sample_job.created_by_id, ) - assert Notification.query.count() == 1 - assert NotificationHistory.query.count() == 0 - persisted_notification = Notification.query.all()[0] + assert _get_notification_query_count() == 1 + assert _get_notification_history_query_count() == 0 + stmt = select(Notification) + persisted_notification = db.session.execute(stmt).scalars().all()[0] assert persisted_notification.id == n_id assert persisted_notification.job_id == sample_job.id assert persisted_notification.job_row_number == 10 @@ -250,7 +263,9 @@ def test_send_notification_to_queue( send_notification_to_queue(notification=notification, queue=requested_queue) - mocked.assert_called_once_with([str(notification.id)], queue=expected_queue) + mocked.assert_called_once_with( + [str(notification.id)], queue=expected_queue, countdown=60 + ) def test_send_notification_to_queue_throws_exception_deletes_notification( @@ -263,12 +278,11 @@ def test_send_notification_to_queue_throws_exception_deletes_notification( with pytest.raises(Boto3Error): send_notification_to_queue(sample_notification, False) mocked.assert_called_once_with( - [(str(sample_notification.id))], - queue="send-sms-tasks", + [(str(sample_notification.id))], queue="send-sms-tasks", countdown=60 ) - assert Notification.query.count() == 0 - assert NotificationHistory.query.count() == 0 + assert _get_notification_query_count() == 0 + assert _get_notification_history_query_count() == 0 @pytest.mark.parametrize( @@ -349,7 +363,8 @@ def test_persist_notification_with_international_info_stores_correct_info( job_row_number=10, client_reference="ref from client", ) - persisted_notification = Notification.query.all()[0] + stmt = select(Notification) + persisted_notification = db.session.execute(stmt).scalars().all()[0] assert persisted_notification.international is expected_international assert persisted_notification.phone_prefix == expected_prefix @@ -372,7 +387,8 @@ def test_persist_notification_with_international_info_does_not_store_for_email( job_row_number=10, client_reference="ref from client", ) - persisted_notification = Notification.query.all()[0] + stmt = select(Notification) + persisted_notification = db.session.execute(stmt).scalars().all()[0] assert persisted_notification.international is False assert persisted_notification.phone_prefix is None @@ -404,7 +420,8 @@ def test_persist_sms_notification_stores_normalised_number( key_type=sample_api_key.key_type, job_id=sample_job.id, ) - persisted_notification = Notification.query.all()[0] + stmt = select(Notification) + persisted_notification = db.session.execute(stmt).scalars().all()[0] assert persisted_notification.to == "1" assert persisted_notification.normalised_to == "1" @@ -428,7 +445,8 @@ def test_persist_email_notification_stores_normalised_email( key_type=sample_api_key.key_type, job_id=sample_job.id, ) - persisted_notification = Notification.query.all()[0] + stmt = select(Notification) + persisted_notification = db.session.execute(stmt).scalars().all()[0] assert persisted_notification.to == "1" assert persisted_notification.normalised_to == "1" @@ -449,6 +467,7 @@ def test_persist_notification_with_billable_units_stores_correct_info(mocker): key_type=KeyType.NORMAL, billable_units=3, ) - persisted_notification = Notification.query.all()[0] + stmt = select(Notification) + persisted_notification = db.session.execute(stmt).scalars().all()[0] assert persisted_notification.billable_units == 3 diff --git a/tests/app/notifications/test_receive_notification.py b/tests/app/notifications/test_receive_notification.py index aa2972bc1..a3c1dad1a 100644 --- a/tests/app/notifications/test_receive_notification.py +++ b/tests/app/notifications/test_receive_notification.py @@ -4,7 +4,9 @@ from unittest import mock import pytest from flask import current_app, json +from sqlalchemy import func, select +from app import db from app.enums import ServicePermissionType from app.models import InboundSms from app.notifications.receive_notifications import ( @@ -63,7 +65,7 @@ def test_receive_notification_returns_received_to_sns( prom_counter_labels_mock.assert_called_once_with("sns") prom_counter_labels_mock.return_value.inc.assert_called_once_with() - inbound_sms_id = InboundSms.query.all()[0].id + inbound_sms_id = db.session.execute(select(InboundSms)).scalars().all()[0].id mocked.assert_called_once_with( [str(inbound_sms_id), str(sample_service_full_permissions.id)], queue="notify-internal-tasks", @@ -100,7 +102,9 @@ def test_receive_notification_from_sns_without_permissions_does_not_persist( parsed_response = json.loads(response.get_data(as_text=True)) assert parsed_response["result"] == "success" - assert InboundSms.query.count() == 0 + stmt = select(func.count()).select_from(InboundSms) + count = db.session.execute(stmt).scalar() or 0 + assert count == 0 assert mocked.called is False @@ -133,7 +137,7 @@ def test_receive_notification_without_permissions_does_not_create_inbound_even_w response = sns_post(client, data) assert response.status_code == 200 - assert len(InboundSms.query.all()) == 0 + assert len(db.session.execute(select(InboundSms)).scalars().all()) == 0 assert mocked_has_permissions.called mocked_send_inbound_sms.assert_not_called() @@ -286,7 +290,10 @@ def test_receive_notification_error_if_not_single_matching_service( # we still return 'RECEIVED' to MMG assert response.status_code == 200 assert response.get_data(as_text=True) == "RECEIVED" - assert InboundSms.query.count() == 0 + + stmt = select(func.count()).select_from(InboundSms) + count = db.session.execute(stmt).scalar() or 0 + assert count == 0 @pytest.mark.skip(reason="Need to implement inbound SNS tests. Body here from MMG") diff --git a/tests/app/notifications/test_validators.py b/tests/app/notifications/test_validators.py index f9df6fb91..5cf9f2de0 100644 --- a/tests/app/notifications/test_validators.py +++ b/tests/app/notifications/test_validators.py @@ -1,11 +1,8 @@ import pytest -from flask import current_app -from freezegun import freeze_time -import app from app.dao import templates_dao from app.enums import KeyType, NotificationType, ServicePermissionType, TemplateType -from app.errors import BadRequestError, RateLimitError, TotalRequestsError +from app.errors import BadRequestError, TotalRequestsError from app.notifications.process_notifications import create_content_for_notification from app.notifications.sns_cert_validator import ( VALID_SNS_TOPICS, @@ -17,10 +14,8 @@ from app.notifications.validators import ( check_if_service_can_send_files_by_email, check_is_message_too_long, check_notification_content_is_not_empty, - check_rate_limiting, check_reply_to, check_service_email_reply_to_id, - check_service_over_api_rate_limit, check_service_over_total_message_limit, check_service_sms_sender_id, check_template_is_active, @@ -29,16 +24,11 @@ from app.notifications.validators import ( validate_and_format_recipient, validate_template, ) -from app.serialised_models import ( - SerialisedAPIKeyCollection, - SerialisedService, - SerialisedTemplate, -) +from app.serialised_models import SerialisedService, SerialisedTemplate from app.service.utils import service_allowed_to_send_to from app.utils import get_template_instance from notifications_utils import SMS_CHAR_COUNT_LIMIT from tests.app.db import ( - create_api_key, create_reply_to_email, create_service, create_service_guest_list, @@ -62,13 +52,13 @@ def test_check_service_over_total_message_limit_fails( service = create_service() mocker.patch( "app.redis_store.get", - return_value="250001", + return_value="100001", ) with pytest.raises(TotalRequestsError) as e: check_service_over_total_message_limit(key_type, service) assert e.value.status_code == 429 - assert e.value.message == "Exceeded total application limits (250000) for today" + assert e.value.message == "Exceeded total application limits (100000) for today" assert e.value.fields == [] @@ -482,92 +472,6 @@ def test_validate_template_calls_all_validators_exception_message_too_long( assert not mock_check_message_is_too_long.called -@pytest.mark.parametrize("key_type", [KeyType.TEAM, KeyType.NORMAL, KeyType.TEST]) -def test_check_service_over_api_rate_limit_when_exceed_rate_limit_request_fails_raises_error( - key_type, sample_service, mocker -): - with freeze_time("2016-01-01 12:00:00.000000"): - mocker.patch("app.redis_store.exceeded_rate_limit", return_value=True) - - sample_service.restricted = True - api_key = create_api_key(sample_service, key_type=key_type) - serialised_service = SerialisedService.from_id(sample_service.id) - serialised_api_key = SerialisedAPIKeyCollection.from_service_id( - serialised_service.id - )[0] - - with pytest.raises(RateLimitError) as e: - check_service_over_api_rate_limit(serialised_service, serialised_api_key) - - app.redis_store.exceeded_rate_limit.assert_called_with( - f"{sample_service.id}-{api_key.key_type}", - sample_service.rate_limit, - 60, - ) - assert e.value.status_code == 429 - assert e.value.message == ( - f"Exceeded rate limit for key type " - f"{key_type.name if key_type != KeyType.NORMAL else 'LIVE'} of " - f"{sample_service.rate_limit} requests per {60} seconds" - ) - assert e.value.fields == [] - - -def test_check_service_over_api_rate_limit_when_rate_limit_has_not_exceeded_limit_succeeds( - sample_service, - mocker, -): - with freeze_time("2016-01-01 12:00:00.000000"): - mocker.patch("app.redis_store.exceeded_rate_limit", return_value=False) - - sample_service.restricted = True - api_key = create_api_key(sample_service) - serialised_service = SerialisedService.from_id(sample_service.id) - serialised_api_key = SerialisedAPIKeyCollection.from_service_id( - serialised_service.id - )[0] - - check_service_over_api_rate_limit(serialised_service, serialised_api_key) - app.redis_store.exceeded_rate_limit.assert_called_with( - f"{sample_service.id}-{api_key.key_type}", - 3000, - 60, - ) - - -def test_check_service_over_api_rate_limit_should_do_nothing_if_limiting_is_disabled( - sample_service, mocker -): - with freeze_time("2016-01-01 12:00:00.000000"): - current_app.config["API_RATE_LIMIT_ENABLED"] = False - - mocker.patch("app.redis_store.exceeded_rate_limit", return_value=False) - - sample_service.restricted = True - create_api_key(sample_service) - serialised_service = SerialisedService.from_id(sample_service.id) - serialised_api_key = SerialisedAPIKeyCollection.from_service_id( - serialised_service.id - )[0] - - check_service_over_api_rate_limit(serialised_service, serialised_api_key) - app.redis_store.exceeded_rate_limit.assert_not_called() - - -def test_check_rate_limiting_validates_api_rate_limit_and_daily_limit( - notify_db_session, mocker -): - mock_rate_limit = mocker.patch( - "app.notifications.validators.check_service_over_api_rate_limit" - ) - service = create_service() - api_key = create_api_key(service=service) - - check_rate_limiting(service, api_key) - - mock_rate_limit.assert_called_once_with(service, api_key) - - @pytest.mark.parametrize("key_type", [KeyType.TEST, KeyType.NORMAL]) @pytest.mark.skip( "We currently don't support international numbers, our validation fails before here" diff --git a/tests/app/organization/test_invite_rest.py b/tests/app/organization/test_invite_rest.py index 3b3c2387d..19ce7ccd6 100644 --- a/tests/app/organization/test_invite_rest.py +++ b/tests/app/organization/test_invite_rest.py @@ -4,7 +4,9 @@ import uuid import pytest from flask import current_app, json from freezegun import freeze_time +from sqlalchemy import select +from app import db from app.enums import InvitedUserStatus from app.models import Notification from notifications_utils.url_safe_token import generate_token @@ -62,7 +64,7 @@ def test_create_invited_org_user( assert json_resp["data"]["status"] == InvitedUserStatus.PENDING assert json_resp["data"]["id"] - notification = Notification.query.first() + notification = db.session.execute(select(Notification)).scalars().first() assert notification.reply_to_text == sample_user.email_address @@ -73,7 +75,7 @@ def test_create_invited_org_user( # assert len(notification.personalisation["url"]) > len(expected_start_of_invite_url) mocked.assert_called_once_with( - [(str(notification.id))], queue="notify-internal-tasks" + [(str(notification.id))], queue="notify-internal-tasks", countdown=60 ) diff --git a/tests/app/organization/test_rest.py b/tests/app/organization/test_rest.py index a9d7db135..445a47297 100644 --- a/tests/app/organization/test_rest.py +++ b/tests/app/organization/test_rest.py @@ -4,8 +4,10 @@ from unittest.mock import Mock import pytest from flask import current_app from freezegun import freeze_time +from sqlalchemy import select from sqlalchemy.exc import SQLAlchemyError +from app import db from app.dao.organization_dao import ( dao_add_service_to_organization, dao_add_user_to_organization, @@ -175,7 +177,7 @@ def test_post_create_organization(admin_request, notify_db_session): "organization.create_organization", _data=data, _expected_status=201 ) - organizations = Organization.query.all() + organizations = _get_organizations() assert data["name"] == response["name"] assert data["active"] == response["active"] @@ -186,6 +188,11 @@ def test_post_create_organization(admin_request, notify_db_session): assert organizations[0].email_branding_id is None +def _get_organizations(): + stmt = select(Organization) + return db.session.execute(stmt).scalars().all() + + @pytest.mark.parametrize("org_type", ["nhs_central", "nhs_local", "nhs_gp"]) @pytest.mark.skip(reason="Update for TTS") def test_post_create_organization_sets_default_nhs_branding_for_nhs_orgs( @@ -201,7 +208,7 @@ def test_post_create_organization_sets_default_nhs_branding_for_nhs_orgs( "organization.create_organization", _data=data, _expected_status=201 ) - organizations = Organization.query.all() + organizations = _get_organizations() assert len(organizations) == 1 assert organizations[0].email_branding_id == uuid.UUID( @@ -212,7 +219,7 @@ def test_post_create_organization_sets_default_nhs_branding_for_nhs_orgs( def test_post_create_organization_existing_name_raises_400( admin_request, sample_organization ): - organization = Organization.query.all() + organization = _get_organizations() assert len(organization) == 1 data = { @@ -225,14 +232,14 @@ def test_post_create_organization_existing_name_raises_400( "organization.create_organization", _data=data, _expected_status=400 ) - organization = Organization.query.all() + organization = _get_organizations() assert len(organization) == 1 assert response["message"] == "Organization name already exists" def test_post_create_organization_works(admin_request, sample_organization): - organization = Organization.query.all() + organization = _get_organizations() assert len(organization) == 1 data = { @@ -245,7 +252,7 @@ def test_post_create_organization_works(admin_request, sample_organization): "organization.create_organization", _data=data, _expected_status=201 ) - organization = Organization.query.all() + organization = _get_organizations() assert len(organization) == 2 @@ -310,7 +317,7 @@ def test_post_update_organization_updates_fields( _expected_status=204, ) - organization = Organization.query.all() + organization = _get_organizations() assert len(organization) == 1 assert organization[0].id == org.id @@ -343,7 +350,7 @@ def test_post_update_organization_updates_domains( _expected_status=204, ) - organization = Organization.query.all() + organization = _get_organizations() assert len(organization) == 1 assert [domain.domain for domain in organization[0].domains] == domain_list @@ -383,7 +390,7 @@ def test_post_update_organization_to_nhs_type_updates_branding_if_none_present( _expected_status=204, ) - organization = Organization.query.all() + organization = _get_organizations() assert len(organization) == 1 assert organization[0].id == org.id @@ -413,7 +420,7 @@ def test_post_update_organization_to_nhs_type_does_not_update_branding_if_defaul _expected_status=204, ) - organization = Organization.query.all() + organization = _get_organizations() assert len(organization) == 1 assert organization[0].id == org.id @@ -471,7 +478,7 @@ def test_post_update_organization_gives_404_status_if_org_does_not_exist( _expected_status=404, ) - organization = Organization.query.all() + organization = _get_organizations() assert not organization @@ -592,7 +599,7 @@ def test_post_link_service_to_organization_inserts_annual_billing( data = {"service_id": str(sample_service.id)} organization = create_organization(organization_type=OrganizationType.FEDERAL) assert len(organization.services) == 0 - assert len(AnnualBilling.query.all()) == 0 + assert len(db.session.execute(select(AnnualBilling)).scalars().all()) == 0 admin_request.post( "organization.link_service_to_organization", _data=data, @@ -600,7 +607,7 @@ def test_post_link_service_to_organization_inserts_annual_billing( _expected_status=204, ) - annual_billing = AnnualBilling.query.all() + annual_billing = db.session.execute(select(AnnualBilling)).scalars().all() assert len(annual_billing) == 1 assert annual_billing[0].free_sms_fragment_limit == 150000 @@ -617,7 +624,7 @@ def test_post_link_service_to_organization_rollback_service_if_annual_billing_up organization = create_organization(organization_type=OrganizationType.FEDERAL) assert len(organization.services) == 0 - assert len(AnnualBilling.query.all()) == 0 + assert len(db.session.execute(select(AnnualBilling)).scalars().all()) == 0 with pytest.raises(expected_exception=SQLAlchemyError): admin_request.post( "organization.link_service_to_organization", @@ -626,7 +633,7 @@ def test_post_link_service_to_organization_rollback_service_if_annual_billing_up ) assert not sample_service.organization_type assert len(organization.services) == 0 - assert len(AnnualBilling.query.all()) == 0 + assert len(db.session.execute(select(AnnualBilling)).scalars().all()) == 0 @freeze_time("2021-09-24 13:30") @@ -656,7 +663,7 @@ def test_post_link_service_to_another_org( assert not sample_organization.services assert len(new_org.services) == 1 assert sample_service.organization_type == OrganizationType.FEDERAL - annual_billing = AnnualBilling.query.all() + annual_billing = db.session.execute(select(AnnualBilling)).scalars().all() assert len(annual_billing) == 1 assert annual_billing[0].free_sms_fragment_limit == 150000 diff --git a/tests/app/provider_details/test_rest.py b/tests/app/provider_details/test_rest.py index a5780fcb6..0d64bf297 100644 --- a/tests/app/provider_details/test_rest.py +++ b/tests/app/provider_details/test_rest.py @@ -1,7 +1,9 @@ import pytest from flask import json from freezegun import freeze_time +from sqlalchemy import select +from app import db from app.models import ProviderDetails, ProviderDetailsHistory from tests import create_admin_authorization_header from tests.app.db import create_ft_billing @@ -53,7 +55,7 @@ def test_get_provider_contains_correct_fields(client, sample_template): def test_should_be_able_to_update_status(client, restore_provider_details): - provider = ProviderDetails.query.first() + provider = db.session.execute(select(ProviderDetails)).scalars().first() update_resp_1 = client.post( "/provider-details/{}".format(provider.id), @@ -76,7 +78,7 @@ def test_should_be_able_to_update_status(client, restore_provider_details): def test_should_not_be_able_to_update_disallowed_fields( client, restore_provider_details, field, value ): - provider = ProviderDetails.query.first() + provider = db.session.execute(select(ProviderDetails)).scalars().first() resp = client.post( "/provider-details/{}".format(provider.id), @@ -94,7 +96,7 @@ def test_should_not_be_able_to_update_disallowed_fields( def test_get_provider_versions_contains_correct_fields(client, notify_db_session): - provider = ProviderDetailsHistory.query.first() + provider = db.session.execute(select(ProviderDetailsHistory)).scalars().first() response = client.get( "/provider-details/{}/versions".format(provider.id), headers=[create_admin_authorization_header()], @@ -117,7 +119,7 @@ def test_get_provider_versions_contains_correct_fields(client, notify_db_session def test_update_provider_should_store_user_id( client, restore_provider_details, sample_user ): - provider = ProviderDetails.query.first() + provider = db.session.execute(select(ProviderDetails)).scalars().first() update_resp_1 = client.post( "/provider-details/{}".format(provider.id), diff --git a/tests/app/service/send_notification/test_send_notification.py b/tests/app/service/send_notification/test_send_notification.py index dcd6cc8e7..14802b56e 100644 --- a/tests/app/service/send_notification/test_send_notification.py +++ b/tests/app/service/send_notification/test_send_notification.py @@ -5,14 +5,16 @@ import pytest from flask import current_app, json from freezegun import freeze_time from notifications_python_client.authentication import create_jwt_token +from sqlalchemy import func, select import app +from app import db from app.dao import notifications_dao from app.dao.api_key_dao import save_model_api_key from app.dao.services_dao import dao_update_service from app.dao.templates_dao import dao_get_all_templates_for_service, dao_update_template from app.enums import KeyType, NotificationType, TemplateType -from app.errors import InvalidRequest, RateLimitError +from app.errors import InvalidRequest from app.models import ApiKey, Notification, NotificationHistory, Template from app.service.send_notification import send_one_off_notification from notifications_utils import SMS_CHAR_COUNT_LIMIT @@ -148,7 +150,9 @@ def test_send_notification_with_placeholders_replaced( {"template_version": sample_email_template_with_placeholders.version} ) - mocked.assert_called_once_with([notification_id], queue="send-email-tasks") + mocked.assert_called_once_with( + [notification_id], queue="send-email-tasks", countdown=60 + ) assert response.status_code == 201 assert response_data["body"] == "Hello Jo\nThis is an email from GOV.UK" assert response_data["subject"] == "Jo" @@ -418,7 +422,9 @@ def test_should_allow_valid_sms_notification(notify_api, sample_template, mocker response_data = json.loads(response.data)["data"] notification_id = response_data["notification"]["id"] - mocked.assert_called_once_with([notification_id], queue="send-sms-tasks") + mocked.assert_called_once_with( + [notification_id], queue="send-sms-tasks", countdown=60 + ) assert response.status_code == 201 assert notification_id assert "subject" not in response_data @@ -474,7 +480,7 @@ def test_should_allow_valid_email_notification( response_data = json.loads(response.get_data(as_text=True))["data"] notification_id = response_data["notification"]["id"] app.celery.provider_tasks.deliver_email.apply_async.assert_called_once_with( - [notification_id], queue="send-email-tasks" + [notification_id], queue="send-email-tasks", countdown=60 ) assert response.status_code == 201 @@ -618,7 +624,7 @@ def test_should_send_email_if_team_api_key_and_a_service_user( ) app.celery.provider_tasks.deliver_email.apply_async.assert_called_once_with( - [fake_uuid], queue="send-email-tasks" + [fake_uuid], queue="send-email-tasks", countdown=60 ) assert response.status_code == 201 @@ -656,7 +662,7 @@ def test_should_send_sms_to_anyone_with_test_key( ], ) app.celery.provider_tasks.deliver_sms.apply_async.assert_called_once_with( - [fake_uuid], queue="send-sms-tasks" + [fake_uuid], queue="send-sms-tasks", countdown=60 ) assert response.status_code == 201 @@ -695,7 +701,7 @@ def test_should_send_email_to_anyone_with_test_key( ) app.celery.provider_tasks.deliver_email.apply_async.assert_called_once_with( - [fake_uuid], queue="send-email-tasks" + [fake_uuid], queue="send-email-tasks", countdown=60 ) assert response.status_code == 201 @@ -733,7 +739,7 @@ def test_should_send_sms_if_team_api_key_and_a_service_user( ) app.celery.provider_tasks.deliver_sms.apply_async.assert_called_once_with( - [fake_uuid], queue="send-sms-tasks" + [fake_uuid], queue="send-sms-tasks", countdown=60 ) assert response.status_code == 201 @@ -790,7 +796,7 @@ def test_should_persist_notification( ], ) - mocked.assert_called_once_with([fake_uuid], queue=queue_name) + mocked.assert_called_once_with([fake_uuid], queue=queue_name, countdown=60) assert response.status_code == 201 notification = notifications_dao.get_notification_by_id(fake_uuid) @@ -851,9 +857,9 @@ def test_should_delete_notification_and_return_error_if_redis_fails( ) assert str(e.value) == "failed to talk to redis" - mocked.assert_called_once_with([fake_uuid], queue=queue_name) + mocked.assert_called_once_with([fake_uuid], queue=queue_name, countdown=60) assert not notifications_dao.get_notification_by_id(fake_uuid) - assert not NotificationHistory.query.get(fake_uuid) + assert not db.session.get(NotificationHistory, fake_uuid) @pytest.mark.parametrize( @@ -883,7 +889,9 @@ def test_should_not_persist_notification_or_send_email_if_simulated_email( assert response.status_code == 201 apply_async.assert_not_called() - assert Notification.query.count() == 0 + stmt = select(func.count()).select_from(Notification) + count = db.session.execute(stmt).scalar() or 0 + assert count == 0 @pytest.mark.parametrize("to_sms", ["+14254147755", "+14254147167"]) @@ -906,7 +914,10 @@ def test_should_not_persist_notification_or_send_sms_if_simulated_number( assert response.status_code == 201 apply_async.assert_not_called() - assert Notification.query.count() == 0 + + stmt = select(func.count()).select_from(Notification) + count = db.session.execute(stmt).scalar() or 0 + assert count == 0 @pytest.mark.parametrize("key_type", [KeyType.NORMAL, KeyType.TEAM]) @@ -1058,7 +1069,7 @@ def test_should_error_if_notification_type_does_not_match_template_type( def test_create_template_raises_invalid_request_exception_with_missing_personalisation( sample_template_with_placeholders, ): - template = Template.query.get(sample_template_with_placeholders.id) + template = db.session.get(Template, sample_template_with_placeholders.id) from app.notifications.rest import create_template_object_for_notification with pytest.raises(InvalidRequest) as e: @@ -1071,7 +1082,7 @@ def test_create_template_doesnt_raise_with_too_much_personalisation( ): from app.notifications.rest import create_template_object_for_notification - template = Template.query.get(sample_template_with_placeholders.id) + template = db.session.get(Template, sample_template_with_placeholders.id) create_template_object_for_notification(template, {"name": "Jo", "extra": "stuff"}) @@ -1088,7 +1099,7 @@ def test_create_template_raises_invalid_request_when_content_too_large( sample = create_template( sample_service, template_type=template_type, content="((long_text))" ) - template = Template.query.get(sample.id) + template = db.session.get(Template, sample.id) from app.notifications.rest import create_template_object_for_notification try: @@ -1113,51 +1124,6 @@ def test_create_template_raises_invalid_request_when_content_too_large( } -@pytest.mark.parametrize( - "notification_type, send_to", - [ - (NotificationType.SMS, "2028675309"), - ( - NotificationType.EMAIL, - "sample@email.com", - ), - ], -) -def test_returns_a_429_limit_exceeded_if_rate_limit_exceeded( - client, sample_service, mocker, notification_type, send_to -): - sample = create_template(sample_service, template_type=notification_type) - persist_mock = mocker.patch("app.notifications.rest.persist_notification") - deliver_mock = mocker.patch("app.notifications.rest.send_notification_to_queue") - - mocker.patch( - "app.notifications.rest.check_rate_limiting", - side_effect=RateLimitError("LIMIT", "INTERVAL", "TYPE"), - ) - - data = {"to": send_to, "template": str(sample.id)} - - auth_header = create_service_authorization_header(service_id=sample.service_id) - - response = client.post( - path=f"/notifications/{notification_type}", - data=json.dumps(data), - headers=[("Content-Type", "application/json"), auth_header], - ) - - message = json.loads(response.data)["message"] - result = json.loads(response.data)["result"] - assert response.status_code == 429 - assert result == "error" - assert message == ( - "Exceeded rate limit for key type TYPE of LIMIT " - "requests per INTERVAL seconds" - ) - - assert not persist_mock.called - assert not deliver_mock.called - - def test_should_allow_store_original_number_on_sms_notification( client, sample_template, mocker ): @@ -1178,10 +1144,12 @@ def test_should_allow_store_original_number_on_sms_notification( response_data = json.loads(response.data)["data"] notification_id = response_data["notification"]["id"] - mocked.assert_called_once_with([notification_id], queue="send-sms-tasks") + mocked.assert_called_once_with( + [notification_id], queue="send-sms-tasks", countdown=60 + ) assert response.status_code == 201 assert notification_id - notifications = Notification.query.all() + notifications = db.session.execute(select(Notification)).scalars().all() assert len(notifications) == 1 assert "1" == notifications[0].to @@ -1342,7 +1310,7 @@ def test_post_notification_should_set_reply_to_text( ], ) assert response.status_code == 201 - notifications = Notification.query.all() + notifications = db.session.execute(select(Notification)).scalars().all() assert len(notifications) == 1 assert notifications[0].reply_to_text == expected_reply_to @@ -1370,5 +1338,5 @@ def test_send_notification_should_set_client_reference_from_placeholder( notification_id = send_one_off_notification(sample_letter_template.service_id, data) assert deliver_mock.called - notification = Notification.query.get(notification_id["id"]) + notification = db.session.get(Notification, notification_id["id"]) assert notification.client_reference == reference_paceholder diff --git a/tests/app/service/send_notification/test_send_one_off_notification.py b/tests/app/service/send_notification/test_send_one_off_notification.py index 78ab0977e..92d329b06 100644 --- a/tests/app/service/send_notification/test_send_one_off_notification.py +++ b/tests/app/service/send_notification/test_send_one_off_notification.py @@ -3,6 +3,7 @@ from unittest.mock import Mock import pytest +from app import db from app.dao.service_guest_list_dao import dao_add_and_commit_guest_list_contacts from app.enums import ( KeyType, @@ -266,7 +267,7 @@ def test_send_one_off_notification_should_add_email_reply_to_text_for_notificati notification_id = send_one_off_notification( service_id=sample_email_template.service.id, post_data=data ) - notification = Notification.query.get(notification_id["id"]) + notification = db.session.get(Notification, notification_id["id"]) celery_mock.assert_called_once_with(notification=notification, queue=None) assert notification.reply_to_text == reply_to_email.email_address @@ -289,7 +290,7 @@ def test_send_one_off_sms_notification_should_use_sms_sender_reply_to_text( notification_id = send_one_off_notification( service_id=sample_service.id, post_data=data ) - notification = Notification.query.get(notification_id["id"]) + notification = db.session.get(Notification, notification_id["id"]) celery_mock.assert_called_once_with(notification=notification, queue=None) assert notification.reply_to_text == "+12028675309" @@ -313,7 +314,7 @@ def test_send_one_off_sms_notification_should_use_default_service_reply_to_text( notification_id = send_one_off_notification( service_id=sample_service.id, post_data=data ) - notification = Notification.query.get(notification_id["id"]) + notification = db.session.get(Notification, notification_id["id"]) celery_mock.assert_called_once_with(notification=notification, queue=None) assert notification.reply_to_text == "+12028675309" diff --git a/tests/app/service/test_api_key_endpoints.py b/tests/app/service/test_api_key_endpoints.py index 8ca0e374d..091910224 100644 --- a/tests/app/service/test_api_key_endpoints.py +++ b/tests/app/service/test_api_key_endpoints.py @@ -1,7 +1,9 @@ import json from flask import url_for +from sqlalchemy import func, select +from app import db from app.dao.api_key_dao import expire_api_key from app.enums import KeyType from app.models import ApiKey @@ -25,7 +27,13 @@ def test_api_key_should_create_new_api_key_for_service(notify_api, sample_servic ) assert response.status_code == 201 assert "data" in json.loads(response.get_data(as_text=True)) - saved_api_key = ApiKey.query.filter_by(service_id=sample_service.id).first() + saved_api_key = ( + db.session.execute( + select(ApiKey).where(ApiKey.service_id == sample_service.id) + ) + .scalars() + .first() + ) assert saved_api_key.service_id == sample_service.id assert saved_api_key.name == "some secret name" @@ -60,10 +68,15 @@ def test_create_api_key_without_key_type_rejects(client, sample_service): assert json_resp["message"] == {"key_type": ["Missing data for required field."]} +def _get_api_key_count(): + stmt = select(func.count()).select_from(ApiKey) + return db.session.execute(stmt).scalar() or 0 + + def test_revoke_should_expire_api_key_for_service(notify_api, sample_api_key): with notify_api.test_request_context(): with notify_api.test_client() as client: - assert ApiKey.query.count() == 1 + assert _get_api_key_count() == 1 auth_header = create_admin_authorization_header() response = client.post( url_for( @@ -74,7 +87,7 @@ def test_revoke_should_expire_api_key_for_service(notify_api, sample_api_key): headers=[auth_header], ) assert response.status_code == 202 - api_keys_for_service = ApiKey.query.get(sample_api_key.id) + api_keys_for_service = db.session.get(ApiKey, sample_api_key.id) assert api_keys_for_service.expiry_date is not None @@ -83,7 +96,7 @@ def test_api_key_should_create_multiple_new_api_key_for_service( ): with notify_api.test_request_context(): with notify_api.test_client() as client: - assert ApiKey.query.count() == 0 + assert _get_api_key_count() == 0 data = { "name": "some secret name", "created_by": str(sample_service.created_by.id), @@ -96,7 +109,7 @@ def test_api_key_should_create_multiple_new_api_key_for_service( headers=[("Content-Type", "application/json"), auth_header], ) assert response.status_code == 201 - assert ApiKey.query.count() == 1 + assert _get_api_key_count() == 1 data["name"] = "another secret name" auth_header = create_admin_authorization_header() @@ -109,7 +122,7 @@ def test_api_key_should_create_multiple_new_api_key_for_service( assert json.loads(response.get_data(as_text=True)) != json.loads( response2.get_data(as_text=True) ) - assert ApiKey.query.count() == 2 + assert _get_api_key_count() == 2 def test_get_api_keys_should_return_all_keys_for_service(notify_api, sample_api_key): @@ -130,7 +143,7 @@ def test_get_api_keys_should_return_all_keys_for_service(notify_api, sample_api_ service_id=one_to_expire.service_id, api_key_id=one_to_expire.id ) - assert ApiKey.query.count() == 4 + assert _get_api_key_count() == 4 auth_header = create_admin_authorization_header() response = client.get( diff --git a/tests/app/service/test_archived_service.py b/tests/app/service/test_archived_service.py index 9853ee1f5..2e32a1982 100644 --- a/tests/app/service/test_archived_service.py +++ b/tests/app/service/test_archived_service.py @@ -3,6 +3,7 @@ from datetime import datetime import pytest from freezegun import freeze_time +from sqlalchemy import select from app import db from app.dao.api_key_dao import expire_api_key @@ -85,8 +86,12 @@ def test_deactivating_service_archives_templates(archived_service): def test_deactivating_service_creates_history(archived_service): ServiceHistory = Service.get_history_model() history = ( - ServiceHistory.query.filter_by(id=archived_service.id) - .order_by(ServiceHistory.version.desc()) + db.session.execute( + select(ServiceHistory) + .where(ServiceHistory.id == archived_service.id) + .order_by(ServiceHistory.version.desc()) + ) + .scalars() .first() ) diff --git a/tests/app/service/test_callback_rest.py b/tests/app/service/test_callback_rest.py index 28ffe3aff..5cd025d30 100644 --- a/tests/app/service/test_callback_rest.py +++ b/tests/app/service/test_callback_rest.py @@ -1,5 +1,8 @@ import uuid +from sqlalchemy import func, select + +from app import db from app.models import ServiceCallbackApi, ServiceInboundApi from tests.app.db import create_service_callback_api, create_service_inbound_api @@ -101,7 +104,10 @@ def test_delete_service_inbound_api(admin_request, sample_service): ) assert response is None - assert ServiceInboundApi.query.count() == 0 + + stmt = select(func.count()).select_from(ServiceInboundApi) + count = db.session.execute(stmt).scalar() or 0 + assert count == 0 def test_create_service_callback_api(admin_request, sample_service): @@ -207,4 +213,7 @@ def test_delete_service_callback_api(admin_request, sample_service): ) assert response is None - assert ServiceCallbackApi.query.count() == 0 + + stmt = select(func.count()).select_from(ServiceCallbackApi) + count = db.session.execute(stmt).scalar() or 0 + assert count == 0 diff --git a/tests/app/service/test_rest.py b/tests/app/service/test_rest.py index a5b22ddd3..cd2ef8005 100644 --- a/tests/app/service/test_rest.py +++ b/tests/app/service/test_rest.py @@ -6,8 +6,10 @@ from unittest.mock import ANY import pytest from flask import current_app, url_for from freezegun import freeze_time +from sqlalchemy import func, select from sqlalchemy.exc import SQLAlchemyError +from app import db from app.dao.organization_dao import dao_add_service_to_organization from app.dao.service_sms_sender_dao import dao_get_sms_senders_by_service_id from app.dao.service_user_dao import dao_get_service_user @@ -395,7 +397,7 @@ def test_create_service( "name": "created service", "user_id": str(sample_user.id), "message_limit": 1000, - "total_message_limit": 250000, + "total_message_limit": 100000, "restricted": False, "active": False, "email_from": "created.service", @@ -413,7 +415,7 @@ def test_create_service( assert json_resp["data"]["email_from"] == "created.service" assert json_resp["data"]["count_as_live"] is expected_count_as_live - service_db = Service.query.get(json_resp["data"]["id"]) + service_db = db.session.get(Service, json_resp["data"]["id"]) assert service_db.name == "created service" json_resp = admin_request.get( @@ -424,9 +426,8 @@ def test_create_service( assert json_resp["data"]["name"] == "created service" - service_sms_senders = ServiceSmsSender.query.filter_by( - service_id=service_db.id - ).all() + stmt = select(ServiceSmsSender).where(ServiceSmsSender.service_id == service_db.id) + service_sms_senders = db.session.execute(stmt).scalars().all() assert len(service_sms_senders) == 1 assert service_sms_senders[0].sms_sender == current_app.config["FROM_NUMBER"] @@ -467,7 +468,7 @@ def test_create_service_with_domain_sets_organization( "name": "created service", "user_id": str(sample_user.id), "message_limit": 1000, - "total_message_limit": 250000, + "total_message_limit": 100000, "restricted": False, "active": False, "email_from": "created.service", @@ -494,16 +495,17 @@ def test_create_service_should_create_annual_billing_for_service( "name": "created service", "user_id": str(sample_user.id), "message_limit": 1000, - "total_message_limit": 250000, + "total_message_limit": 100000, "restricted": False, "active": False, "email_from": "created.service", "created_by": str(sample_user.id), } - assert len(AnnualBilling.query.all()) == 0 + + assert len(db.session.execute(select(AnnualBilling)).scalars().all()) == 0 admin_request.post("service.create_service", _data=data, _expected_status=201) - annual_billing = AnnualBilling.query.all() + annual_billing = db.session.execute(select(AnnualBilling)).scalars().all() assert len(annual_billing) == 1 @@ -518,19 +520,25 @@ def test_create_service_should_raise_exception_and_not_create_service_if_annual_ "name": "created service", "user_id": str(sample_user.id), "message_limit": 1000, - "total_message_limit": 250000, + "total_message_limit": 100000, "restricted": False, "active": False, "email_from": "created.service", "created_by": str(sample_user.id), } - assert len(AnnualBilling.query.all()) == 0 + assert len(db.session.execute(select(AnnualBilling)).scalars().all()) == 0 with pytest.raises(expected_exception=SQLAlchemyError): admin_request.post("service.create_service", _data=data) - annual_billing = AnnualBilling.query.all() + annual_billing = db.session.execute(select(AnnualBilling)).scalars().all() assert len(annual_billing) == 0 - assert len(Service.query.filter(Service.name == "created service").all()) == 0 + stmt = ( + select(func.count()) + .select_from(Service) + .where(Service.name == "created service") + ) + count = db.session.execute(stmt).scalar() or 0 + assert count == 0 def test_create_service_inherits_branding_from_organization( @@ -549,7 +557,7 @@ def test_create_service_inherits_branding_from_organization( "name": "created service", "user_id": str(sample_user.id), "message_limit": 1000, - "total_message_limit": 250000, + "total_message_limit": 100000, "restricted": False, "active": False, "email_from": "created.service", @@ -568,7 +576,7 @@ def test_should_not_create_service_with_missing_user_id_field(notify_api, fake_u "email_from": "service", "name": "created service", "message_limit": 1000, - "total_message_limit": 250000, + "total_message_limit": 100000, "restricted": False, "active": False, "created_by": str(fake_uuid), @@ -589,7 +597,7 @@ def test_should_error_if_created_by_missing(notify_api, sample_user): "email_from": "service", "name": "created service", "message_limit": 1000, - "total_message_limit": 250000, + "total_message_limit": 100000, "restricted": False, "active": False, "user_id": str(sample_user.id), @@ -615,7 +623,7 @@ def test_should_not_create_service_with_missing_if_user_id_is_not_in_database( "user_id": fake_uuid, "name": "created service", "message_limit": 1000, - "total_message_limit": 250000, + "total_message_limit": 100000, "restricted": False, "active": False, "created_by": str(fake_uuid), @@ -658,7 +666,7 @@ def test_should_not_create_service_with_duplicate_name( "name": sample_service.name, "user_id": str(sample_service.users[0].id), "message_limit": 1000, - "total_message_limit": 250000, + "total_message_limit": 100000, "restricted": False, "active": False, "email_from": "sample.service2", @@ -686,7 +694,7 @@ def test_create_service_should_throw_duplicate_key_constraint_for_existing_email "name": service_name, "user_id": str(first_service.users[0].id), "message_limit": 1000, - "total_message_limit": 250000, + "total_message_limit": 100000, "restricted": False, "active": False, "email_from": "first.service", @@ -933,7 +941,8 @@ def test_update_service_flags_will_remove_service_permissions( assert resp.status_code == 200 assert ServicePermissionType.INTERNATIONAL_SMS not in result["data"]["permissions"] - permissions = ServicePermission.query.filter_by(service_id=service.id).all() + stmt = select(ServicePermission).where(ServicePermission.service_id == service.id) + permissions = db.session.execute(stmt).scalars().all() assert {p.permission for p in permissions} == { ServicePermissionType.SMS, ServicePermissionType.EMAIL, @@ -1004,9 +1013,10 @@ def test_add_service_permission_will_add_permission( headers=[("Content-Type", "application/json"), auth_header], ) - permissions = ServicePermission.query.filter_by( - service_id=service_with_no_permissions.id - ).all() + stmt = select(ServicePermission).where( + ServicePermission.service_id == service_with_no_permissions.id + ) + permissions = db.session.execute(stmt).scalars().all() assert resp.status_code == 200 assert [p.permission for p in permissions] == [permission_to_add] @@ -1210,7 +1220,7 @@ def test_default_permissions_are_added_for_user_service( "name": "created service", "user_id": str(sample_user.id), "message_limit": 1000, - "total_message_limit": 250000, + "total_message_limit": 100000, "restricted": False, "active": False, "email_from": "created.service", @@ -1655,6 +1665,27 @@ def test_remove_user_from_service(client, sample_user_service_permission): assert resp.status_code == 204 +def test_get_service_message_ratio(mocker, client, sample_user_service_permission): + service = sample_user_service_permission.service + + mock_redis = mocker.patch("app.service.rest.redis_store.get") + mock_redis.return_value = 1 + + endpoint = url_for( + "service.get_service_message_ratio", + service_id=str(service.id), + ) + auth_header = create_admin_authorization_header() + + resp = client.get( + endpoint, headers=[("Content-Type", "application/json"), auth_header] + ) + assert resp.status_code == 200 + result = resp.json + assert result["total_message_limit"] == 100000 + assert result["messages_sent"] == 1 + + def test_remove_non_existant_user_from_service(client, sample_user_service_permission): second_user = create_user(email="new@digital.fake.gov") endpoint = url_for( @@ -2191,6 +2222,7 @@ def test_set_sms_prefixing_for_service_cant_be_none( StatisticsType.REQUESTED: 2, StatisticsType.DELIVERED: 1, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, }, ), ( @@ -2199,6 +2231,7 @@ def test_set_sms_prefixing_for_service_cant_be_none( StatisticsType.REQUESTED: 1, StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, }, ), ], @@ -2247,11 +2280,13 @@ def test_get_services_with_detailed_flag(client, sample_template): NotificationType.EMAIL: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 0, }, NotificationType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 3, }, } @@ -2278,11 +2313,13 @@ def test_get_services_with_detailed_flag_excluding_from_test_key( NotificationType.EMAIL: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 0, }, NotificationType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 2, }, } @@ -2354,11 +2391,13 @@ def test_get_detailed_services_groups_by_service(notify_db_session): NotificationType.EMAIL: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 0, }, NotificationType.SMS: { StatisticsType.DELIVERED: 1, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 3, }, } @@ -2367,11 +2406,13 @@ def test_get_detailed_services_groups_by_service(notify_db_session): NotificationType.EMAIL: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 0, }, NotificationType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 1, }, } @@ -2397,11 +2438,13 @@ def test_get_detailed_services_includes_services_with_no_notifications( NotificationType.EMAIL: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 0, }, NotificationType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 1, }, } @@ -2410,11 +2453,13 @@ def test_get_detailed_services_includes_services_with_no_notifications( NotificationType.EMAIL: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 0, }, NotificationType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 0, }, } @@ -2439,11 +2484,13 @@ def test_get_detailed_services_only_includes_todays_notifications(sample_templat NotificationType.EMAIL: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 0, }, NotificationType.SMS: { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 3, }, } @@ -2492,11 +2539,13 @@ def test_get_detailed_services_for_date_range( assert data[0]["statistics"][NotificationType.EMAIL] == { StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 0, } assert data[0]["statistics"][NotificationType.SMS] == { StatisticsType.DELIVERED: 2, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, StatisticsType.REQUESTED: 2, } @@ -2822,7 +2871,7 @@ def test_send_one_off_notification(sample_service, admin_request, mocker): _expected_status=201, ) - noti = Notification.query.one() + noti = db.session.execute(select(Notification)).scalars().one() assert response["id"] == str(noti.id) @@ -3012,11 +3061,11 @@ def test_verify_reply_to_email_address_should_send_verification_email( _expected_status=201, ) - notification = Notification.query.first() + notification = db.session.execute(select(Notification)).scalars().first() assert notification.template_id == verify_reply_to_address_email_template.id assert response["data"] == {"id": str(notification.id)} mocked.assert_called_once_with( - [str(notification.id)], queue="notify-internal-tasks" + [str(notification.id)], queue="notify-internal-tasks", countdown=60 ) assert ( notification.reply_to_text @@ -3051,7 +3100,7 @@ def test_add_service_reply_to_email_address(admin_request, sample_service): _expected_status=201, ) - results = ServiceEmailReplyTo.query.all() + results = db.session.execute(select(ServiceEmailReplyTo)).scalars().all() assert len(results) == 1 assert response["data"] == results[0].serialize() @@ -3091,7 +3140,7 @@ def test_add_service_reply_to_email_address_can_add_multiple_addresses( _data=second, _expected_status=201, ) - results = ServiceEmailReplyTo.query.all() + results = db.session.execute(select(ServiceEmailReplyTo)).scalars().all() assert len(results) == 2 default = [x for x in results if x.is_default] assert response["data"] == default[0].serialize() @@ -3142,7 +3191,7 @@ def test_update_service_reply_to_email_address(admin_request, sample_service): _expected_status=200, ) - results = ServiceEmailReplyTo.query.all() + results = db.session.execute(select(ServiceEmailReplyTo)).scalars().all() assert len(results) == 1 assert response["data"] == results[0].serialize() @@ -3254,7 +3303,7 @@ def test_add_service_sms_sender_can_add_multiple_senders(client, notify_db_sessi resp_json = json.loads(response.get_data(as_text=True)) assert resp_json["sms_sender"] == "second" assert not resp_json["is_default"] - senders = ServiceSmsSender.query.all() + senders = db.session.execute(select(ServiceSmsSender)).scalars().all() assert len(senders) == 2 @@ -3280,7 +3329,7 @@ def test_add_service_sms_sender_when_it_is_an_inbound_number_updates_the_only_ex ], ) assert response.status_code == 201 - updated_number = InboundNumber.query.get(inbound_number.id) + updated_number = db.session.get(InboundNumber, inbound_number.id) assert updated_number.service_id == service.id resp_json = json.loads(response.get_data(as_text=True)) assert resp_json["sms_sender"] == inbound_number.number @@ -3311,15 +3360,20 @@ def test_add_service_sms_sender_when_it_is_an_inbound_number_inserts_new_sms_sen ], ) assert response.status_code == 201 - updated_number = InboundNumber.query.get(inbound_number.id) + updated_number = db.session.get(InboundNumber, inbound_number.id) assert updated_number.service_id == service.id resp_json = json.loads(response.get_data(as_text=True)) assert resp_json["sms_sender"] == inbound_number.number assert resp_json["inbound_number_id"] == str(inbound_number.id) assert resp_json["is_default"] - senders = ServiceSmsSender.query.filter_by(service_id=service.id).all() - assert len(senders) == 3 + stmt = ( + select(func.count()) + .select_from(ServiceSmsSender) + .where(ServiceSmsSender.service_id == service.id) + ) + senders = db.session.execute(stmt).scalar() or 0 + assert senders == 3 def test_add_service_sms_sender_switches_default(client, notify_db_session): @@ -3341,7 +3395,8 @@ def test_add_service_sms_sender_switches_default(client, notify_db_session): assert resp_json["sms_sender"] == "second" assert not resp_json["inbound_number_id"] assert resp_json["is_default"] - sms_senders = ServiceSmsSender.query.filter_by(sms_sender="first").first() + stmt = select(ServiceSmsSender).where(ServiceSmsSender.sms_sender == "first") + sms_senders = db.session.execute(stmt).scalars().first() assert not sms_senders.is_default @@ -3407,7 +3462,8 @@ def test_update_service_sms_sender_switches_default(client, notify_db_session): assert resp_json["sms_sender"] == "second" assert not resp_json["inbound_number_id"] assert resp_json["is_default"] - sms_senders = ServiceSmsSender.query.filter_by(sms_sender="first").first() + stmt = select(ServiceSmsSender).where(ServiceSmsSender.sms_sender == "first") + sms_senders = db.session.execute(stmt).scalars().first() assert not sms_senders.is_default @@ -3674,3 +3730,24 @@ def test_get_service_notification_statistics_by_day( assert mock_get_service_statistics_for_specific_days.assert_called_once assert response == mock_data + + +# def test_valid_request(): +# request = MagicMock() +# request.args = { +# "service_id": "123", +# "name": "Test Name", +# "email_from": "test@example.com", +# } +# result = check_request_args(request) +# assert result == ("123", "Test Name", "test@example.com") + + +# def test_missing_service_id(): +# request = MagicMock() +# request.args = {"name": "Test Name", "email_from": "test@example.com"} +# try: +# check_request_args(request) +# except Exception as e: +# assert e.status_code == 400 +# assert {"service_id": ["Can't be empty"] in e.errors} diff --git a/tests/app/service/test_sender.py b/tests/app/service/test_sender.py index caae265c8..bb1b9baeb 100644 --- a/tests/app/service/test_sender.py +++ b/tests/app/service/test_sender.py @@ -1,6 +1,8 @@ import pytest from flask import current_app +from sqlalchemy import func, select +from app import db from app.dao.services_dao import dao_add_user_to_service from app.enums import NotificationType, TemplateType from app.models import Notification @@ -21,9 +23,11 @@ def test_send_notification_to_service_users_persists_notifications_correctly( service_id=sample_service.id, template_id=template.id ) - notification = Notification.query.one() + notification = db.session.execute(select(Notification)).scalars().one() - assert Notification.query.count() == 1 + stmt = select(func.count()).select_from(Notification) + count = db.session.execute(stmt).scalar() or 0 + assert count == 1 assert notification.to == "1" assert str(notification.service_id) == current_app.config["NOTIFY_SERVICE_ID"] assert notification.template.id == template.id @@ -54,6 +58,7 @@ def test_send_notification_to_service_users_includes_user_fields_in_personalisat ): persist_mock = mocker.patch("app.service.sender.persist_notification") mocker.patch("app.service.sender.send_notification_to_queue") + mocker.patch("app.service.sender.redis_store") user = sample_service.users[0] @@ -78,15 +83,20 @@ def test_send_notification_to_service_users_sends_to_active_users_only( notify_service, mocker ): mocker.patch("app.service.sender.send_notification_to_queue") + mocker.patch("app.service.sender.redis_store", autospec=True) first_active_user = create_user(email="foo@bar.com", state="active") second_active_user = create_user(email="foo1@bar.com", state="active") pending_user = create_user(email="foo2@bar.com", state="pending") service = create_service(user=first_active_user) dao_add_user_to_service(service, second_active_user) + dao_add_user_to_service(service, pending_user) + template = create_template(service, template_type=TemplateType.EMAIL) send_notification_to_service_users(service_id=service.id, template_id=template.id) - assert Notification.query.count() == 2 + stmt = select(func.count()).select_from(Notification) + count = db.session.execute(stmt).scalar() or 0 + assert count == 2 diff --git a/tests/app/service/test_service_data_retention_rest.py b/tests/app/service/test_service_data_retention_rest.py index f0cff358c..f9b82908c 100644 --- a/tests/app/service/test_service_data_retention_rest.py +++ b/tests/app/service/test_service_data_retention_rest.py @@ -1,6 +1,9 @@ import json import uuid +from sqlalchemy import select + +from app import db from app.enums import NotificationType from app.models import ServiceDataRetention from tests import create_admin_authorization_header @@ -106,7 +109,7 @@ def test_create_service_data_retention(client, sample_service): assert response.status_code == 201 json_resp = json.loads(response.get_data(as_text=True))["result"] - results = ServiceDataRetention.query.all() + results = db.session.execute(select(ServiceDataRetention)).scalars().all() assert len(results) == 1 data_retention = results[0] assert json_resp == data_retention.serialize() diff --git a/tests/app/service/test_service_guest_list.py b/tests/app/service/test_service_guest_list.py index 5d86a06c2..9b30d64b1 100644 --- a/tests/app/service/test_service_guest_list.py +++ b/tests/app/service/test_service_guest_list.py @@ -1,6 +1,9 @@ import json import uuid +from sqlalchemy import select + +from app import db from app.dao.service_guest_list_dao import dao_add_and_commit_guest_list_contacts from app.enums import RecipientType from app.models import ServiceGuestList @@ -87,7 +90,13 @@ def test_update_guest_list_replaces_old_guest_list(client, sample_service_guest_ ) assert response.status_code == 204 - guest_list = ServiceGuestList.query.order_by(ServiceGuestList.recipient).all() + guest_list = ( + db.session.execute( + select(ServiceGuestList).order_by(ServiceGuestList.recipient) + ) + .scalars() + .all() + ) assert len(guest_list) == 2 assert guest_list[0].recipient == "+12028765309" assert guest_list[1].recipient == "foo@bar.com" @@ -112,5 +121,5 @@ def test_update_guest_list_doesnt_remove_old_guest_list_if_error( "result": "error", "message": 'Invalid guest list: "" is not a valid email address or phone number', } - guest_list = ServiceGuestList.query.one() + guest_list = db.session.execute(select(ServiceGuestList)).scalars().one() assert guest_list.id == sample_service_guest_list.id diff --git a/tests/app/service/test_statistics.py b/tests/app/service/test_statistics.py index b3534fed3..a16625361 100644 --- a/tests/app/service/test_statistics.py +++ b/tests/app/service/test_statistics.py @@ -9,6 +9,7 @@ from freezegun import freeze_time from app.enums import KeyType, NotificationStatus, NotificationType, StatisticsType from app.service.statistics import ( add_monthly_notification_status_stats, + calculate_pending_stats, create_empty_monthly_notification_status_stats_dict, create_stats_dict, create_zeroed_stats_dicts, @@ -27,22 +28,22 @@ NewStatsRow = collections.namedtuple( @pytest.mark.idparametrize( "stats, email_counts, sms_counts", { - "empty": ([], [0, 0, 0], [0, 0, 0]), + "empty": ([], [0, 0, 0, 0], [0, 0, 0, 0]), "always_increment_requested": ( [ StatsRow(NotificationType.EMAIL, NotificationStatus.DELIVERED, 1), StatsRow(NotificationType.EMAIL, NotificationStatus.FAILED, 1), ], - [2, 1, 1], - [0, 0, 0], + [2, 1, 1, 0], + [0, 0, 0, 0], ), "dont_mix_template_types": ( [ StatsRow(NotificationType.EMAIL, NotificationStatus.DELIVERED, 1), StatsRow(NotificationType.SMS, NotificationStatus.DELIVERED, 1), ], - [1, 1, 0], - [1, 1, 0], + [1, 1, 0, 0], + [1, 1, 0, 0], ), "convert_fail_statuses_to_failed": ( [ @@ -57,8 +58,8 @@ NewStatsRow = collections.namedtuple( NotificationType.EMAIL, NotificationStatus.PERMANENT_FAILURE, 1 ), ], - [4, 0, 4], - [0, 0, 0], + [4, 0, 4, 0], + [0, 0, 0, 0], ), "convert_sent_to_delivered": ( [ @@ -66,16 +67,16 @@ NewStatsRow = collections.namedtuple( StatsRow(NotificationType.SMS, NotificationStatus.DELIVERED, 1), StatsRow(NotificationType.SMS, NotificationStatus.SENT, 1), ], - [0, 0, 0], - [3, 2, 0], + [0, 0, 0, 0], + [3, 2, 0, 0], ), "handles_none_rows": ( [ StatsRow(NotificationType.SMS, NotificationStatus.SENDING, 1), StatsRow(None, None, None), ], - [0, 0, 0], - [1, 0, 0], + [0, 0, 0, 0], + [1, 0, 0, 0], ), }, ) @@ -89,6 +90,7 @@ def test_format_statistics(stats, email_counts, sms_counts): StatisticsType.REQUESTED, StatisticsType.DELIVERED, StatisticsType.FAILURE, + StatisticsType.PENDING, ], email_counts, ) @@ -101,23 +103,58 @@ def test_format_statistics(stats, email_counts, sms_counts): StatisticsType.REQUESTED, StatisticsType.DELIVERED, StatisticsType.FAILURE, + StatisticsType.PENDING, ], sms_counts, ) } +def test_format_statistics_with_pending(): + stats = [ + StatsRow(NotificationType.SMS, NotificationStatus.DELIVERED, 10), + StatsRow(NotificationType.SMS, NotificationStatus.FAILED, 2), + ] + + total_notifications_for_sms = 20 + + result = format_statistics(stats, total_notifications=total_notifications_for_sms) + + expected_sms_counts = { + StatisticsType.REQUESTED: 12, + StatisticsType.DELIVERED: 10, + StatisticsType.FAILURE: 2, + StatisticsType.PENDING: 8, + } + + assert result[NotificationType.SMS] == expected_sms_counts + + +@pytest.mark.parametrize( + "delivered, failed, total, expected", + [ + (10, 2, 20, 8), + (10, 10, 20, 0), + (15, 10, 20, 0), + ], +) +def test_calculate_pending(delivered, failed, total, expected): + assert calculate_pending_stats(delivered, failed, total) == expected + + def test_create_zeroed_stats_dicts(): assert create_zeroed_stats_dicts() == { NotificationType.SMS: { StatisticsType.REQUESTED: 0, StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, }, NotificationType.EMAIL: { StatisticsType.REQUESTED: 0, StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, }, } diff --git a/tests/app/service/test_statistics_rest.py b/tests/app/service/test_statistics_rest.py index 6d20cacc3..254736bc9 100644 --- a/tests/app/service/test_statistics_rest.py +++ b/tests/app/service/test_statistics_rest.py @@ -119,6 +119,7 @@ def test_get_template_usage_by_month_returns_two_templates( StatisticsType.REQUESTED: 2, StatisticsType.DELIVERED: 1, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, }, ), ( @@ -127,6 +128,7 @@ def test_get_template_usage_by_month_returns_two_templates( StatisticsType.REQUESTED: 1, StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, }, ), ], @@ -163,11 +165,13 @@ def test_get_service_notification_statistics_with_unknown_service(admin_request) StatisticsType.REQUESTED: 0, StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, }, NotificationType.EMAIL: { StatisticsType.REQUESTED: 0, StatisticsType.DELIVERED: 0, StatisticsType.FAILURE: 0, + StatisticsType.PENDING: 0, }, } diff --git a/tests/app/service/test_suspend_resume_service.py b/tests/app/service/test_suspend_resume_service.py index a5b87f6fb..a59345f9b 100644 --- a/tests/app/service/test_suspend_resume_service.py +++ b/tests/app/service/test_suspend_resume_service.py @@ -3,7 +3,9 @@ from datetime import datetime import pytest from freezegun import freeze_time +from sqlalchemy import select +from app import db from app.models import Service from tests import create_admin_authorization_header @@ -77,8 +79,12 @@ def test_service_history_is_created(client, sample_service, action, original_sta ) ServiceHistory = Service.get_history_model() history = ( - ServiceHistory.query.filter_by(id=sample_service.id) - .order_by(ServiceHistory.version.desc()) + db.session.execute( + select(ServiceHistory) + .where(ServiceHistory.id == sample_service.id) + .order_by(ServiceHistory.version.desc()) + ) + .scalars() .first() ) diff --git a/tests/app/service_invite/test_service_invite_rest.py b/tests/app/service_invite/test_service_invite_rest.py index 61b8b79e7..c980c87a1 100644 --- a/tests/app/service_invite/test_service_invite_rest.py +++ b/tests/app/service_invite/test_service_invite_rest.py @@ -5,7 +5,9 @@ from functools import partial import pytest from flask import current_app from freezegun import freeze_time +from sqlalchemy import select +from app import db from app.enums import AuthType, InvitedUserStatus from app.models import Notification from notifications_utils.url_safe_token import generate_token @@ -72,7 +74,7 @@ def test_create_invited_user( "folder_3", ] - notification = Notification.query.first() + notification = db.session.execute(select(Notification)).scalars().first() assert notification.reply_to_text == invite_from.email_address @@ -90,7 +92,7 @@ def test_create_invited_user( ) mocked.assert_called_once_with( - [(str(notification.id))], queue="notify-internal-tasks" + [(str(notification.id))], queue="notify-internal-tasks", countdown=60 ) diff --git a/tests/app/template/test_rest.py b/tests/app/template/test_rest.py index 45dfc24f9..349230696 100644 --- a/tests/app/template/test_rest.py +++ b/tests/app/template/test_rest.py @@ -6,7 +6,9 @@ from datetime import datetime, timedelta import pytest from freezegun import freeze_time +from sqlalchemy import select +from app import db from app.dao.templates_dao import dao_get_template_by_id, dao_redact_template from app.enums import ServicePermissionType, TemplateProcessType, TemplateType from app.models import Template, TemplateHistory @@ -58,7 +60,7 @@ def test_should_create_a_new_template_for_a_service( else: assert not json_resp["data"]["subject"] - template = Template.query.get(json_resp["data"]["id"]) + template = db.session.get(Template, json_resp["data"]["id"]) from app.schemas import template_schema assert sorted(json_resp["data"]) == sorted(template_schema.dump(template)) @@ -86,7 +88,8 @@ def test_create_a_new_template_for_a_service_adds_folder_relationship( data=data, ) assert response.status_code == 201 - template = Template.query.filter(Template.name == "my template").first() + stmt = select(Template).where(Template.name == "my template") + template = db.session.execute(stmt).scalars().first() assert template.folder == parent_folder @@ -349,7 +352,8 @@ def test_update_should_update_a_template(client, sample_user): assert update_json_resp["data"]["created_by"] == str(sample_user.id) template_created_by_users = [ - template.created_by_id for template in TemplateHistory.query.all() + template.created_by_id + for template in db.session.execute(select(TemplateHistory)).scalars().all() ] assert len(template_created_by_users) == 2 assert service.created_by.id in template_created_by_users @@ -377,7 +381,7 @@ def test_should_be_able_to_archive_template(client, sample_template): ) assert resp.status_code == 200 - assert Template.query.first().archived + assert db.session.execute(select(Template)).scalars().first().archived def test_should_be_able_to_archive_template_should_remove_template_folders( @@ -399,7 +403,7 @@ def test_should_be_able_to_archive_template_should_remove_template_folders( data=json.dumps(data), ) - updated_template = Template.query.get(template.id) + updated_template = db.session.get(Template, template.id) assert updated_template.archived assert not updated_template.folder diff --git a/tests/app/template_folder/test_template_folder_rest.py b/tests/app/template_folder/test_template_folder_rest.py index 6461ad3df..64a232192 100644 --- a/tests/app/template_folder/test_template_folder_rest.py +++ b/tests/app/template_folder/test_template_folder_rest.py @@ -1,7 +1,9 @@ import uuid import pytest +from sqlalchemy import func, select +from app import db from app.dao.service_user_dao import dao_get_service_user from app.models import TemplateFolder from tests.app.db import ( @@ -268,7 +270,7 @@ def test_delete_template_folder(admin_request, sample_service): template_folder_id=existing_folder.id, ) - assert TemplateFolder.query.all() == [] + assert db.session.execute(select(TemplateFolder)).scalars().all() == [] def test_delete_template_folder_fails_if_folder_has_subfolders( @@ -286,7 +288,9 @@ def test_delete_template_folder_fails_if_folder_has_subfolders( assert resp == {"result": "error", "message": "Folder is not empty"} - assert TemplateFolder.query.count() == 2 + stmt = select(func.count()).select_from(TemplateFolder) + count = db.session.execute(stmt).scalar() or 0 + assert count == 2 def test_delete_template_folder_fails_if_folder_contains_templates( @@ -304,7 +308,9 @@ def test_delete_template_folder_fails_if_folder_contains_templates( assert resp == {"result": "error", "message": "Folder is not empty"} - assert TemplateFolder.query.count() == 1 + stmt = select(func.count()).select_from(TemplateFolder) + count = db.session.execute(stmt).scalar() or 0 + assert count == 1 @pytest.mark.parametrize( diff --git a/tests/app/test_commands.py b/tests/app/test_commands.py index 690532da9..859e36f34 100644 --- a/tests/app/test_commands.py +++ b/tests/app/test_commands.py @@ -3,7 +3,9 @@ from datetime import datetime, timedelta from unittest.mock import MagicMock, mock_open import pytest +from sqlalchemy import func, select +from app import db from app.commands import ( _update_template, bulk_invite_user_to_service, @@ -54,8 +56,13 @@ from tests.app.db import ( ) +def _get_user_query_count(): + stmt = select(func.count()).select_from(User) + return db.session.execute(stmt).scalar() or 0 + + def test_purge_functional_test_data(notify_db_session, notify_api): - orig_user_count = User.query.count() + orig_user_count = _get_user_query_count() notify_api.test_cli_runner().invoke( create_test_user, @@ -71,16 +78,16 @@ def test_purge_functional_test_data(notify_db_session, notify_api): ], ) - user_count = User.query.count() + user_count = _get_user_query_count() assert user_count == orig_user_count + 1 notify_api.test_cli_runner().invoke(purge_functional_test_data, ["-u", "somebody"]) # if the email address has a uuid, it is test data so it should be purged and there should be # zero users. Otherwise, it is real data so there should be one user. - assert User.query.count() == orig_user_count + assert _get_user_query_count() == orig_user_count def test_purge_functional_test_data_bad_mobile(notify_db_session, notify_api): - user_count = User.query.count() + user_count = _get_user_query_count() assert user_count == 0 # run the command command_response = notify_api.test_cli_runner().invoke( @@ -99,7 +106,7 @@ def test_purge_functional_test_data_bad_mobile(notify_db_session, notify_api): # The bad mobile phone number results in a bad parameter error, # leading to a system exit 2 and no entry made in db assert "SystemExit(2)" in str(command_response) - user_count = User.query.count() + user_count = _get_user_query_count() assert user_count == 0 @@ -115,7 +122,8 @@ def test_update_jobs_archived_flag(notify_db_session, notify_api): right_now = right_now.strftime("%Y-%m-%d") tomorrow = tomorrow.strftime("%Y-%m-%d") - archived_jobs = Job.query.filter(Job.archived is True).count() + stmt = select(Job).where(Job.archived is True) + archived_jobs = db.session.execute(stmt).scalar() or 0 assert archived_jobs == 0 notify_api.test_cli_runner().invoke( @@ -127,14 +135,19 @@ def test_update_jobs_archived_flag(notify_db_session, notify_api): right_now, ], ) - jobs = Job.query.all() + jobs = db.session.execute(select(Job)).scalars().all() assert len(jobs) == 1 for job in jobs: assert job.archived is True +def _get_organization_query_count(): + stmt = select(func.count()).select_from(Organization) + return db.session.execute(stmt).scalar() or 0 + + def test_populate_organizations_from_file(notify_db_session, notify_api): - org_count = Organization.query.count() + org_count = _get_organization_query_count() assert org_count == 0 file_name = "./tests/app/orgs1.csv" @@ -149,7 +162,7 @@ def test_populate_organizations_from_file(notify_db_session, notify_api): os.remove(file_name) print(f"command_response = {command_response}") - org_count = Organization.query.count() + org_count = _get_organization_query_count() assert org_count == 1 @@ -158,13 +171,13 @@ def test_populate_organization_agreement_details_from_file( ): file_name = "./tests/app/orgs.csv" - org_count = Organization.query.count() + org_count = _get_organization_query_count() assert org_count == 0 create_organization() - org_count = Organization.query.count() + org_count = _get_organization_query_count() assert org_count == 1 - org = Organization.query.one() + org = db.session.execute(select(Organization)).scalars().one() org.agreement_signed = True notify_db_session.commit() @@ -180,13 +193,18 @@ def test_populate_organization_agreement_details_from_file( ) print(f"command_response = {command_response}") - org_count = Organization.query.count() + org_count = _get_organization_query_count() assert org_count == 1 - org = Organization.query.one() + org = db.session.execute(select(Organization)).scalars().one() assert org.agreement_signed_on_behalf_of_name == "bob" os.remove(file_name) +def _get_organization_query_one(): + stmt = select(Organization) + return db.session.execute(stmt).scalars().one() + + def test_bulk_invite_user_to_service( notify_db_session, notify_api, sample_service, sample_user ): @@ -221,7 +239,7 @@ def test_bulk_invite_user_to_service( def test_create_test_user_command(notify_db_session, notify_api): # number of users before adding ours - user_count = User.query.count() + user_count = _get_user_query_count() # run the command notify_api.test_cli_runner().invoke( @@ -239,10 +257,11 @@ def test_create_test_user_command(notify_db_session, notify_api): ) # there should be one more user - assert User.query.count() == user_count + 1 + assert _get_user_query_count() == user_count + 1 # that user should be the one we added - user = User.query.filter_by(name="Fake Personson").first() + stmt = select(User).where(User.name == "Fake Personson") + user = db.session.execute(stmt).scalars().first() assert user.email_address == "somebody@fake.gov" assert user.auth_type == AuthType.SMS assert user.state == "active" @@ -281,10 +300,11 @@ def test_populate_annual_billing_with_defaults( populate_annual_billing_with_defaults, ["-y", 2022] ) - results = AnnualBilling.query.filter( + stmt = select(AnnualBilling).where( AnnualBilling.financial_year_start == 2022, AnnualBilling.service_id == service.id, - ).all() + ) + results = db.session.execute(stmt).scalars().all() assert len(results) == 1 assert results[0].free_sms_fragment_limit == expected_allowance @@ -306,10 +326,11 @@ def test_populate_annual_billing_with_the_previous_years_allowance( populate_annual_billing_with_defaults, ["-y", 2022] ) - results = AnnualBilling.query.filter( + stmt = select(AnnualBilling).where( AnnualBilling.financial_year_start == 2022, AnnualBilling.service_id == service.id, - ).all() + ) + results = db.session.execute(stmt).scalars().all() assert len(results) == 1 assert results[0].free_sms_fragment_limit == expected_allowance @@ -318,18 +339,24 @@ def test_populate_annual_billing_with_the_previous_years_allowance( populate_annual_billing_with_the_previous_years_allowance, ["-y", 2023] ) - results = AnnualBilling.query.filter( + stmt = select(AnnualBilling).where( AnnualBilling.financial_year_start == 2023, AnnualBilling.service_id == service.id, - ).all() + ) + results = db.session.execute(stmt).scalars().all() assert len(results) == 1 assert results[0].free_sms_fragment_limit == expected_allowance +def _get_notification_query_one(): + stmt = select(Notification) + return db.session.execute(stmt).scalars().one() + + def test_fix_billable_units(notify_db_session, notify_api, sample_template): create_notification(template=sample_template) - notification = Notification.query.one() + notification = _get_notification_query_one() notification.billable_units = 0 notification.notification_type = NotificationType.SMS notification.status = NotificationStatus.DELIVERED @@ -340,7 +367,7 @@ def test_fix_billable_units(notify_db_session, notify_api, sample_template): notify_api.test_cli_runner().invoke(fix_billable_units, []) - notification = Notification.query.one() + notification = _get_notification_query_one() assert notification.billable_units == 1 @@ -355,10 +382,16 @@ def test_populate_annual_billing_with_defaults_sets_free_allowance_to_zero_if_pr populate_annual_billing_with_defaults, ["-y", 2022] ) - results = AnnualBilling.query.filter( - AnnualBilling.financial_year_start == 2022, - AnnualBilling.service_id == service.id, - ).all() + results = ( + db.session.execute( + select(AnnualBilling).where( + AnnualBilling.financial_year_start == 2022, + AnnualBilling.service_id == service.id, + ) + ) + .scalars() + .all() + ) assert len(results) == 1 assert results[0].free_sms_fragment_limit == 0 @@ -375,7 +408,7 @@ def test_update_template(notify_db_session, email_2fa_code_template): "", ) - t = Template.query.all() + t = db.session.execute(select(Template)).scalars().all() assert t[0].name == "Example text message template!" @@ -395,22 +428,26 @@ def test_create_service_command(notify_db_session, notify_api): ], ) - user = User.query.first() + user = db.session.execute(select(User)).scalars().first() - service_count = Service.query.count() + stmt = select(func.count()).select_from(Service) + service_count = db.session.execute(stmt).scalar() or 0 # run the command - result = notify_api.test_cli_runner().invoke( + notify_api.test_cli_runner().invoke( create_new_service, ["-e", "somebody@fake.gov", "-n", "Fake Service", "-c", user.id], ) - print(result) # there should be one more service - assert Service.query.count() == service_count + 1 + + stmt = select(func.count()).select_from(Service) + count = db.session.execute(stmt).scalar() or 0 + assert count == service_count + 1 # that service should be the one we added - service = Service.query.filter_by(name="Fake Service").first() + stmt = select(Service).where(Service.name == "Fake Service") + service = db.session.execute(stmt).scalars().first() assert service.email_from == "somebody@fake.gov" assert service.restricted is False assert service.message_limit == 40000 diff --git a/tests/app/test_model.py b/tests/app/test_model.py index e74ef06ff..4b6dec10c 100644 --- a/tests/app/test_model.py +++ b/tests/app/test_model.py @@ -1,8 +1,9 @@ import pytest from freezegun import freeze_time +from sqlalchemy import select from sqlalchemy.exc import IntegrityError -from app import encryption +from app import db, encryption from app.enums import ( AgreementStatus, AgreementType, @@ -408,7 +409,7 @@ def test_annual_billing_serialize(): def test_repr(): service = create_service() - sps = ServicePermission.query.all() + sps = db.session.execute(select(ServicePermission)).scalars().all() for sp in sps: assert "has service permission" in sp.__repr__() diff --git a/tests/app/test_schemas.py b/tests/app/test_schemas.py index 270c36a17..b71d2fef8 100644 --- a/tests/app/test_schemas.py +++ b/tests/app/test_schemas.py @@ -2,8 +2,9 @@ import datetime import pytest from marshmallow import ValidationError -from sqlalchemy import desc +from sqlalchemy import desc, select +from app import db from app.dao.provider_details_dao import ( dao_update_provider_details, get_provider_details_by_identifier, @@ -145,13 +146,13 @@ def test_provider_details_history_schema_returns_user_details( dao_update_provider_details(current_sms_provider) - current_sms_provider_in_history = ( - ProviderDetailsHistory.query.filter( - ProviderDetailsHistory.id == current_sms_provider.id - ) + stmt = ( + select(ProviderDetailsHistory) + .where(ProviderDetailsHistory.id == current_sms_provider.id) .order_by(desc(ProviderDetailsHistory.version)) - .first() ) + current_sms_provider_in_history = db.session.execute(stmt).scalars().first() + data = provider_details_schema.dump(current_sms_provider_in_history) assert sorted(data["created_by"].keys()) == sorted(["id", "email_address", "name"]) diff --git a/tests/app/user/test_rest.py b/tests/app/user/test_rest.py index 4e064ca8e..0a1eb9aec 100644 --- a/tests/app/user/test_rest.py +++ b/tests/app/user/test_rest.py @@ -6,7 +6,9 @@ from unittest import mock import pytest from flask import current_app from freezegun import freeze_time +from sqlalchemy import delete, func, select +from app import db from app.dao.service_user_dao import dao_get_service_user, dao_update_service_user from app.enums import AuthType, KeyType, NotificationType, PermissionType from app.models import Notification, Permission, User @@ -99,7 +101,9 @@ def test_post_user(admin_request, notify_db_session): """ Tests POST endpoint '/' to create a user. """ - User.query.delete() + db.session.execute(delete(User)) + db.session.commit() + data = { "name": "Test User", "email_address": "user@digital.fake.gov", @@ -113,7 +117,13 @@ def test_post_user(admin_request, notify_db_session): } json_resp = admin_request.post("user.create_user", _data=data, _expected_status=201) - user = User.query.filter_by(email_address="user@digital.fake.gov").first() + user = ( + db.session.execute( + select(User).where(User.email_address == "user@digital.fake.gov") + ) + .scalars() + .first() + ) assert user.check_password("password") assert json_resp["data"]["email_address"] == user.email_address assert json_resp["data"]["id"] == str(user.id) @@ -121,7 +131,9 @@ def test_post_user(admin_request, notify_db_session): def test_post_user_without_auth_type(admin_request, notify_db_session): - User.query.delete() + + db.session.execute(delete(User)) + db.session.commit() data = { "name": "Test User", "email_address": "user@digital.fake.gov", @@ -132,7 +144,13 @@ def test_post_user_without_auth_type(admin_request, notify_db_session): json_resp = admin_request.post("user.create_user", _data=data, _expected_status=201) - user = User.query.filter_by(email_address="user@digital.fake.gov").first() + user = ( + db.session.execute( + select(User).where(User.email_address == "user@digital.fake.gov") + ) + .scalars() + .first() + ) assert json_resp["data"]["id"] == str(user.id) assert user.auth_type == AuthType.SMS @@ -141,7 +159,9 @@ def test_post_user_missing_attribute_email(admin_request, notify_db_session): """ Tests POST endpoint '/' missing attribute email. """ - User.query.delete() + + db.session.execute(delete(User)) + db.session.commit() data = { "name": "Test User", "password": "password", @@ -153,17 +173,24 @@ def test_post_user_missing_attribute_email(admin_request, notify_db_session): } json_resp = admin_request.post("user.create_user", _data=data, _expected_status=400) - assert User.query.count() == 0 + assert _get_user_count() == 0 assert {"email_address": ["Missing data for required field."]} == json_resp[ "message" ] +def _get_user_count(): + stmt = select(func.count()).select_from(User) + return db.session.execute(stmt).scalar() or 0 + + def test_create_user_missing_attribute_password(admin_request, notify_db_session): """ Tests POST endpoint '/' missing attribute password. """ - User.query.delete() + + db.session.execute(delete(User)) + db.session.commit() data = { "name": "Test User", "email_address": "user@digital.fake.gov", @@ -174,7 +201,7 @@ def test_create_user_missing_attribute_password(admin_request, notify_db_session "permissions": {}, } json_resp = admin_request.post("user.create_user", _data=data, _expected_status=400) - assert User.query.count() == 0 + assert _get_user_count() == 0 assert {"password": ["Missing data for required field."]} == json_resp["message"] @@ -329,7 +356,8 @@ def test_post_user_attribute_with_updated_by_sends_notification_to_international _data=update_dict, ) - notification = Notification.query.first() + stmt = select(Notification) + notification = db.session.execute(stmt).scalars().first() assert ( notification.reply_to_text == current_app.config["NOTIFY_INTERNATIONAL_SMS_SENDER"] @@ -464,9 +492,15 @@ def test_set_user_permissions(admin_request, sample_user, sample_service): _expected_status=204, ) - permission = Permission.query.filter_by( - permission=PermissionType.MANAGE_SETTINGS - ).first() + permission = ( + db.session.execute( + select(Permission).where( + Permission.permission == PermissionType.MANAGE_SETTINGS + ) + ) + .scalars() + .first() + ) assert permission.user == sample_user assert permission.service == sample_service assert permission.permission == PermissionType.MANAGE_SETTINGS @@ -487,15 +521,27 @@ def test_set_user_permissions_multiple(admin_request, sample_user, sample_servic _expected_status=204, ) - permission = Permission.query.filter_by( - permission=PermissionType.MANAGE_SETTINGS - ).first() + permission = ( + db.session.execute( + select(Permission).where( + Permission.permission == PermissionType.MANAGE_SETTINGS + ) + ) + .scalars() + .first() + ) assert permission.user == sample_user assert permission.service == sample_service assert permission.permission == PermissionType.MANAGE_SETTINGS - permission = Permission.query.filter_by( - permission=PermissionType.MANAGE_TEMPLATES - ).first() + permission = ( + db.session.execute( + select(Permission).where( + Permission.permission == PermissionType.MANAGE_TEMPLATES + ) + ) + .scalars() + .first() + ) assert permission.user == sample_user assert permission.service == sample_service assert permission.permission == PermissionType.MANAGE_TEMPLATES @@ -512,9 +558,16 @@ def test_set_user_permissions_remove_old(admin_request, sample_user, sample_serv _expected_status=204, ) - query = Permission.query.filter_by(user=sample_user) - assert query.count() == 1 - assert query.first().permission == PermissionType.MANAGE_SETTINGS + query = ( + select(func.count()) + .select_from(Permission) + .where(Permission.user == sample_user) + ) + count = db.session.execute(query).scalar() or 0 + assert count == 1 + query = select(Permission).where(Permission.user == sample_user) + first_permission = db.session.execute(query).scalars().first() + assert first_permission.permission == PermissionType.MANAGE_SETTINGS def test_set_user_folder_permissions(admin_request, sample_user, sample_service): @@ -646,9 +699,10 @@ def test_send_already_registered_email( _expected_status=204, ) - notification = Notification.query.first() + stmt = select(Notification) + notification = db.session.execute(stmt).scalars().first() mocked.assert_called_once_with( - ([str(notification.id)]), queue="notify-internal-tasks" + ([str(notification.id)]), queue="notify-internal-tasks", countdown=60 ) assert ( notification.reply_to_text @@ -684,10 +738,10 @@ def test_send_user_confirm_new_email_returns_204( _data=data, _expected_status=204, ) - - notification = Notification.query.first() + stmt = select(Notification) + notification = db.session.execute(stmt).scalars().first() mocked.assert_called_once_with( - ([str(notification.id)]), queue="notify-internal-tasks" + ([str(notification.id)]), queue="notify-internal-tasks", countdown=60 ) assert ( notification.reply_to_text diff --git a/tests/app/user/test_rest_verify.py b/tests/app/user/test_rest_verify.py index ff74f6b57..30e090ae7 100644 --- a/tests/app/user/test_rest_verify.py +++ b/tests/app/user/test_rest_verify.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta import pytest from flask import current_app, url_for from freezegun import freeze_time +from sqlalchemy import func, select import app.celery.tasks from app import db @@ -19,7 +20,7 @@ from tests import create_admin_authorization_header @freeze_time("2016-01-01T12:00:00") def test_user_verify_sms_code(client, sample_sms_code): sample_sms_code.user.logged_in_at = utc_now() - timedelta(days=1) - assert not VerifyCode.query.first().code_used + assert not db.session.execute(select(VerifyCode)).scalars().first().code_used assert sample_sms_code.user.current_session_id is None data = json.dumps( {"code_type": sample_sms_code.code_type, "code": sample_sms_code.txt_code} @@ -31,14 +32,14 @@ def test_user_verify_sms_code(client, sample_sms_code): headers=[("Content-Type", "application/json"), auth_header], ) assert resp.status_code == 204 - assert VerifyCode.query.first().code_used + assert db.session.execute(select(VerifyCode)).scalars().first().code_used assert sample_sms_code.user.logged_in_at == utc_now() assert sample_sms_code.user.email_access_validated_at != utc_now() assert sample_sms_code.user.current_session_id is not None def test_user_verify_code_missing_code(client, sample_sms_code): - assert not VerifyCode.query.first().code_used + assert not db.session.execute(select(VerifyCode)).scalars().first().code_used data = json.dumps({"code_type": sample_sms_code.code_type}) auth_header = create_admin_authorization_header() resp = client.post( @@ -47,14 +48,14 @@ def test_user_verify_code_missing_code(client, sample_sms_code): headers=[("Content-Type", "application/json"), auth_header], ) assert resp.status_code == 400 - assert not VerifyCode.query.first().code_used - assert User.query.get(sample_sms_code.user.id).failed_login_count == 0 + assert not db.session.execute(select(VerifyCode)).scalars().first().code_used + assert db.session.get(User, sample_sms_code.user.id).failed_login_count == 0 def test_user_verify_code_bad_code_and_increments_failed_login_count( client, sample_sms_code ): - assert not VerifyCode.query.first().code_used + assert not db.session.execute(select(VerifyCode)).scalars().first().code_used data = json.dumps({"code_type": sample_sms_code.code_type, "code": "blah"}) auth_header = create_admin_authorization_header() resp = client.post( @@ -63,8 +64,8 @@ def test_user_verify_code_bad_code_and_increments_failed_login_count( headers=[("Content-Type", "application/json"), auth_header], ) assert resp.status_code == 404 - assert not VerifyCode.query.first().code_used - assert User.query.get(sample_sms_code.user.id).failed_login_count == 1 + assert not db.session.execute(select(VerifyCode)).scalars().first().code_used + assert db.session.get(User, sample_sms_code.user.id).failed_login_count == 1 @pytest.mark.parametrize( @@ -133,7 +134,7 @@ def test_user_verify_password(client, sample_user): headers=[("Content-Type", "application/json"), auth_header], ) assert resp.status_code == 204 - assert User.query.get(sample_user.id).logged_in_at == yesterday + assert db.session.get(User, sample_user.id).logged_in_at == yesterday def test_user_verify_password_invalid_password(client, sample_user): @@ -221,16 +222,16 @@ def test_send_user_sms_code(client, sample_user, sms_code_template, mocker): assert resp.status_code == 204 assert mocked.call_count == 1 - assert VerifyCode.query.one().check_code("11111") + assert db.session.execute(select(VerifyCode)).scalars().one().check_code("11111") - notification = Notification.query.one() + notification = db.session.execute(select(Notification)).scalars().one() assert notification.personalisation == {"verify_code": "11111"} assert notification.to == "1" assert str(notification.service_id) == current_app.config["NOTIFY_SERVICE_ID"] assert notification.reply_to_text == notify_service.get_default_sms_sender() app.celery.provider_tasks.deliver_sms.apply_async.assert_called_once_with( - ([str(notification.id)]), queue="notify-internal-tasks" + ([str(notification.id)]), queue="notify-internal-tasks", countdown=60 ) @@ -263,10 +264,10 @@ def test_send_user_code_for_sms_with_optional_to_field( assert resp.status_code == 204 assert mocked.call_count == 1 - notification = Notification.query.first() + notification = db.session.execute(select(Notification)).scalars().first() assert notification.to == "1" app.celery.provider_tasks.deliver_sms.apply_async.assert_called_once_with( - ([str(notification.id)]), queue="notify-internal-tasks" + ([str(notification.id)]), queue="notify-internal-tasks", countdown=60 ) @@ -295,7 +296,7 @@ def test_send_sms_code_returns_204_when_too_many_codes_already_created( ) db.session.add(verify_code) db.session.commit() - assert VerifyCode.query.count() == 5 + assert _get_verify_code_count() == 5 auth_header = create_admin_authorization_header() resp = client.post( url_for( @@ -307,7 +308,12 @@ def test_send_sms_code_returns_204_when_too_many_codes_already_created( headers=[("Content-Type", "application/json"), auth_header], ) assert resp.status_code == 204 - assert VerifyCode.query.count() == 5 + assert _get_verify_code_count() == 5 + + +def _get_verify_code_count(): + stmt = select(func.count()).select_from(VerifyCode) + return db.session.execute(stmt).scalar() or 0 @pytest.mark.parametrize( @@ -340,10 +346,10 @@ def test_send_new_user_email_verification( ) notify_service = email_verification_template.service assert resp.status_code == 204 - notification = Notification.query.first() - assert VerifyCode.query.count() == 0 + notification = db.session.execute(select(Notification)).scalars().first() + assert _get_verify_code_count() == 0 mocked.assert_called_once_with( - ([str(notification.id)]), queue="notify-internal-tasks" + ([str(notification.id)]), queue="notify-internal-tasks", countdown=60 ) assert ( notification.reply_to_text @@ -481,14 +487,16 @@ def test_send_user_email_code( _data=data, _expected_status=204, ) - noti = Notification.query.one() + noti = db.session.execute(select(Notification)).scalars().one() assert ( noti.reply_to_text == email_2fa_code_template.service.get_default_reply_to_email_address() ) assert noti.to == "1" assert str(noti.template_id) == current_app.config["EMAIL_2FA_TEMPLATE_ID"] - deliver_email.assert_called_once_with([str(noti.id)], queue="notify-internal-tasks") + deliver_email.assert_called_once_with( + [str(noti.id)], queue="notify-internal-tasks", countdown=60 + ) @pytest.mark.skip(reason="Broken email functionality") @@ -510,12 +518,6 @@ def test_send_user_email_code_with_urlencoded_next_param( _data=data, _expected_status=204, ) - # TODO We are stripping out the personalisation from the db - # It should be recovered -- if needed -- from s3, but - # the purpose of this functionality is not clear. Is this - # 2fa codes for email users? Sms users receive 2fa codes via sms - # noti = Notification.query.one() - # assert noti.personalisation["url"].endswith("?next=%2Fservices") def test_send_email_code_returns_404_for_bad_input_data(admin_request): @@ -602,7 +604,7 @@ def test_send_user_2fa_code_sends_from_number_for_international_numbers( ) assert resp.status_code == 204 - notification = Notification.query.first() + notification = db.session.execute(select(Notification)).scalars().first() assert ( notification.reply_to_text == current_app.config["NOTIFY_INTERNATIONAL_SMS_SENDER"] diff --git a/tests/notifications_utils/test_s3.py b/tests/notifications_utils/test_s3.py index 46b863c4f..6769fddd0 100644 --- a/tests/notifications_utils/test_s3.py +++ b/tests/notifications_utils/test_s3.py @@ -1,9 +1,16 @@ +from unittest.mock import MagicMock from urllib.parse import parse_qs import botocore import pytest -from notifications_utils.s3 import S3ObjectNotFound, s3download, s3upload +from notifications_utils.s3 import ( + AWS_CLIENT_CONFIG, + S3ObjectNotFound, + get_s3_resource, + s3download, + s3upload, +) contents = "some file data" region = "eu-west-1" @@ -13,7 +20,11 @@ content_type = "binary/octet-stream" def test_s3upload_save_file_to_bucket(mocker): - mocked = mocker.patch("notifications_utils.s3.Session.resource") + + mock_s3_resource = mocker.Mock() + mocked = mocker.patch( + "notifications_utils.s3.get_s3_resource", return_value=mock_s3_resource + ) s3upload( filedata=contents, region=region, bucket_name=bucket, file_location=location ) @@ -27,7 +38,11 @@ def test_s3upload_save_file_to_bucket(mocker): def test_s3upload_save_file_to_bucket_with_contenttype(mocker): content_type = "image/png" - mocked = mocker.patch("notifications_utils.s3.Session.resource") + + mock_s3_resource = mocker.Mock() + mocked = mocker.patch( + "notifications_utils.s3.get_s3_resource", return_value=mock_s3_resource + ) s3upload( filedata=contents, region=region, @@ -44,7 +59,11 @@ def test_s3upload_save_file_to_bucket_with_contenttype(mocker): def test_s3upload_raises_exception(app, mocker): - mocked = mocker.patch("notifications_utils.s3.Session.resource") + + mock_s3_resource = mocker.Mock() + mocked = mocker.patch( + "notifications_utils.s3.get_s3_resource", return_value=mock_s3_resource + ) response = {"Error": {"Code": 500}} exception = botocore.exceptions.ClientError(response, "Bad exception") mocked.return_value.Object.return_value.put.side_effect = exception @@ -58,7 +77,12 @@ def test_s3upload_raises_exception(app, mocker): def test_s3upload_save_file_to_bucket_with_urlencoded_tags(mocker): - mocked = mocker.patch("notifications_utils.s3.Session.resource") + + mock_s3_resource = mocker.Mock() + mocked = mocker.patch( + "notifications_utils.s3.get_s3_resource", return_value=mock_s3_resource + ) + s3upload( filedata=contents, region=region, @@ -74,7 +98,12 @@ def test_s3upload_save_file_to_bucket_with_urlencoded_tags(mocker): def test_s3upload_save_file_to_bucket_with_metadata(mocker): - mocked = mocker.patch("notifications_utils.s3.Session.resource") + + mock_s3_resource = mocker.Mock() + mocked = mocker.patch( + "notifications_utils.s3.get_s3_resource", return_value=mock_s3_resource + ) + s3upload( filedata=contents, region=region, @@ -88,17 +117,49 @@ def test_s3upload_save_file_to_bucket_with_metadata(mocker): assert metadata == {"status": "valid", "pages": "5"} +def test_get_s3_resource(mocker): + mock_session = mocker.patch("notifications_utils.s3.Session") + mock_current_app = mocker.patch("notifications_utils.s3.current_app") + sa_key = "sec" + sa_key = f"{sa_key}ret_access_key" + + mock_current_app.config = { + "CSV_UPLOAD_BUCKET": { + "access_key_id": "test_access_key", + sa_key: "test_s_key", + "region": "us-west-100", + } + } + mock_s3_resource = MagicMock() + mock_session.return_value.resource.return_value = mock_s3_resource + result = get_s3_resource() + + mock_session.return_value.resource.assert_called_once_with( + "s3", config=AWS_CLIENT_CONFIG + ) + assert result == mock_s3_resource + + def test_s3download_gets_file(mocker): - mocked = mocker.patch("notifications_utils.s3.Session.resource") + + mock_s3_resource = mocker.Mock() + mocked = mocker.patch( + "notifications_utils.s3.get_s3_resource", return_value=mock_s3_resource + ) + mocked_object = mocked.return_value.Object - mocked_get = mocked.return_value.Object.return_value.get + mocked_object.return_value.get.return_value = {"Body": mocker.Mock()} s3download("bucket", "location.file") mocked_object.assert_called_once_with("bucket", "location.file") - mocked_get.assert_called_once_with() def test_s3download_raises_on_error(mocker): - mocked = mocker.patch("notifications_utils.s3.Session.resource") + + mock_s3_resource = mocker.Mock() + mocked = mocker.patch( + "notifications_utils.s3.get_s3_resource", return_value=mock_s3_resource + ) + mocked.return_value.Object.side_effect = botocore.exceptions.ClientError( {"Error": {"Code": 404}}, "Bad exception", diff --git a/zap.conf b/zap.conf index f4e88ff07..255e0dde8 100644 --- a/zap.conf +++ b/zap.conf @@ -50,7 +50,7 @@ 10061 WARN (X-AspNet-Version Response Header - Passive/release) 10062 FAIL (PII Disclosure - Passive/beta) 10095 IGNORE (Backup File Disclosure - Active/beta) -10096 WARN (Timestamp Disclosure - Passive/release) +10096 IGNORE (Timestamp Disclosure - Passive/release) 10097 WARN (Hash Disclosure - Passive/beta) 10098 WARN (Cross-Domain Misconfiguration - Passive/release) 10104 WARN (User Agent Fuzzer - Active/beta) @@ -119,3 +119,4 @@ 90030 WARN (WSDL File Detection - Passive/alpha) 90033 WARN (Loosely Scoped Cookie - Passive/release) 90034 WARN (Cloud Metadata Potentially Exposed - Active/beta) +100001 IGNORE (Unexpected Content-Type was returned)