Compare commits

..

15 Commits

Author SHA1 Message Date
David McDonald
226815b7d8 more wip 2020-06-16 13:59:15 +01:00
Pea Tyczynska
f718b71dba Make freeze requirements 2020-06-16 12:31:39 +01:00
David McDonald
e674a3ca22 wip 2020-06-16 12:22:24 +01:00
Leo Hemsted
eec2c2859e Merge pull request #2876 from alphagov/more-histograms
add more prometheus metrics
2020-06-15 17:28:59 +01:00
Leo Hemsted
58ab99d74b add more prometheus metrics
Two new metrics:

auth_db_connection_duration_seconds (histogram)
  wraps the first DB call of post notifications. This includes waiting
  to get a connection from the pool, and also making the actual request
  to the db to retrieve the service and api keys. (i'm not sure there's
  an easy way to separate these two things)

post_notification_json_parse_duration_seconds
  wraps parsing the v2 post notifications json parsing and schema
  validation. Shouldn't include any async code
2020-06-15 16:26:56 +01:00
David McDonald
4c230a7235 Merge pull request #2819 from alphagov/additional-prometheus-metrics
Additional prometheus metrics
2020-06-12 17:02:12 +01:00
David McDonald
8b4a424df1 Tidy up 2020-06-12 16:51:44 +01:00
Leo Hemsted
15ce9fe3f9 add metrics for redis timings 2020-06-12 14:52:22 +01:00
Leo Hemsted
d9b3b31a6a add loadtesting to manifest so we can deploy a separate app 2020-06-12 14:52:22 +01:00
Leo Hemsted
cd9b80f415 set test_errors app fixture to session scope
we have one global metrics variable `metrics = GDSMetrics()`, and we
then call `metrics.init_app` from within the flask application set up.
The v2/test_errors.py app_for_test fixture calls create_app, would call
metrics.init_app multiple times for the same metrics instance. This
causes errors, so change the fixture to session level so it only calls
once per test run.
2020-06-12 14:52:22 +01:00
Leo Hemsted
faa8faa0c4 bump prometheus-client to 0.7.1
there's multiple performance improvements from prometheus-client 0.2.0.
pin this bump while we wait for gds metrics client to increase its
dependency
2020-06-12 14:52:22 +01:00
Leo Hemsted
c4dc0f64c5 dd sqlalchemy connection metrics for celery tasks
grab the worker app name and task name rather than the web host and
endpoint. also add a fallback for if we're not in a web request or a
celery task. I think that'll probably happen when we use alembic, or if
we do things from within flask shell
2020-06-12 14:52:22 +01:00
Leo Hemsted
6e32ca5996 add prometheus metrics for connection pools (both web and sql)
add the following prometheus metrics to keep track of general app
instance health.

 # sqs_apply_async_duration

how long does the actual SQS call (a standard web request to AWS) take.
a histogram with default bucket sizes, split up per task that was
created.

 # concurrent_web_request_count

how many web requests is this app currently serving. this is split up
per process, so we'd expect multiple responses per app instance

 # db_connection_total_connected

how many connections does this app (process) have open to the database.
They might be idle.

 # db_connection_total_checked_out

how many connections does this app (process) have open that are
currently in use by a web worker

 # db_connection_open_duration_seconds

a histogram per endpoint of how long the db connection was taken from
the pool for. won't have any data if a connection was never opened.
2020-06-12 14:52:22 +01:00
Leo Hemsted
4bb37a05ec Merge pull request #2871 from alphagov/utils-bump
bump utils to fix statsd timing bug
2020-06-11 15:13:04 +01:00
Leo Hemsted
bd433ad24f bump utils to fix statsd timing bug
statsd timing should always be in seconds, not milliseconds
2020-06-11 15:01:15 +01:00
23 changed files with 403 additions and 56 deletions

View File

@@ -1,19 +1,23 @@
import time
import os
import random
import string
import uuid
from flask import _request_ctx_stack, request, g, jsonify, make_response
from celery import current_task
from flask import _request_ctx_stack, request, g, jsonify, make_response, current_app, has_request_context
from flask_sqlalchemy import SQLAlchemy as _SQLAlchemy
from flask_marshmallow import Marshmallow
from flask_migrate import Migrate
from gds_metrics import GDSMetrics
from gds_metrics.metrics import Gauge, Histogram
from time import monotonic
from notifications_utils.clients.zendesk.zendesk_client import ZendeskClient
from notifications_utils.clients.statsd.statsd_client import StatsdClient
from notifications_utils.clients.redis.redis_client import RedisClient
from notifications_utils.clients.encryption.encryption_client import Encryption
from notifications_utils import logging, request_helper
from sqlalchemy import event
from werkzeug.exceptions import HTTPException as WerkzeugHTTPException
from werkzeug.local import LocalProxy
@@ -64,6 +68,11 @@ clients = Clients()
api_user = LocalProxy(lambda: _request_ctx_stack.top.api_user)
authenticated_service = LocalProxy(lambda: _request_ctx_stack.top.authenticated_service)
CONCURRENT_REQUESTS = Gauge(
'concurrent_web_request_count',
'How many concurrent requests are currently being served',
)
def create_app(application):
from app.config import configs
@@ -108,6 +117,9 @@ def create_app(application):
from app.commands import setup_commands
setup_commands(application)
# set up sqlalchemy events
setup_sqlalchemy_events(application)
return application
@@ -255,17 +267,22 @@ def register_v2_blueprints(application):
def init_app(app):
@app.before_request
def record_user_agent():
statsd_client.incr("user-agent.{}".format(process_user_agent(request.headers.get('User-Agent', None))))
@app.before_request
def record_request_details():
CONCURRENT_REQUESTS.inc()
g.start = monotonic()
g.endpoint = request.endpoint
@app.after_request
def after_request(response):
CONCURRENT_REQUESTS.dec()
response.headers.add('Access-Control-Allow-Origin', '*')
response.headers.add('Access-Control-Allow-Headers', 'Content-Type,Authorization')
response.headers.add('Access-Control-Allow-Methods', 'GET,PUT,POST,DELETE')
@@ -311,3 +328,83 @@ def process_user_agent(user_agent_string):
return "non-notify-user-agent"
else:
return "unknown"
def setup_sqlalchemy_events(app):
TOTAL_DB_CONNECTIONS = Gauge(
'db_connection_total_connected',
'How many db connections are currently held (potentially idle) by the server',
)
TOTAL_CHECKED_OUT_DB_CONNECTIONS = Gauge(
'db_connection_total_checked_out',
'How many db connections are currently checked out by web requests',
)
DB_CONNECTION_OPEN_DURATION_SECONDS = Histogram(
'db_connection_open_duration_seconds',
'How long db connections are held open for in seconds',
['method', 'host', 'path']
)
# need this or db.engine isn't accessible
with app.app_context():
@event.listens_for(db.engine, 'connect')
def connect(dbapi_connection, connection_record):
# connection first opened with db
TOTAL_DB_CONNECTIONS.inc()
@event.listens_for(db.engine, 'close')
def close(dbapi_connection, connection_record):
# connection closed (probably only happens with overflow connections)
TOTAL_DB_CONNECTIONS.dec()
@event.listens_for(db.engine, 'checkout')
def checkout(dbapi_connection, connection_record, connection_proxy):
# connection given to a web worker
TOTAL_CHECKED_OUT_DB_CONNECTIONS.inc()
# this will overwrite any previous checkout_at timestamp
connection_record.info['checkout_at'] = time.monotonic()
# checkin runs after the request is already torn down, therefore we add the request_data onto the
# connection_record as otherwise it won't have that information when checkin actually runs.
# Note: this is not a problem for checkouts as the checkout always happens within a web request or task
# web requests
if has_request_context():
connection_record.info['request_data'] = {
'method': request.method,
'host': request.host,
'url_rule': request.url_rule.rule if request.url_rule else 'No endpoint'
}
# celery apps
elif current_task:
connection_record.info['request_data'] = {
'method': 'celery',
'host': current_app.config['NOTIFY_APP_NAME'], # worker name
'url_rule': current_task.name, # task name
}
# anything else. migrations possibly.
else:
current_app.logger.warning('Checked out sqlalchemy connection from outside of request/task')
connection_record.info['request_data'] = {
'method': 'unknown',
'host': 'unknown',
'url_rule': 'unknown',
}
@event.listens_for(db.engine, 'checkin')
def checkin(dbapi_connection, connection_record):
# connection returned by a web worker
TOTAL_CHECKED_OUT_DB_CONNECTIONS.dec()
# duration that connection was held by a single web request
duration = time.monotonic() - connection_record.info['checkout_at']
DB_CONNECTION_OPEN_DURATION_SECONDS.labels(
connection_record.info['request_data']['method'],
connection_record.info['request_data']['host'],
connection_record.info['request_data']['url_rule']
).observe(duration)

View File

@@ -6,12 +6,18 @@ from notifications_python_client.errors import (
from notifications_utils import request_helper
from sqlalchemy.exc import DataError
from sqlalchemy.orm.exc import NoResultFound
from gds_metrics import Histogram
from app.dao.services_dao import dao_fetch_service_by_id_with_api_keys
GENERAL_TOKEN_ERROR_MESSAGE = 'Invalid token: make sure your API token matches the example at https://docs.notifications.service.gov.uk/rest-api.html#authorisation-header' # noqa
AUTH_DB_CONNECTION_DURATION_SECONDS = Histogram(
'auth_db_connection_duration_seconds',
'Time taken to get DB connection and fetch service from database',
)
class AuthError(Exception):
def __init__(self, message, code, service_id=None, api_key_id=None):
@@ -87,7 +93,8 @@ def requires_auth():
issuer = __get_token_issuer(auth_token) # ie the `iss` claim which should be a service ID
try:
service = dao_fetch_service_by_id_with_api_keys(issuer)
with AUTH_DB_CONNECTION_DURATION_SECONDS.time():
service = dao_fetch_service_by_id_with_api_keys(issuer)
except DataError:
raise AuthError("Invalid token: service id is not the right data type", 403)
except NoResultFound:

View File

@@ -1,5 +1,6 @@
import time
from gds_metrics.metrics import Histogram
from celery import Celery, Task
from celery.signals import worker_process_shutdown
from flask import g, request
@@ -19,6 +20,12 @@ def log_on_worker_shutdown(sender, signal, pid, exitcode, **kwargs):
def make_task(app):
SQS_APPLY_ASYNC_DURATION_SECONDS = Histogram(
'sqs_apply_async_duration_seconds',
'Time taken to put task on queue',
['task_name']
)
class NotifyTask(Task):
abstract = True
start = None
@@ -52,7 +59,8 @@ def make_task(app):
if has_request_context() and hasattr(request, 'request_id'):
kwargs['request_id'] = request.request_id
return super().apply_async(args, kwargs, task_id, producer, link, link_error, **options)
with SQS_APPLY_ASYNC_DURATION_SECONDS.labels(self.name).time():
return super().apply_async(args, kwargs, task_id, producer, link, link_error, **options)
return NotifyTask

View File

@@ -677,6 +677,12 @@ def dao_get_notifications_by_references(references):
).all()
@statsd(namespace="dao")
def dao_created_scheduled_notification(scheduled_notification):
db.session.add(scheduled_notification)
db.session.commit()
def dao_get_total_notifications_sent_per_day_for_performance_platform(start_date, end_date):
"""
SELECT

View File

@@ -72,8 +72,8 @@ def send_sms_to_provider(notification):
notification.billable_units = template.fragment_count
update_notification_to_sending(notification, provider)
delta_milliseconds = (datetime.utcnow() - notification.created_at).total_seconds() * 1000
statsd_client.timing("sms.total-time", delta_milliseconds)
delta_seconds = (datetime.utcnow() - notification.created_at).total_seconds()
statsd_client.timing("sms.total-time", delta_seconds)
def send_email_to_provider(notification):
@@ -118,8 +118,8 @@ def send_email_to_provider(notification):
notification.reference = reference
update_notification_to_sending(notification, provider)
delta_milliseconds = (datetime.utcnow() - notification.created_at).total_seconds() * 1000
statsd_client.timing("email.total-time", delta_milliseconds)
delta_seconds = (datetime.utcnow() - notification.created_at).total_seconds()
statsd_client.timing("email.total-time", delta_seconds)
def update_notification_to_sending(notification, provider):

View File

@@ -27,7 +27,7 @@ from notifications_utils.template import (
SMSMessageTemplate,
LetterPrintTemplate,
)
from notifications_utils.timezones import convert_utc_to_bst
from notifications_utils.timezones import convert_bst_to_utc, convert_utc_to_bst
from app.hashing import (
hashpw,
@@ -291,6 +291,7 @@ service_letter_branding = db.Table(
INTERNATIONAL_SMS_TYPE = 'international_sms'
INBOUND_SMS_TYPE = 'inbound_sms'
SCHEDULE_NOTIFICATIONS = 'schedule_notifications'
EMAIL_AUTH = 'email_auth'
LETTERS_AS_PDF = 'letters_as_pdf'
PRECOMPILED_LETTER = 'precompiled_letter'
@@ -305,6 +306,7 @@ SERVICE_PERMISSION_TYPES = [
LETTER_TYPE,
INTERNATIONAL_SMS_TYPE,
INBOUND_SMS_TYPE,
SCHEDULE_NOTIFICATIONS,
EMAIL_AUTH,
LETTERS_AS_PDF,
UPLOAD_DOCUMENT,
@@ -1413,6 +1415,8 @@ class Notification(db.Model):
client_reference = db.Column(db.String, index=True, nullable=True)
_personalisation = db.Column(db.String, nullable=True)
scheduled_notification = db.relationship('ScheduledNotification', uselist=False)
client_reference = db.Column(db.String, index=True, nullable=True)
international = db.Column(db.Boolean, nullable=False, default=False)
@@ -1628,7 +1632,13 @@ class Notification(db.Model):
"created_by_name": self.get_created_by_name(),
"sent_at": self.sent_at.strftime(DATETIME_FORMAT) if self.sent_at else None,
"completed_at": self.completed_at(),
"scheduled_for": None,
"scheduled_for": (
convert_bst_to_utc(
self.scheduled_notification.scheduled_for
).strftime(DATETIME_FORMAT)
if self.scheduled_notification
else None
),
"postage": self.postage
}

View File

@@ -9,6 +9,7 @@ from notifications_utils.recipients import (
validate_and_format_phone_number,
format_email_address
)
from notifications_utils.timezones import convert_bst_to_utc
from app import redis_store
from app.celery import provider_tasks
@@ -21,16 +22,27 @@ from app.models import (
SMS_TYPE,
LETTER_TYPE,
NOTIFICATION_CREATED,
Notification
Notification,
ScheduledNotification
)
from app.dao.notifications_dao import (
dao_create_notification,
dao_delete_notifications_by_id
dao_delete_notifications_by_id,
dao_created_scheduled_notification
)
from app.v2.errors import BadRequestError
from gds_metrics import Histogram
REDIS_GET_AND_INCR_DAILY_LIMIT_DURATION_SECONDS = Histogram(
'redis_get_and_incr_daily_limit_duration_seconds',
'Time taken to get and possibly incremement the daily limit cache key',
)
def create_content_for_notification(template, personalisation):
template_object = template._as_utils_template_with_personalisation(personalisation)
check_placeholders(template_object)
@@ -112,8 +124,9 @@ def persist_notification(
if not simulated:
dao_create_notification(notification)
if key_type != KEY_TYPE_TEST:
if redis_store.get(redis.daily_limit_cache_key(service.id)):
redis_store.incr(redis.daily_limit_cache_key(service.id))
with REDIS_GET_AND_INCR_DAILY_LIMIT_DURATION_SECONDS.time():
if redis_store.get(redis.daily_limit_cache_key(service.id)):
redis_store.incr(redis.daily_limit_cache_key(service.id))
current_app.logger.info(
"{} {} created at {}".format(notification_type, notification_id, notification_created_at)
@@ -158,3 +171,10 @@ def simulated_recipient(to_address, notification_type):
return to_address in formatted_simulated_numbers
else:
return to_address in current_app.config['SIMULATED_EMAIL_ADDRESSES']
def persist_scheduled_notification(notification_id, scheduled_for):
scheduled_datetime = convert_bst_to_utc(datetime.strptime(scheduled_for, "%Y-%m-%d %H:%M"))
scheduled_notification = ScheduledNotification(notification_id=notification_id,
scheduled_for=scheduled_datetime)
dao_created_scheduled_notification(scheduled_notification)

View File

@@ -12,7 +12,7 @@ from app.dao import services_dao, templates_dao
from app.dao.service_sms_sender_dao import dao_get_service_sms_senders_by_id
from app.models import (
INTERNATIONAL_SMS_TYPE, SMS_TYPE, EMAIL_TYPE, LETTER_TYPE,
KEY_TYPE_TEST, KEY_TYPE_TEAM
KEY_TYPE_TEST, KEY_TYPE_TEAM, SCHEDULE_NOTIFICATIONS
)
from app.service.utils import service_allowed_to_send_to
from app.v2.errors import TooManyRequestsError, BadRequestError, RateLimitError
@@ -22,15 +22,24 @@ from app.utils import get_public_notify_type_text
from app.dao.service_email_reply_to_dao import dao_get_reply_to_by_id
from app.dao.service_letter_contact_dao import dao_get_letter_contact_by_id
from gds_metrics.metrics import Histogram
REDIS_EXCEEDED_RATE_LIMIT_DURATION_SECONDS = Histogram(
'redis_exceeded_rate_limit_duration_seconds',
'Time taken to check rate limit',
)
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)
with REDIS_EXCEEDED_RATE_LIMIT_DURATION_SECONDS.time():
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_daily_message_limit(key_type, service):
@@ -98,6 +107,12 @@ def check_if_service_can_send_files_by_email(service_contact_link, service_id):
)
def check_service_can_schedule_notification(permissions, scheduled_for):
if scheduled_for:
if not service_has_permission(SCHEDULE_NOTIFICATIONS, permissions):
raise BadRequestError(message="Cannot schedule notifications (this feature is invite-only)")
def validate_and_format_recipient(send_to, key_type, service, notification_type, allow_whitelisted_recipients=True):
if send_to is None:
raise BadRequestError(message="Recipient can't be empty")

View File

@@ -136,6 +136,7 @@ post_sms_request = {
"phone_number": {"type": "string", "format": "phone_number"},
"template_id": uuid,
"personalisation": personalisation,
"scheduled_for": {"type": ["string", "null"], "format": "datetime_within_next_day"},
"sms_sender_id": uuid
},
"required": ["phone_number", "template_id"],
@@ -181,6 +182,7 @@ post_email_request = {
"email_address": {"type": "string", "format": "email_address"},
"template_id": uuid,
"personalisation": personalisation,
"scheduled_for": {"type": ["string", "null"], "format": "datetime_within_next_day"},
"email_reply_to_id": uuid
},
"required": ["email_address", "template_id"],

View File

@@ -7,6 +7,7 @@ from boto.exception import SQSError
from flask import request, jsonify, current_app, abort
from notifications_utils.postal_address import PostalAddress
from notifications_utils.recipients import try_validate_and_format_phone_number
from gds_metrics import Histogram
from app import (
api_user,
@@ -43,12 +44,14 @@ from app.notifications.process_letter_notifications import (
)
from app.notifications.process_notifications import (
persist_notification,
persist_scheduled_notification,
send_notification_to_queue,
simulated_recipient
)
from app.notifications.validators import (
check_if_service_can_send_files_by_email,
check_rate_limiting,
check_service_can_schedule_notification,
check_service_email_reply_to_id,
check_service_has_permission,
check_service_sms_sender_id,
@@ -72,6 +75,12 @@ from app.v2.notifications.notification_schemas import (
from app.v2.utils import get_valid_json
POST_NOTIFICATION_JSON_PARSE_DURATION_SECONDS = Histogram(
'post_notification_json_parse_duration_seconds',
'Time taken to parse and validate post request json',
)
@v2_notification_blueprint.route('/{}'.format(LETTER_TYPE), methods=['POST'])
def post_precompiled_letter_notification():
request_json = get_valid_json()
@@ -114,19 +123,24 @@ def post_precompiled_letter_notification():
@v2_notification_blueprint.route('/<notification_type>', methods=['POST'])
def post_notification(notification_type):
request_json = get_valid_json()
with POST_NOTIFICATION_JSON_PARSE_DURATION_SECONDS.time():
request_json = get_valid_json()
if notification_type == EMAIL_TYPE:
form = validate(request_json, post_email_request)
elif notification_type == SMS_TYPE:
form = validate(request_json, post_sms_request)
elif notification_type == LETTER_TYPE:
form = validate(request_json, post_letter_request)
else:
abort(404)
if notification_type == EMAIL_TYPE:
form = validate(request_json, post_email_request)
elif notification_type == SMS_TYPE:
form = validate(request_json, post_sms_request)
elif notification_type == LETTER_TYPE:
form = validate(request_json, post_letter_request)
else:
abort(404)
check_service_has_permission(notification_type, authenticated_service.permissions)
scheduled_for = form.get("scheduled_for", None)
check_service_can_schedule_notification(authenticated_service.permissions, scheduled_for)
check_rate_limiting(authenticated_service, api_user)
template, template_with_content = validate_template(
@@ -177,7 +191,7 @@ def post_notification(notification_type):
resp = create_resp_partial(
notification=notification,
url_root=request.url_root,
scheduled_for=None,
scheduled_for=scheduled_for,
content=template_with_content.content_with_placeholders_filled_in,
)
return jsonify(resp), 201
@@ -244,15 +258,19 @@ def process_sms_or_email_notification(*, form, notification_type, api_key, templ
document_download_count=document_download_count
)
if not simulated:
queue_name = QueueNames.PRIORITY if template.process_type == PRIORITY else None
send_notification_to_queue(
notification=notification,
research_mode=service.research_mode,
queue=queue_name
)
scheduled_for = form.get("scheduled_for", None)
if scheduled_for:
persist_scheduled_notification(notification.id, form["scheduled_for"])
else:
current_app.logger.debug("POST simulated notification for id: {}".format(notification.id))
if not simulated:
queue_name = QueueNames.PRIORITY if template.process_type == PRIORITY else None
send_notification_to_queue(
notification=notification,
research_mode=service.research_mode,
queue=queue_name
)
else:
current_app.logger.debug("POST simulated notification for id: {}".format(notification.id))
return notification

View File

@@ -2,9 +2,12 @@
from __future__ import print_function
from flask import Flask
import psycogreen.eventlet
from app import create_app
psycogreen.eventlet.patch_psycopg()
application = Flask('app')
create_app(application)

View File

@@ -6,7 +6,7 @@ import gunicorn
from gds_metrics.gunicorn import child_exit # noqa
workers = 4
worker_class = "eventlet"
worker_class = "gevent"
worker_connections = 256
errorlog = "/home/vcap/logs/gunicorn_error.log"
bind = "0.0.0.0:{}".format(os.getenv("PORT"))

View File

@@ -1,6 +1,6 @@
{%- set app_vars = {
'notify-api': {
'NOTIFY_APP_NAME': 'api',
'notify-api-canary1': {
'NOTIFY_APP_NAME': 'api-canary1',
'disk_quota': '2G',
'sqlalchemy_pool_size': 30,
'routes': {
@@ -12,8 +12,8 @@
'health-check-invocation-timeout': 3,
'instances': {
'preview': None,
'staging': None,
'production': 25
'staging': 1,
'production': 1
},
},
'notify-api-db-migration': {

View File

@@ -10,13 +10,14 @@ Flask-Migrate==2.5.3
git+https://github.com/mitsuhiko/flask-sqlalchemy.git@500e732dd1b975a56ab06a46bd1a20a21e682262#egg=Flask-SQLAlchemy==2.3.2.dev20190108
Flask==1.1.2
click-datetime==0.2
eventlet==0.25.2
gevent==20.6.1
gunicorn==20.0.4
iso8601==0.1.12
itsdangerous==1.1.0
jsonschema==3.2.0
marshmallow-sqlalchemy==0.23.0
marshmallow==2.21.0 # pyup: <3 # v3 throws errors
psycogreen==1.0.2
psycopg2-binary==2.8.5
PyJWT==1.7.1
SQLAlchemy==1.3.17
@@ -26,6 +27,8 @@ notifications-python-client==5.5.1
# PaaS
awscli-cwlogs>=1.4,<1.5
git+https://github.com/alphagov/notifications-utils.git@39.4.3#egg=notifications-utils==39.4.3
git+https://github.com/alphagov/notifications-utils.git@39.4.4#egg=notifications-utils==39.4.4
# gds-metrics requires prometheseus 0.2.0, override that requirement as 0.7.1 brings significant performance gains
prometheus-client==0.7.1
gds-metrics==0.2.0

View File

@@ -12,13 +12,14 @@ Flask-Migrate==2.5.3
git+https://github.com/mitsuhiko/flask-sqlalchemy.git@500e732dd1b975a56ab06a46bd1a20a21e682262#egg=Flask-SQLAlchemy==2.3.2.dev20190108
Flask==1.1.2
click-datetime==0.2
eventlet==0.25.2
gevent==20.6.1
gunicorn==20.0.4
iso8601==0.1.12
itsdangerous==1.1.0
jsonschema==3.2.0
marshmallow-sqlalchemy==0.23.0
marshmallow==2.21.0 # pyup: <3 # v3 throws errors
psycogreen==1.0.2
psycopg2-binary==2.8.5
PyJWT==1.7.1
SQLAlchemy==1.3.17
@@ -28,8 +29,10 @@ notifications-python-client==5.5.1
# PaaS
awscli-cwlogs>=1.4,<1.5
git+https://github.com/alphagov/notifications-utils.git@39.4.3#egg=notifications-utils==39.4.3
git+https://github.com/alphagov/notifications-utils.git@39.4.4#egg=notifications-utils==39.4.4
# gds-metrics requires prometheseus 0.2.0, override that requirement as 0.7.1 brings significant performance gains
prometheus-client==0.7.1
gds-metrics==0.2.0
## The following requirements were added by pip freeze:
@@ -37,19 +40,18 @@ alembic==1.4.2
amqp==1.4.9
anyjson==0.3.3
attrs==19.3.0
awscli==1.18.75
awscli==1.18.80
bcrypt==3.1.7
billiard==3.3.0.23
bleach==3.1.4
blinker==1.4
boto==2.49.0
boto3==1.10.38
botocore==1.16.25
botocore==1.17.3
certifi==2020.4.5.2
chardet==3.0.4
click==7.1.2
colorama==0.4.3
dnspython==1.16.0
docutils==0.15.2
flask-redis==0.4.0
future==0.18.2
@@ -66,7 +68,6 @@ mistune==0.8.4
monotonic==1.5
orderedset==2.0.1
phonenumbers==8.11.2
prometheus-client==0.2.0
pyasn1==0.4.8
pycparser==2.20
PyPDF2==1.26.0
@@ -87,3 +88,5 @@ urllib3==1.25.9
webencodings==0.5.1
Werkzeug==1.0.1
zipp==3.1.0
zope.event==4.4
zope.interface==5.1.0

View File

@@ -1,6 +1,6 @@
#!/bin/bash
case $NOTIFY_APP_NAME in
api)
api|api-canary1)
unset GUNICORN_CMD_ARGS
exec scripts/run_app_paas.sh gunicorn -c /home/vcap/app/gunicorn_config.py application
;;

View File

@@ -10,6 +10,7 @@ from sqlalchemy.orm.exc import NoResultFound
from app.dao.notifications_dao import (
dao_create_notification,
dao_created_scheduled_notification,
dao_delete_notifications_by_id,
dao_get_last_notification_added_for_job_id,
dao_get_notifications_by_recipient_or_reference,
@@ -35,6 +36,7 @@ from app.models import (
Job,
Notification,
NotificationHistory,
ScheduledNotification,
NOTIFICATION_STATUS_TYPES,
NOTIFICATION_STATUS_TYPES_FAILED,
NOTIFICATION_TEMPORARY_FAILURE,
@@ -447,6 +449,7 @@ def test_save_notification_with_no_job(sample_template, mmg_provider):
def test_get_notification_with_personalisation_by_id(sample_template):
notification = create_notification(template=sample_template,
scheduled_for='2017-05-05 14:15',
status='created')
notification_from_db = get_notification_with_personalisation(
sample_template.service.id,
@@ -454,6 +457,7 @@ def test_get_notification_with_personalisation_by_id(sample_template):
key_type=None
)
assert notification == notification_from_db
assert notification_from_db.scheduled_notification.scheduled_for == datetime(2017, 5, 5, 14, 15)
def test_get_notification_by_id_when_notification_exists(sample_notification):
@@ -1388,6 +1392,18 @@ def test_dao_get_notifications_by_reference(
assert results.items[0].id == letter.id
def test_dao_created_scheduled_notification(sample_notification):
scheduled_notification = ScheduledNotification(notification_id=sample_notification.id,
scheduled_for=datetime.strptime("2017-01-05 14:15",
"%Y-%m-%d %H:%M"))
dao_created_scheduled_notification(scheduled_notification)
saved_notification = ScheduledNotification.query.all()
assert len(saved_notification) == 1
assert saved_notification[0].notification_id == sample_notification.id
assert saved_notification[0].scheduled_for == datetime(2017, 1, 5, 14, 15)
def test_dao_get_notifications_by_to_field_filters_status(sample_template):
notification = create_notification(
template=sample_template, to_field='+447700900855',

View File

@@ -9,7 +9,8 @@ from app.dao.invited_org_user_dao import save_invited_org_user
from app.dao.invited_user_dao import save_invited_user
from app.dao.jobs_dao import dao_create_job
from app.dao.notifications_dao import (
dao_create_notification
dao_create_notification,
dao_created_scheduled_notification
)
from app.dao.organisation_dao import dao_create_organisation, dao_add_service_to_organisation
from app.dao.permissions_dao import permission_dao
@@ -38,6 +39,7 @@ from app.models import (
ServiceInboundApi,
ServiceCallbackApi,
ServiceLetterContact,
ScheduledNotification,
ServicePermission,
ServiceSmsSender,
ServiceWhitelist,
@@ -292,6 +294,14 @@ def create_notification(
}
notification = Notification(**data)
dao_create_notification(notification)
if scheduled_for:
scheduled_notification = ScheduledNotification(id=uuid.uuid4(),
notification_id=notification.id,
scheduled_for=datetime.strptime(scheduled_for,
"%Y-%m-%d %H:%M"))
if status != 'created':
scheduled_notification.pending = False
dao_created_scheduled_notification(scheduled_notification)
return notification

View File

@@ -10,12 +10,14 @@ from collections import namedtuple
from app.models import (
Notification,
NotificationHistory,
ScheduledNotification,
Template,
LETTER_TYPE
)
from app.notifications.process_notifications import (
create_content_for_notification,
persist_notification,
persist_scheduled_notification,
send_notification_to_queue,
simulated_recipient
)
@@ -383,6 +385,14 @@ def test_persist_notification_with_international_info_does_not_store_for_email(
assert persisted_notification.rate_multiplier is None
def test_persist_scheduled_notification(sample_notification):
persist_scheduled_notification(sample_notification.id, '2017-05-12 14:15')
scheduled_notification = ScheduledNotification.query.all()
assert len(scheduled_notification) == 1
assert scheduled_notification[0].notification_id == sample_notification.id
assert scheduled_notification[0].scheduled_for == datetime.datetime(2017, 5, 12, 13, 15)
@pytest.mark.parametrize('recipient, expected_recipient_normalised', [
('7900900123', '447900900123'),
('+447900 900 123', '447900900123'),

View File

@@ -21,14 +21,16 @@ def test_get_notification_by_id_returns_200(
sample_notification = create_notification(
template=sample_template,
billable_units=billable_units,
sent_by=provider
sent_by=provider,
scheduled_for="2017-05-12 15:15"
)
# another
create_notification(
template=sample_template,
billable_units=billable_units,
sent_by=provider
sent_by=provider,
scheduled_for="2017-06-12 15:15"
)
auth_header = create_authorization_header(service_id=sample_notification.service_id)
@@ -68,7 +70,7 @@ def test_get_notification_by_id_returns_200(
"subject": None,
'sent_at': sample_notification.sent_at,
'completed_at': sample_notification.completed_at(),
'scheduled_for': None,
'scheduled_for': '2017-05-12T14:15:00.000000Z',
'postage': None,
}
@@ -164,7 +166,7 @@ def test_get_notification_by_id_returns_created_by_name_if_notification_created_
assert json_response['created_by_name'] == 'Test User'
def test_get_notifications_returns_none_for_scheduled_for(client, sample_template):
def test_get_notifications_returns_scheduled_for(client, sample_template):
sample_notification_with_reference = create_notification(template=sample_template,
client_reference='some-client-reference',
scheduled_for='2017-05-23 17:15')
@@ -181,7 +183,7 @@ def test_get_notifications_returns_none_for_scheduled_for(client, sample_templat
assert len(json_response['notifications']) == 1
assert json_response['notifications'][0]['id'] == str(sample_notification_with_reference.id)
assert not json_response['notifications'][0]['scheduled_for']
assert json_response['notifications'][0]['scheduled_for'] == "2017-05-23T16:15:00.000000Z"
def test_get_notification_by_reference_nonexistent_reference_returns_no_notifications(client, sample_service):

View File

@@ -2,6 +2,7 @@ import uuid
import pytest
from flask import json
from freezegun import freeze_time
from jsonschema import ValidationError
from app.models import NOTIFICATION_CREATED, EMAIL_TYPE
@@ -256,3 +257,66 @@ def valid_email_response():
},
"scheduled_for": ""
}
@pytest.mark.parametrize("schema",
[post_email_request_schema, post_sms_request_schema])
@freeze_time("2017-05-12 13:00:00")
def test_post_schema_valid_scheduled_for(schema):
j = {"template_id": str(uuid.uuid4()),
"scheduled_for": "2017-05-12 13:15"}
if schema == post_email_request_schema:
j.update({"email_address": "joe@gmail.com"})
else:
j.update({"phone_number": "07515111111"})
assert validate(j, schema) == j
@pytest.mark.parametrize("invalid_datetime",
["13:00:00 2017-01-01",
"2017-31-12 13:00:00",
"01-01-2017T14:00:00.0000Z"
])
@pytest.mark.parametrize("schema",
[post_email_request_schema, post_sms_request_schema])
def test_post_email_schema_invalid_scheduled_for(invalid_datetime, schema):
j = {"template_id": str(uuid.uuid4()),
"scheduled_for": invalid_datetime}
if schema == post_email_request_schema:
j.update({"email_address": "joe@gmail.com"})
else:
j.update({"phone_number": "07515111111"})
with pytest.raises(ValidationError) as e:
validate(j, schema)
error = json.loads(str(e.value))
assert error['status_code'] == 400
assert error['errors'] == [{'error': 'ValidationError',
'message': "scheduled_for datetime format is invalid. "
"It must be a valid ISO8601 date time format, "
"https://en.wikipedia.org/wiki/ISO_8601"}]
@freeze_time("2017-05-12 13:00:00")
def test_scheduled_for_raises_validation_error_when_in_the_past():
j = {"phone_number": "07515111111",
"template_id": str(uuid.uuid4()),
"scheduled_for": "2017-05-12 10:00"}
with pytest.raises(ValidationError) as e:
validate(j, post_sms_request_schema)
error = json.loads(str(e.value))
assert error['status_code'] == 400
assert error['errors'] == [{'error': 'ValidationError',
'message': "scheduled_for datetime can not be in the past"}]
@freeze_time("2017-05-12 13:00:00")
def test_scheduled_for_raises_validation_error_when_more_than_24_hours_in_the_future():
j = {"phone_number": "07515111111",
"template_id": str(uuid.uuid4()),
"scheduled_for": "2017-05-13 14:00"}
with pytest.raises(ValidationError) as e:
validate(j, post_sms_request_schema)
error = json.loads(str(e.value))
assert error['status_code'] == 400
assert error['errors'] == [{'error': 'ValidationError',
'message': "scheduled_for datetime can only be 24 hours in the future"}]

View File

@@ -3,12 +3,15 @@ from unittest import mock
from unittest.mock import call
import pytest
from freezegun import freeze_time
from boto.exception import SQSError
from app.dao.service_sms_sender_dao import dao_update_service_sms_sender
from app.models import (
ScheduledNotification,
EMAIL_TYPE,
NOTIFICATION_CREATED,
SCHEDULE_NOTIFICATIONS,
SMS_TYPE,
INTERNATIONAL_SMS_TYPE
)
@@ -606,6 +609,56 @@ def test_post_sms_should_persist_supplied_sms_number(client, sample_template_wit
assert mocked.called
@pytest.mark.parametrize("notification_type, key_send_to, send_to",
[("sms", "phone_number", "07700 900 855"),
("email", "email_address", "sample@email.com")])
@freeze_time("2017-05-14 14:00:00")
def test_post_notification_with_scheduled_for(
client, notify_db_session, notification_type, key_send_to, send_to
):
service = create_service(service_name=str(uuid.uuid4()),
service_permissions=[EMAIL_TYPE, SMS_TYPE, SCHEDULE_NOTIFICATIONS])
template = create_template(service=service, template_type=notification_type)
data = {
key_send_to: send_to,
'template_id': str(template.id) if notification_type == EMAIL_TYPE else str(template.id),
'scheduled_for': '2017-05-14 14:15'
}
auth_header = create_authorization_header(service_id=service.id)
response = client.post('/v2/notifications/{}'.format(notification_type),
data=json.dumps(data),
headers=[('Content-Type', 'application/json'), auth_header])
assert response.status_code == 201
resp_json = json.loads(response.get_data(as_text=True))
scheduled_notification = ScheduledNotification.query.filter_by(notification_id=resp_json["id"]).all()
assert len(scheduled_notification) == 1
assert resp_json["id"] == str(scheduled_notification[0].notification_id)
assert resp_json["scheduled_for"] == '2017-05-14 14:15'
@pytest.mark.parametrize("notification_type, key_send_to, send_to",
[("sms", "phone_number", "07700 900 855"),
("email", "email_address", "sample@email.com")])
@freeze_time("2017-05-14 14:00:00")
def test_post_notification_raises_bad_request_if_service_not_invited_to_schedule(
client, sample_template, sample_email_template, notification_type, key_send_to, send_to):
data = {
key_send_to: send_to,
'template_id': str(sample_email_template.id) if notification_type == EMAIL_TYPE else str(sample_template.id),
'scheduled_for': '2017-05-14 14:15'
}
auth_header = create_authorization_header(service_id=sample_template.service_id)
response = client.post('/v2/notifications/{}'.format(notification_type),
data=json.dumps(data),
headers=[('Content-Type', 'application/json'), auth_header])
assert response.status_code == 400
error_json = json.loads(response.get_data(as_text=True))
assert error_json['errors'] == [
{"error": "BadRequestError", "message": 'Cannot schedule notifications (this feature is invite-only)'}]
def test_post_notification_raises_bad_request_if_not_valid_notification_type(client, sample_service):
auth_header = create_authorization_header(service_id=sample_service.id)
response = client.post(

View File

@@ -4,7 +4,7 @@ from sqlalchemy.exc import DataError
@pytest.fixture(scope='function')
def app_for_test(mocker):
def app_for_test():
import flask
from flask import Blueprint
from app.authentication.auth import AuthError