Merge branch 'master' into pyup-update-monotonic-1.2-to-1.3

This commit is contained in:
Leo Hemsted
2017-07-31 18:34:44 +01:00
committed by GitHub
42 changed files with 952 additions and 236 deletions

2
.gitignore vendored
View File

@@ -69,7 +69,5 @@ environment.sh
celerybeat-schedule celerybeat-schedule
wheelhouse/
# CloudFoundry # CloudFoundry
.cf .cf

View File

@@ -80,8 +80,7 @@ generate-version-file: ## Generates the app version file
.PHONY: build .PHONY: build
build: dependencies generate-version-file ## Build project build: dependencies generate-version-file ## Build project
rm -rf wheelhouse . venv/bin/activate && PIP_ACCEL_CACHE=${PIP_ACCEL_CACHE} pip-accel install -r requirements.txt
. venv/bin/activate && PIP_ACCEL_CACHE=${PIP_ACCEL_CACHE} pip-accel wheel --wheel-dir=wheelhouse -r requirements.txt
.PHONY: cf-build .PHONY: cf-build
cf-build: dependencies generate-version-file ## Build project for PAAS cf-build: dependencies generate-version-file ## Build project for PAAS
@@ -260,7 +259,7 @@ clean-docker-containers: ## Clean up any remaining docker containers
.PHONY: clean .PHONY: clean
clean: clean:
rm -rf node_modules cache target venv .coverage build tests/.cache wheelhouse rm -rf node_modules cache target venv .coverage build tests/.cache
.PHONY: cf-login .PHONY: cf-login
cf-login: ## Log in to Cloud Foundry cf-login: ## Log in to Cloud Foundry

View File

@@ -52,13 +52,18 @@ def restrict_ip_sms():
ip_list = ip_route.split(',') ip_list = ip_route.split(',')
if len(ip_list) >= 3: if len(ip_list) >= 3:
ip = ip_list[len(ip_list) - 3] ip = ip_list[len(ip_list) - 3]
current_app.logger.info("Inbound sms ip route list {}".format(ip_route)) current_app.logger.info("Inbound sms ip route list {}"
.format(ip_route))
# Temporary custom header for route security - to experiment if the header passes through
if request.headers.get("X-Custom-forwarder"):
current_app.logger.info("X-Custom-forwarder {}".format(request.headers.get("X-Custom-forwarder")))
if ip in current_app.config.get('SMS_INBOUND_WHITELIST'): if ip in current_app.config.get('SMS_INBOUND_WHITELIST'):
current_app.logger.info("Inbound sms ip addresses {} passed ".format(ip)) current_app.logger.info("Inbound sms ip addresses {} passed ".format(ip))
return return
else: else:
current_app.logger.info("Inbound sms ip addresses {} blocked ".format(ip)) current_app.logger.info("Inbound sms ip addresses blocked {}".format(ip))
return return
# raise AuthError('Unknown source IP address from the SMS provider', 403) # raise AuthError('Unknown source IP address from the SMS provider', 403)

View File

@@ -9,9 +9,17 @@ from sqlalchemy.exc import SQLAlchemyError
from app.aws import s3 from app.aws import s3
from app import notify_celery from app import notify_celery
from app import performance_platform_client from app import performance_platform_client
from app.dao.date_util import get_month_start_end_date
from app.dao.inbound_sms_dao import delete_inbound_sms_created_more_than_a_week_ago from app.dao.inbound_sms_dao import delete_inbound_sms_created_more_than_a_week_ago
from app.dao.invited_user_dao import delete_invitations_created_more_than_two_days_ago from app.dao.invited_user_dao import delete_invitations_created_more_than_two_days_ago
from app.dao.jobs_dao import dao_set_scheduled_jobs_to_pending, dao_get_jobs_older_than_limited_by from app.dao.jobs_dao import (
dao_set_scheduled_jobs_to_pending,
dao_get_jobs_older_than_limited_by
)
from app.dao.monthly_billing_dao import (
get_service_ids_that_need_sms_billing_populated,
create_or_update_monthly_billing_sms
)
from app.dao.notifications_dao import ( from app.dao.notifications_dao import (
dao_timeout_notifications, dao_timeout_notifications,
is_delivery_slow_for_provider, is_delivery_slow_for_provider,
@@ -281,3 +289,14 @@ def delete_dvla_response_files_older_than_seven_days():
except SQLAlchemyError as e: except SQLAlchemyError as e:
current_app.logger.exception("Failed to delete dvla response files") current_app.logger.exception("Failed to delete dvla response files")
raise raise
@notify_celery.task(name="populate_monthly_billing")
@statsd(namespace="tasks")
def populate_monthly_billing():
# for every service with billable units this month update billing totals for yesterday
# this will overwrite the existing amount.
yesterday = datetime.utcnow() - timedelta(days=1)
start_date, end_date = get_month_start_end_date(yesterday)
services = get_service_ids_that_need_sms_billing_populated(start_date=start_date, end_date=end_date)
[create_or_update_monthly_billing_sms(service_id=s.service_id, billing_month=yesterday) for s in services]

View File

@@ -5,7 +5,8 @@ from flask.ext.script import Command, Manager, Option
from app import db from app import db
from app.models import (PROVIDERS, Service, User, NotificationHistory) from app.dao.monthly_billing_dao import create_or_update_monthly_billing_sms, get_monthly_billing_sms
from app.models import (PROVIDERS, User)
from app.dao.services_dao import ( from app.dao.services_dao import (
delete_service_and_all_associated_db_objects, delete_service_and_all_associated_db_objects,
dao_fetch_all_services_by_user dao_fetch_all_services_by_user
@@ -146,3 +147,19 @@ class CustomDbScript(Command):
print('Committed {} updates at {}'.format(len(result), datetime.utcnow())) print('Committed {} updates at {}'.format(len(result), datetime.utcnow()))
db.session.commit() db.session.commit()
result = db.session.execute(subq_hist).fetchall() result = db.session.execute(subq_hist).fetchall()
class PopulateMonthlyBilling(Command):
option_list = (
Option('-s', '-service-id', dest='service_id',
help="Service id to populate monthly billing for"),
Option('-m', '-month', dest="month", help="Use for integer value for month, e.g. 7 for July"),
Option('-y', '-year', dest="year", help="Use for integer value for year, e.g. 2017")
)
def run(self, service_id, month, year):
print('Starting populating monthly billing')
create_or_update_monthly_billing_sms(service_id, datetime(int(year), int(month), 1))
results = get_monthly_billing_sms(service_id, datetime(int(year), int(month), 1))
print("Finished populating data for {} for service id {}".format(month, service_id))
print(results.monthly_totals)

View File

@@ -22,7 +22,6 @@ class QueueNames(object):
PERIODIC = 'periodic-tasks' PERIODIC = 'periodic-tasks'
PRIORITY = 'priority-tasks' PRIORITY = 'priority-tasks'
DATABASE = 'database-tasks' DATABASE = 'database-tasks'
SEND_COMBINED = 'send-tasks'
SEND_SMS = 'send-sms-tasks' SEND_SMS = 'send-sms-tasks'
SEND_EMAIL = 'send-email-tasks' SEND_EMAIL = 'send-email-tasks'
RESEARCH_MODE = 'research-mode-tasks' RESEARCH_MODE = 'research-mode-tasks'
@@ -38,7 +37,6 @@ class QueueNames(object):
QueueNames.PRIORITY, QueueNames.PRIORITY,
QueueNames.PERIODIC, QueueNames.PERIODIC,
QueueNames.DATABASE, QueueNames.DATABASE,
QueueNames.SEND_COMBINED,
QueueNames.SEND_SMS, QueueNames.SEND_SMS,
QueueNames.SEND_EMAIL, QueueNames.SEND_EMAIL,
QueueNames.RESEARCH_MODE, QueueNames.RESEARCH_MODE,
@@ -115,7 +113,6 @@ class Config(object):
PAGE_SIZE = 50 PAGE_SIZE = 50
API_PAGE_SIZE = 250 API_PAGE_SIZE = 250
SMS_CHAR_COUNT_LIMIT = 495 SMS_CHAR_COUNT_LIMIT = 495
BRANDING_PATH = '/images/email-template/crests/'
TEST_MESSAGE_FILENAME = 'Test message' TEST_MESSAGE_FILENAME = 'Test message'
ONE_OFF_MESSAGE_FILENAME = 'Report' ONE_OFF_MESSAGE_FILENAME = 'Report'
MAX_VERIFY_CODE_COUNT = 10 MAX_VERIFY_CODE_COUNT = 10

View File

@@ -2,6 +2,8 @@ from datetime import datetime, timedelta
import pytz import pytz
from app.utils import convert_bst_to_utc
def get_financial_year(year): def get_financial_year(year):
return get_april_fools(year), get_april_fools(year + 1) - timedelta(microseconds=1) return get_april_fools(year), get_april_fools(year + 1) - timedelta(microseconds=1)
@@ -16,3 +18,16 @@ def get_april_fools(year):
""" """
return pytz.timezone('Europe/London').localize(datetime(year, 4, 1, 0, 0, 0)).astimezone(pytz.UTC).replace( return pytz.timezone('Europe/London').localize(datetime(year, 4, 1, 0, 0, 0)).astimezone(pytz.UTC).replace(
tzinfo=None) tzinfo=None)
def get_month_start_end_date(month_year):
"""
This function return the start and date of the month_year as UTC,
:param month_year: the datetime to calculate the start and end date for that month
:return: start_date, end_date, month
"""
import calendar
_, num_days = calendar.monthrange(month_year.year, month_year.month)
first_day = datetime(month_year.year, month_year.month, 1, 0, 0, 0)
last_day = datetime(month_year.year, month_year.month, num_days, 23, 59, 59, 99999)
return convert_bst_to_utc(first_day), convert_bst_to_utc(last_day)

View File

@@ -0,0 +1,58 @@
from datetime import datetime
from app import db
from app.dao.dao_utils import transactional
from app.dao.date_util import get_month_start_end_date
from app.dao.notification_usage_dao import get_billing_data_for_month
from app.models import MonthlyBilling, SMS_TYPE, NotificationHistory
from app.statsd_decorators import statsd
def get_service_ids_that_need_sms_billing_populated(start_date, end_date):
return db.session.query(
NotificationHistory.service_id
).filter(
NotificationHistory.created_at >= start_date,
NotificationHistory.created_at <= end_date,
NotificationHistory.notification_type == SMS_TYPE,
NotificationHistory.billable_units != 0
).distinct().all()
@transactional
def create_or_update_monthly_billing_sms(service_id, billing_month):
start_date, end_date = get_month_start_end_date(billing_month)
monthly = get_billing_data_for_month(service_id=service_id, start_date=start_date, end_date=end_date)
# update monthly
monthly_totals = _monthly_billing_data_to_json(monthly)
row = MonthlyBilling.query.filter_by(start_date=start_date,
notification_type='sms').first()
if row:
row.monthly_totals = monthly_totals
row.updated_at = datetime.utcnow()
else:
row = MonthlyBilling(service_id=service_id,
notification_type=SMS_TYPE,
monthly_totals=monthly_totals,
start_date=start_date,
end_date=end_date)
db.session.add(row)
@statsd(namespace="dao")
def get_monthly_billing_sms(service_id, billing_month):
start_date, end_date = get_month_start_end_date(billing_month)
monthly = MonthlyBilling.query.filter_by(service_id=service_id,
start_date=start_date,
notification_type=SMS_TYPE).first()
return monthly
def _monthly_billing_data_to_json(monthly):
# total cost must take into account the free allowance.
# might be a good idea to capture free allowance in this table
return [{"billing_units": x.billing_units,
"rate_multiplier": x.rate_multiplier,
"international": x.international,
"rate": x.rate,
"total_cost": (x.billing_units * x.rate_multiplier) * x.rate} for x in monthly]

View File

@@ -6,7 +6,7 @@ from sqlalchemy import func, case, cast
from sqlalchemy import literal_column from sqlalchemy import literal_column
from app import db from app import db
from app.dao.date_util import get_financial_year from app.dao.date_util import get_financial_year, get_month_start_end_date
from app.models import (NotificationHistory, from app.models import (NotificationHistory,
Rate, Rate,
NOTIFICATION_STATUS_TYPES_BILLABLE, NOTIFICATION_STATUS_TYPES_BILLABLE,
@@ -20,7 +20,7 @@ from app.utils import get_london_month_from_utc_column
@statsd(namespace="dao") @statsd(namespace="dao")
def get_yearly_billing_data(service_id, year): def get_yearly_billing_data(service_id, year):
start_date, end_date = get_financial_year(year) start_date, end_date = get_financial_year(year)
rates = get_rates_for_year(start_date, end_date, SMS_TYPE) rates = get_rates_for_daterange(start_date, end_date, SMS_TYPE)
def get_valid_from(valid_from): def get_valid_from(valid_from):
return start_date if valid_from < start_date else valid_from return start_date if valid_from < start_date else valid_from
@@ -35,10 +35,24 @@ def get_yearly_billing_data(service_id, year):
return sum(result, []) return sum(result, [])
@statsd(namespace="dao")
def get_billing_data_for_month(service_id, start_date, end_date):
rates = get_rates_for_daterange(start_date, end_date, SMS_TYPE)
result = []
# so the start end date in the query are the valid from the rate, not the month - this is going to take some thought
for r, n in zip(rates, rates[1:]):
result.extend(sms_billing_data_per_month_query(r.rate, service_id, max(r.valid_from, start_date),
min(n.valid_from, end_date)))
result.extend(
sms_billing_data_per_month_query(rates[-1].rate, service_id, max(rates[-1].valid_from, start_date), end_date))
return result
@statsd(namespace="dao") @statsd(namespace="dao")
def get_monthly_billing_data(service_id, year): def get_monthly_billing_data(service_id, year):
start_date, end_date = get_financial_year(year) start_date, end_date = get_financial_year(year)
rates = get_rates_for_year(start_date, end_date, SMS_TYPE) rates = get_rates_for_daterange(start_date, end_date, SMS_TYPE)
result = [] result = []
for r, n in zip(rates, rates[1:]): for r, n in zip(rates, rates[1:]):
@@ -103,7 +117,7 @@ def sms_yearly_billing_data_query(rate, service_id, start_date, end_date):
return result return result
def get_rates_for_year(start_date, end_date, notification_type): def get_rates_for_daterange(start_date, end_date, notification_type):
rates = Rate.query.filter(Rate.notification_type == notification_type).order_by(Rate.valid_from).all() rates = Rate.query.filter(Rate.notification_type == notification_type).order_by(Rate.valid_from).all()
results = [] results = []
for current_rate, current_rate_expiry_date in zip(rates, rates[1:]): for current_rate, current_rate_expiry_date in zip(rates, rates[1:]):
@@ -115,8 +129,10 @@ def get_rates_for_year(start_date, end_date, notification_type):
results.append(rates[-1]) results.append(rates[-1])
if not results: if not results:
if start_date >= rates[-1].valid_from: for x in reversed(rates):
results.append(rates[-1]) if start_date >= x.valid_from:
results.append(x)
break
return results return results
@@ -128,12 +144,12 @@ def is_between(date, start_date, end_date):
def sms_billing_data_per_month_query(rate, service_id, start_date, end_date): def sms_billing_data_per_month_query(rate, service_id, start_date, end_date):
month = get_london_month_from_utc_column(NotificationHistory.created_at) month = get_london_month_from_utc_column(NotificationHistory.created_at)
result = db.session.query( result = db.session.query(
month, month.label('month'),
func.sum(NotificationHistory.billable_units), func.sum(NotificationHistory.billable_units).label('billing_units'),
rate_multiplier(), rate_multiplier().label('rate_multiplier'),
NotificationHistory.international, NotificationHistory.international,
NotificationHistory.notification_type, NotificationHistory.notification_type,
cast(rate, Float()) cast(rate, Float()).label('rate')
).filter( ).filter(
*billing_data_filter(SMS_TYPE, start_date, end_date, service_id) *billing_data_filter(SMS_TYPE, start_date, end_date, service_id)
).group_by( ).group_by(
@@ -193,7 +209,7 @@ def get_total_billable_units_for_sent_sms_notifications_in_date_range(start_date
def discover_rate_bounds_for_billing_query(start_date, end_date): def discover_rate_bounds_for_billing_query(start_date, end_date):
bounds = [] bounds = []
rates = get_rates_for_year(start_date, end_date, SMS_TYPE) rates = get_rates_for_daterange(start_date, end_date, SMS_TYPE)
def current_valid_from(index): def current_valid_from(index):
return rates[index].valid_from return rates[index].valid_from

View File

@@ -18,7 +18,7 @@ from app.dao.templates_dao import dao_get_template_by_id
from app.models import SMS_TYPE, KEY_TYPE_TEST, BRANDING_ORG, EMAIL_TYPE, NOTIFICATION_TECHNICAL_FAILURE, \ from app.models import SMS_TYPE, KEY_TYPE_TEST, BRANDING_ORG, EMAIL_TYPE, NOTIFICATION_TECHNICAL_FAILURE, \
NOTIFICATION_SENT, NOTIFICATION_SENDING NOTIFICATION_SENT, NOTIFICATION_SENDING
from app.celery.statistics_tasks import record_initial_job_statistics, create_initial_notification_statistic_tasks from app.celery.statistics_tasks import create_initial_notification_statistic_tasks
def send_sms_to_provider(notification): def send_sms_to_provider(notification):
@@ -145,34 +145,20 @@ def provider_to_use(notification_type, notification_id, international=False):
return clients.get_client_by_name_and_type(active_providers_in_order[0].identifier, notification_type) return clients.get_client_by_name_and_type(active_providers_in_order[0].identifier, notification_type)
def get_logo_url(base_url, branding_path, logo_file): def get_logo_url(base_url, logo_file):
"""
Get the complete URL for a given logo.
We have to convert the base_url into a static url. Our hosted environments all have their own cloudfront instances,
found at the static subdomain (eg https://static.notifications.service.gov.uk).
If running locally (dev environment), don't try and use cloudfront - just stick to the actual underlying source
({URL}/static/{PATH})
"""
base_url = parse.urlparse(base_url) base_url = parse.urlparse(base_url)
netloc = base_url.netloc netloc = base_url.netloc
# covers both preview and staging if base_url.netloc.startswith('localhost'):
if base_url.netloc.startswith('localhost') or 'notify.works' in base_url.netloc: netloc = 'notify.tools'
path = '/static' + branding_path + logo_file elif base_url.netloc.startswith('www'):
else: # strip "www."
if base_url.netloc.startswith('www'): netloc = base_url.netloc[4:]
# strip "www."
netloc = base_url.netloc[4:]
netloc = 'static.' + netloc
path = branding_path + logo_file
logo_url = parse.ParseResult( logo_url = parse.ParseResult(
scheme=base_url.scheme, scheme=base_url.scheme,
netloc=netloc, netloc='static-logos.' + netloc,
path=path, path=logo_file,
params=base_url.params, params=base_url.params,
query=base_url.query, query=base_url.query,
fragment=base_url.fragment fragment=base_url.fragment
@@ -185,7 +171,6 @@ def get_html_email_options(service):
if service.organisation: if service.organisation:
logo_url = get_logo_url( logo_url = get_logo_url(
current_app.config['ADMIN_BASE_URL'], current_app.config['ADMIN_BASE_URL'],
current_app.config['BRANDING_PATH'],
service.organisation.logo service.organisation.logo
) )

View File

@@ -4,7 +4,6 @@ import datetime
from flask import url_for, current_app from flask import url_for, current_app
from sqlalchemy.ext.associationproxy import association_proxy from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.dialects.postgresql import ( from sqlalchemy.dialects.postgresql import (
UUID, UUID,
JSON JSON
@@ -1246,3 +1245,29 @@ class LetterRateDetail(db.Model):
letter_rate = db.relationship('LetterRate', backref='letter_rates') letter_rate = db.relationship('LetterRate', backref='letter_rates')
page_total = db.Column(db.Integer, nullable=False) page_total = db.Column(db.Integer, nullable=False)
rate = db.Column(db.Numeric(), nullable=False) rate = db.Column(db.Numeric(), nullable=False)
class MonthlyBilling(db.Model):
__tablename__ = 'monthly_billing'
id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
service_id = db.Column(UUID(as_uuid=True), db.ForeignKey('services.id'), index=True, nullable=False)
service = db.relationship('Service', backref='monthly_billing')
start_date = db.Column(db.DateTime, nullable=False)
end_date = db.Column(db.DateTime, nullable=False)
notification_type = db.Column(notification_types, nullable=False)
monthly_totals = db.Column(JSON, nullable=False)
updated_at = db.Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow)
__table_args__ = (
UniqueConstraint('service_id', 'start_date', 'notification_type', name='uix_monthly_billing'),
)
def serialized(self):
return {
"start_date": self.start_date,
"end_date": self.end_date,
"service_id": str(self.service_id),
"notification_type": self.notification_type,
"monthly_totals": self.monthly_totals
}

View File

@@ -36,6 +36,7 @@ def check_placeholders(template_object):
def persist_notification( def persist_notification(
*,
template_id, template_id,
template_version, template_version,
recipient, recipient,

View File

@@ -9,7 +9,7 @@ from notifications_utils.clients.redis import rate_limit_cache_key, daily_limit_
from app.dao import services_dao, templates_dao from app.dao import services_dao, templates_dao
from app.models import ( from app.models import (
INTERNATIONAL_SMS_TYPE, SMS_TYPE, INTERNATIONAL_SMS_TYPE, SMS_TYPE, EMAIL_TYPE,
KEY_TYPE_TEST, KEY_TYPE_TEAM, SCHEDULE_NOTIFICATIONS KEY_TYPE_TEST, KEY_TYPE_TEAM, SCHEDULE_NOTIFICATIONS
) )
from app.service.utils import service_allowed_to_send_to from app.service.utils import service_allowed_to_send_to
@@ -104,7 +104,7 @@ def validate_and_format_recipient(send_to, key_type, service, notification_type)
number=send_to, number=send_to,
international=international_phone_info.international international=international_phone_info.international
) )
else: elif notification_type == EMAIL_TYPE:
return validate_and_format_email_address(email_address=send_to) return validate_and_format_email_address(email_address=send_to)

View File

@@ -20,6 +20,9 @@ personalisation = {
} }
letter_personalisation = dict(personalisation, required=["address_line_1", "postcode"])
https_url = { https_url = {
"type": "string", "type": "string",
"format": "uri", "format": "uri",

View File

@@ -68,7 +68,6 @@ from app.schemas import (
user_schema, user_schema,
permission_schema, permission_schema,
notification_with_template_schema, notification_with_template_schema,
notification_with_personalisation_schema,
notifications_filter_schema, notifications_filter_schema,
detailed_service_schema detailed_service_schema
) )

View File

@@ -0,0 +1,45 @@
def create_post_sms_response_from_notification(notification, content, from_number, url_root, scheduled_for):
noti = __create_notification_response(notification, url_root, scheduled_for)
noti['content'] = {
'from_number': from_number,
'body': content
}
return noti
def create_post_email_response_from_notification(notification, content, subject, email_from, url_root, scheduled_for):
noti = __create_notification_response(notification, url_root, scheduled_for)
noti['content'] = {
"from_email": email_from,
"body": content,
"subject": subject
}
return noti
def create_post_letter_response_from_notification(notification, content, subject, url_root, scheduled_for):
noti = __create_notification_response(notification, url_root, scheduled_for)
noti['content'] = {
"body": content,
"subject": subject
}
return noti
def __create_notification_response(notification, url_root, scheduled_for):
return {
"id": notification.id,
"reference": notification.client_reference,
"uri": "{}v2/notifications/{}".format(url_root, str(notification.id)),
'template': {
"id": notification.template_id,
"version": notification.template_version,
"uri": "{}services/{}/templates/{}".format(
url_root,
str(notification.service_id),
str(notification.template_id)
)
},
"scheduled_for": scheduled_for if scheduled_for else None
}

View File

@@ -1,5 +1,5 @@
from app.models import NOTIFICATION_STATUS_TYPES, TEMPLATE_TYPES from app.models import NOTIFICATION_STATUS_TYPES, TEMPLATE_TYPES
from app.schema_validation.definitions import (uuid, personalisation) from app.schema_validation.definitions import (uuid, personalisation, letter_personalisation)
template = { template = {
@@ -192,40 +192,43 @@ post_email_response = {
} }
def create_post_sms_response_from_notification(notification, body, from_number, url_root, service_id, scheduled_for): post_letter_request = {
return {"id": notification.id, "$schema": "http://json-schema.org/draft-04/schema#",
"reference": notification.client_reference, "description": "POST letter notification schema",
"content": {'body': body, "type": "object",
'from_number': from_number}, "title": "POST v2/notifications/letter",
"uri": "{}v2/notifications/{}".format(url_root, str(notification.id)), "properties": {
"template": __create_template_from_notification(notification=notification, "reference": {"type": "string"},
url_root=url_root, "template_id": uuid,
service_id=service_id), "personalisation": letter_personalisation
"scheduled_for": scheduled_for if scheduled_for else None },
} "required": ["template_id", "personalisation"]
}
letter_content = {
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Letter content for POST letter notification",
"type": "object",
"title": "notification letter content",
"properties": {
"body": {"type": "string"},
"subject": {"type": "string"}
},
"required": ["body", "subject"]
}
def create_post_email_response_from_notification(notification, content, subject, email_from, url_root, service_id, post_letter_response = {
scheduled_for): "$schema": "http://json-schema.org/draft-04/schema#",
return { "description": "POST sms notification response schema",
"id": notification.id, "type": "object",
"reference": notification.client_reference, "title": "response v2/notifications/letter",
"content": { "properties": {
"from_email": email_from, "id": uuid,
"body": content, "reference": {"type": ["string", "null"]},
"subject": subject "content": letter_content,
}, "uri": {"type": "string", "format": "uri"},
"uri": "{}v2/notifications/{}".format(url_root, str(notification.id)), "template": template,
"template": __create_template_from_notification(notification=notification, "scheduled_for": {"type": ["string", "null"]}
url_root=url_root, },
service_id=service_id), "required": ["id", "content", "uri", "template"]
"scheduled_for": scheduled_for if scheduled_for else None }
}
def __create_template_from_notification(notification, url_root, service_id):
return {
"id": notification.template_id,
"version": notification.template_version,
"uri": "{}services/{}/templates/{}".format(url_root, str(service_id), str(notification.template_id))
}

View File

@@ -1,8 +1,10 @@
from flask import request, jsonify, current_app import functools
from flask import request, jsonify, current_app, abort
from app import api_user, authenticated_service from app import api_user, authenticated_service
from app.config import QueueNames from app.config import QueueNames
from app.models import SMS_TYPE, EMAIL_TYPE, PRIORITY, SCHEDULE_NOTIFICATIONS from app.models import SMS_TYPE, EMAIL_TYPE, LETTER_TYPE, PRIORITY
from app.notifications.process_notifications import ( from app.notifications.process_notifications import (
persist_notification, persist_notification,
send_notification_to_queue, send_notification_to_queue,
@@ -11,28 +13,34 @@ from app.notifications.process_notifications import (
from app.notifications.validators import ( from app.notifications.validators import (
validate_and_format_recipient, validate_and_format_recipient,
check_rate_limiting, check_rate_limiting,
service_has_permission,
check_service_can_schedule_notification, check_service_can_schedule_notification,
check_service_has_permission, check_service_has_permission,
validate_template validate_template
) )
from app.schema_validation import validate from app.schema_validation import validate
from app.utils import get_public_notify_type_text
from app.v2.notifications import v2_notification_blueprint from app.v2.notifications import v2_notification_blueprint
from app.v2.notifications.notification_schemas import ( from app.v2.notifications.notification_schemas import (
post_sms_request, post_sms_request,
create_post_sms_response_from_notification,
post_email_request, post_email_request,
create_post_email_response_from_notification) post_letter_request
from app.v2.errors import BadRequestError )
from app.v2.notifications.create_response import (
create_post_sms_response_from_notification,
create_post_email_response_from_notification,
create_post_letter_response_from_notification
)
@v2_notification_blueprint.route('/<notification_type>', methods=['POST']) @v2_notification_blueprint.route('/<notification_type>', methods=['POST'])
def post_notification(notification_type): def post_notification(notification_type):
if notification_type == EMAIL_TYPE: if notification_type == EMAIL_TYPE:
form = validate(request.get_json(), post_email_request) form = validate(request.get_json(), post_email_request)
else: elif notification_type == SMS_TYPE:
form = validate(request.get_json(), post_sms_request) form = validate(request.get_json(), post_sms_request)
elif notification_type == LETTER_TYPE:
form = validate(request.get_json(), post_letter_request)
else:
abort(404)
check_service_has_permission(notification_type, authenticated_service.permissions) check_service_has_permission(notification_type, authenticated_service.permissions)
@@ -41,12 +49,6 @@ def post_notification(notification_type):
check_rate_limiting(authenticated_service, api_user) check_rate_limiting(authenticated_service, api_user)
form_send_to = form['phone_number'] if notification_type == SMS_TYPE else form['email_address']
send_to = validate_and_format_recipient(send_to=form_send_to,
key_type=api_user.key_type,
service=authenticated_service,
notification_type=notification_type)
template, template_with_content = validate_template( template, template_with_content = validate_template(
form['template_id'], form['template_id'],
form.get('personalisation', {}), form.get('personalisation', {}),
@@ -54,20 +56,74 @@ def post_notification(notification_type):
notification_type, notification_type,
) )
if notification_type == LETTER_TYPE:
notification = process_letter_notification(
form=form,
api_key=api_user,
template=template,
service=authenticated_service,
)
else:
notification = process_sms_or_email_notification(
form=form,
notification_type=notification_type,
api_key=api_user,
template=template,
service=authenticated_service
)
if notification_type == SMS_TYPE:
sms_sender = authenticated_service.sms_sender or current_app.config.get('FROM_NUMBER')
create_resp_partial = functools.partial(
create_post_sms_response_from_notification,
from_number=sms_sender
)
elif notification_type == EMAIL_TYPE:
create_resp_partial = functools.partial(
create_post_email_response_from_notification,
subject=template_with_content.subject,
email_from=authenticated_service.email_from
)
elif notification_type == LETTER_TYPE:
create_resp_partial = functools.partial(
create_post_letter_response_from_notification,
subject=template_with_content.subject,
)
resp = create_resp_partial(
notification=notification,
content=str(template_with_content),
url_root=request.url_root,
scheduled_for=scheduled_for
)
return jsonify(resp), 201
def process_sms_or_email_notification(*, form, notification_type, api_key, template, service):
form_send_to = form['email_address'] if notification_type == EMAIL_TYPE else form['phone_number']
send_to = validate_and_format_recipient(send_to=form_send_to,
key_type=api_key.key_type,
service=service,
notification_type=notification_type)
# Do not persist or send notification to the queue if it is a simulated recipient # Do not persist or send notification to the queue if it is a simulated recipient
simulated = simulated_recipient(send_to, notification_type) simulated = simulated_recipient(send_to, notification_type)
notification = persist_notification(template_id=template.id, notification = persist_notification(
template_version=template.version, template_id=template.id,
recipient=form_send_to, template_version=template.version,
service=authenticated_service, recipient=form_send_to,
personalisation=form.get('personalisation', None), service=service,
notification_type=notification_type, personalisation=form.get('personalisation', None),
api_key_id=api_user.id, notification_type=notification_type,
key_type=api_user.key_type, api_key_id=api_key.id,
client_reference=form.get('reference', None), key_type=api_key.key_type,
simulated=simulated) client_reference=form.get('reference', None),
simulated=simulated
)
scheduled_for = form.get("scheduled_for", None)
if scheduled_for: if scheduled_for:
persist_scheduled_notification(notification.id, form["scheduled_for"]) persist_scheduled_notification(notification.id, form["scheduled_for"])
else: else:
@@ -75,26 +131,19 @@ def post_notification(notification_type):
queue_name = QueueNames.PRIORITY if template.process_type == PRIORITY else None queue_name = QueueNames.PRIORITY if template.process_type == PRIORITY else None
send_notification_to_queue( send_notification_to_queue(
notification=notification, notification=notification,
research_mode=authenticated_service.research_mode, research_mode=service.research_mode,
queue=queue_name queue=queue_name
) )
else: else:
current_app.logger.info("POST simulated notification for id: {}".format(notification.id)) current_app.logger.info("POST simulated notification for id: {}".format(notification.id))
if notification_type == SMS_TYPE: return notification
sms_sender = authenticated_service.sms_sender or current_app.config.get('FROM_NUMBER')
resp = create_post_sms_response_from_notification(notification=notification,
body=str(template_with_content), def process_letter_notification(*, form, api_key, template, service):
from_number=sms_sender, # create job
url_root=request.url_root,
service_id=authenticated_service.id, # create notification
scheduled_for=scheduled_for)
else: # trigger build_dvla_file task
resp = create_post_email_response_from_notification(notification=notification, raise NotImplementedError
content=str(template_with_content),
subject=template_with_content.subject,
email_from=authenticated_service.email_from,
url_root=request.url_root,
service_id=authenticated_service.id,
scheduled_for=scheduled_for)
return jsonify(resp), 201

View File

@@ -16,6 +16,7 @@ manager.add_command('db', MigrateCommand)
manager.add_command('create_provider_rate', commands.CreateProviderRateCommand) manager.add_command('create_provider_rate', commands.CreateProviderRateCommand)
manager.add_command('purge_functional_test_data', commands.PurgeFunctionalTestDataCommand) manager.add_command('purge_functional_test_data', commands.PurgeFunctionalTestDataCommand)
manager.add_command('custom_db_script', commands.CustomDbScript) manager.add_command('custom_db_script', commands.CustomDbScript)
manager.add_command('populate_monthly_billing', commands.PopulateMonthlyBilling)
@manager.command @manager.command

View File

@@ -1,11 +0,0 @@
#!/usr/bin/env python
from app import notify_celery, create_app
from credstash import getAllSecrets
import os
# On AWS get secrets and export to env, skip this on Cloud Foundry
if os.getenv('VCAP_SERVICES') is None:
os.environ.update(getAllSecrets(region="eu-west-1"))
application = create_app("delivery")
application.app_context().push()

8
db.py
View File

@@ -1,12 +1,8 @@
from flask.ext.script import Manager, Server from flask.ext.script import Manager, Server
from flask_migrate import Migrate, MigrateCommand from flask_migrate import Migrate, MigrateCommand
from app import create_app, db
from credstash import getAllSecrets
import os
# On AWS get secrets and export to env, skip this on Cloud Foundry from app import create_app, db
if os.getenv('VCAP_SERVICES') is None:
os.environ.update(getAllSecrets(region="eu-west-1"))
application = create_app() application = create_app()

View File

@@ -16,39 +16,39 @@ memory: 1G
applications: applications:
- name: notify-delivery-celery-beat - name: notify-delivery-celery-beat
command: scripts/run_app_paas.sh celery -A aws_run_celery.notify_celery beat --loglevel=INFO command: scripts/run_app_paas.sh celery -A run_celery.notify_celery beat --loglevel=INFO
instances: 1 instances: 1
memory: 128M memory: 128M
env: env:
NOTIFY_APP_NAME: delivery-celery-beat NOTIFY_APP_NAME: delivery-celery-beat
- name: notify-delivery-worker-database - name: notify-delivery-worker-database
command: scripts/run_app_paas.sh celery -A aws_run_celery.notify_celery worker --loglevel=INFO --concurrency=11 -Q database-tasks command: scripts/run_app_paas.sh celery -A run_celery.notify_celery worker --loglevel=INFO --concurrency=11 -Q database-tasks
env: env:
NOTIFY_APP_NAME: delivery-worker-database NOTIFY_APP_NAME: delivery-worker-database
- name: notify-delivery-worker-research - name: notify-delivery-worker-research
command: scripts/run_app_paas.sh celery -A aws_run_celery.notify_celery worker --loglevel=INFO --concurrency=5 -Q research-mode-tasks command: scripts/run_app_paas.sh celery -A run_celery.notify_celery worker --loglevel=INFO --concurrency=5 -Q research-mode-tasks
env: env:
NOTIFY_APP_NAME: delivery-worker-research NOTIFY_APP_NAME: delivery-worker-research
- name: notify-delivery-worker-sender - name: notify-delivery-worker-sender
command: scripts/run_app_paas.sh celery -A aws_run_celery.notify_celery worker --loglevel=INFO --concurrency=11 -Q send-tasks,send-sms-tasks,send-email-tasks command: scripts/run_app_paas.sh celery -A run_celery.notify_celery worker --loglevel=INFO --concurrency=11 -Q send-sms-tasks,send-email-tasks
env: env:
NOTIFY_APP_NAME: delivery-worker-sender NOTIFY_APP_NAME: delivery-worker-sender
- name: notify-delivery-worker-periodic - name: notify-delivery-worker-periodic
command: scripts/run_app_paas.sh celery -A aws_run_celery.notify_celery worker --loglevel=INFO --concurrency=2 -Q periodic-tasks,statistics-tasks command: scripts/run_app_paas.sh celery -A run_celery.notify_celery worker --loglevel=INFO --concurrency=2 -Q periodic-tasks,statistics-tasks
instances: 1 instances: 1
env: env:
NOTIFY_APP_NAME: delivery-worker-periodic NOTIFY_APP_NAME: delivery-worker-periodic
- name: notify-delivery-worker-priority - name: notify-delivery-worker-priority
command: scripts/run_app_paas.sh celery -A aws_run_celery.notify_celery worker --loglevel=INFO --concurrency=5 -Q priority-tasks command: scripts/run_app_paas.sh celery -A run_celery.notify_celery worker --loglevel=INFO --concurrency=5 -Q priority-tasks
env: env:
NOTIFY_APP_NAME: delivery-worker-priority NOTIFY_APP_NAME: delivery-worker-priority
- name: notify-delivery-worker - name: notify-delivery-worker
command: scripts/run_app_paas.sh celery -A aws_run_celery.notify_celery worker --loglevel=INFO --concurrency=11 -Q job-tasks,retry-tasks,notify-internal-tasks command: scripts/run_app_paas.sh celery -A run_celery.notify_celery worker --loglevel=INFO --concurrency=11 -Q job-tasks,retry-tasks,notify-internal-tasks
env: env:
NOTIFY_APP_NAME: delivery-worker NOTIFY_APP_NAME: delivery-worker

View File

@@ -0,0 +1,38 @@
"""empty message
Revision ID: 0110_monthly_billing
Revises: 0109_rem_old_noti_status
Create Date: 2017-07-13 14:35:03.183659
"""
# revision identifiers, used by Alembic.
revision = '0110_monthly_billing'
down_revision = '0109_rem_old_noti_status'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
def upgrade():
op.create_table('monthly_billing',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('service_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('month', sa.String(), nullable=False),
sa.Column('year', sa.Float(), nullable=False),
sa.Column('notification_type',
postgresql.ENUM('email', 'sms', 'letter', name='notification_type', create_type=False),
nullable=False),
sa.Column('monthly_totals', postgresql.JSON(), nullable=False),
sa.Column('updated_at', sa.DateTime, nullable=False),
sa.ForeignKeyConstraint(['service_id'], ['services.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_monthly_billing_service_id'), 'monthly_billing', ['service_id'], unique=False)
op.create_index(op.f('uix_monthly_billing'), 'monthly_billing', ['service_id', 'month', 'year', 'notification_type'], unique=True)
def downgrade():
op.drop_table('monthly_billing')

View File

@@ -0,0 +1,28 @@
"""empty message
Revision ID: 0111_drop_old_service_flags
Revises: 0110_monthly_billing
Create Date: 2017-07-12 13:35:45.636618
"""
# revision identifiers, used by Alembic.
revision = '0111_drop_old_service_flags'
down_revision = '0110_monthly_billing'
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
def upgrade():
op.drop_column('services', 'can_send_letters')
op.drop_column('services', 'can_send_international_sms')
op.drop_column('services_history', 'can_send_letters')
op.drop_column('services_history', 'can_send_international_sms')
def downgrade():
op.add_column('services_history', sa.Column('can_send_international_sms', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False))
op.add_column('services_history', sa.Column('can_send_letters', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False))
op.add_column('services', sa.Column('can_send_international_sms', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False))
op.add_column('services', sa.Column('can_send_letters', sa.BOOLEAN(), server_default=sa.text('false'), autoincrement=False, nullable=False))

View File

@@ -0,0 +1,43 @@
"""empty message
Revision ID: 0112_add_start_end_dates
Revises: 0111_drop_old_service_flags
Create Date: 2017-07-12 13:35:45.636618
"""
from datetime import datetime
from alembic import op
import sqlalchemy as sa
from app.dao.date_util import get_month_start_end_date
down_revision = '0111_drop_old_service_flags'
revision = '0112_add_start_end_dates'
def upgrade():
op.drop_index('uix_monthly_billing', 'monthly_billing')
op.alter_column('monthly_billing', 'month', nullable=True)
op.alter_column('monthly_billing', 'year', nullable=True)
op.add_column('monthly_billing', sa.Column('start_date', sa.DateTime))
op.add_column('monthly_billing', sa.Column('end_date', sa.DateTime))
conn = op.get_bind()
results = conn.execute("Select id, month, year from monthly_billing")
res = results.fetchall()
for x in res:
start_date, end_date = get_month_start_end_date(
datetime(int(x.year), datetime.strptime(x.month, '%B').month, 1))
conn.execute("update monthly_billing set start_date = '{}', end_date = '{}' where id = '{}'".format(start_date,
end_date,
x.id))
op.alter_column('monthly_billing', 'start_date', nullable=False)
op.alter_column('monthly_billing', 'end_date', nullable=False)
op.create_index(op.f('uix_monthly_billing'), 'monthly_billing', ['service_id', 'start_date', 'notification_type'],
unique=True)
def downgrade():
op.drop_column('monthly_billing', 'start_date')
op.drop_column('monthly_billing', 'end_date')
op.create_index(op.f('uix_monthly_billing'), 'monthly_billing',
['service_id', 'month', 'year', 'notification_type'], unique=True)

View File

@@ -11,9 +11,7 @@ marshmallow==2.4.2
marshmallow-sqlalchemy==0.8.0 marshmallow-sqlalchemy==0.8.0
flask-marshmallow==0.6.2 flask-marshmallow==0.6.2
Flask-Bcrypt==0.6.2 Flask-Bcrypt==0.6.2
credstash==1.8.0
boto3==1.4.4 boto3==1.4.4
celery==3.1.25
monotonic==1.3 monotonic==1.3
statsd==3.2.1 statsd==3.2.1
jsonschema==2.5.1 jsonschema==2.5.1

View File

@@ -1,11 +1,11 @@
-r requirements.txt -r requirements.txt
pycodestyle==2.3.1 pycodestyle==2.3.1
pytest==3.0.1 pytest==3.1.3
pytest-mock==1.2 pytest-mock==1.6.2
pytest-cov==2.3.1 pytest-cov==2.5.1
coveralls==1.1 coveralls==1.1
moto==0.4.25 moto==1.0.1
flex==5.8.0 flex==6.11.0
freezegun==0.3.7 freezegun==0.3.9
requests-mock==1.0.0 requests-mock==1.3.0
strict-rfc3339==0.7 strict-rfc3339==0.7

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env python #!/usr/bin/env python
# notify_celery is referenced from manifest_delivery_base.yml, and cannot be removed
from app import notify_celery, create_app from app import notify_celery, create_app
application = create_app('delivery') application = create_app('delivery')

View File

@@ -5,4 +5,4 @@ set -eo pipefail
echo "Install dependencies" echo "Install dependencies"
cd /home/notify-app/notifications-api; cd /home/notify-app/notifications-api;
pip3 install --find-links=wheelhouse -r /home/notify-app/notifications-api/requirements.txt pip3 install -r /home/notify-app/notifications-api/requirements.txt

View File

@@ -1,7 +1,6 @@
from flask.ext.script import Manager, Server from flask.ext.script import Manager, Server
from flask_migrate import Migrate, MigrateCommand from flask_migrate import Migrate, MigrateCommand
from app import (create_app, db, commands) from app import (create_app, db, commands)
from credstash import getAllSecrets
import os import os
default_env_file = '/home/ubuntu/environment' default_env_file = '/home/ubuntu/environment'
@@ -11,10 +10,6 @@ if os.path.isfile(default_env_file):
with open(default_env_file, 'r') as environment_file: with open(default_env_file, 'r') as environment_file:
environment = environment_file.readline().strip() environment = environment_file.readline().strip()
# On AWS get secrets and export to env, skip this on Cloud Foundry
if os.getenv('VCAP_SERVICES') is None:
os.environ.update(getAllSecrets(region="eu-west-1"))
from app.config import configs from app.config import configs
os.environ['NOTIFY_API_ENVIRONMENT'] = configs[environment] os.environ['NOTIFY_API_ENVIRONMENT'] = configs[environment]

View File

@@ -351,7 +351,7 @@ def test_reject_invalid_ips(restrict_ip_sms_app):
assert exc_info.value.short_message == 'Unknown source IP address from the SMS provider' assert exc_info.value.short_message == 'Unknown source IP address from the SMS provider'
@pytest.mark.xfail(reason='Currently not blocking invalid IPs', strict=True) @pytest.mark.xfail(reason='Currently not blocking invalid senders', strict=True)
def test_illegitimate_ips(restrict_ip_sms_app): def test_illegitimate_ips(restrict_ip_sms_app):
with pytest.raises(AuthError) as exc_info: with pytest.raises(AuthError) as exc_info:
restrict_ip_sms_app.get( restrict_ip_sms_app.get(
@@ -361,4 +361,4 @@ def test_illegitimate_ips(restrict_ip_sms_app):
] ]
) )
assert exc_info.value.short_message == 'Unknown source IP address from the SMS provider' assert exc_info.value.short_message == 'Unknown IP route not from known SMS provider'

View File

@@ -25,8 +25,8 @@ from app.celery.scheduled_tasks import (
send_scheduled_notifications, send_scheduled_notifications,
switch_current_sms_provider_on_slow_delivery, switch_current_sms_provider_on_slow_delivery,
timeout_job_statistics, timeout_job_statistics,
timeout_notifications timeout_notifications,
) populate_monthly_billing)
from app.clients.performance_platform.performance_platform_client import PerformancePlatformClient from app.clients.performance_platform.performance_platform_client import PerformancePlatformClient
from app.dao.jobs_dao import dao_get_job_by_id from app.dao.jobs_dao import dao_get_job_by_id
from app.dao.notifications_dao import dao_get_scheduled_notifications from app.dao.notifications_dao import dao_get_scheduled_notifications
@@ -36,10 +36,10 @@ from app.dao.provider_details_dao import (
) )
from app.models import ( from app.models import (
Service, Template, Service, Template,
SMS_TYPE, LETTER_TYPE SMS_TYPE, LETTER_TYPE,
) MonthlyBilling)
from app.utils import get_london_midnight_in_utc from app.utils import get_london_midnight_in_utc
from tests.app.db import create_notification, create_service, create_template, create_job from tests.app.db import create_notification, create_service, create_template, create_job, create_rate
from tests.app.conftest import ( from tests.app.conftest import (
sample_job as create_sample_job, sample_job as create_sample_job,
sample_notification_history as create_notification_history, sample_notification_history as create_notification_history,
@@ -98,6 +98,8 @@ def test_should_have_decorated_tasks_functions():
'remove_transformed_dvla_files' 'remove_transformed_dvla_files'
assert delete_dvla_response_files_older_than_seven_days.__wrapped__.__name__ == \ assert delete_dvla_response_files_older_than_seven_days.__wrapped__.__name__ == \
'delete_dvla_response_files_older_than_seven_days' 'delete_dvla_response_files_older_than_seven_days'
assert populate_monthly_billing.__wrapped__.__name__ == \
'populate_monthly_billing'
@pytest.fixture(scope='function') @pytest.fixture(scope='function')
@@ -607,3 +609,30 @@ def test_delete_dvla_response_files_older_than_seven_days_does_not_remove_files(
delete_dvla_response_files_older_than_seven_days() delete_dvla_response_files_older_than_seven_days()
remove_s3_mock.assert_not_called() remove_s3_mock.assert_not_called()
@freeze_time("2017-07-12 02:00:00")
def test_populate_monthly_billing(sample_template):
yesterday = datetime(2017, 7, 11, 13, 30)
create_rate(datetime(2016, 1, 1), 0.0123, 'sms')
create_notification(template=sample_template, status='delivered', created_at=yesterday)
create_notification(template=sample_template, status='delivered', created_at=yesterday - timedelta(days=1))
create_notification(template=sample_template, status='delivered', created_at=yesterday + timedelta(days=1))
# not included in billing
create_notification(template=sample_template, status='delivered', created_at=yesterday - timedelta(days=30))
assert len(MonthlyBilling.query.all()) == 0
populate_monthly_billing()
monthly_billing = MonthlyBilling.query.all()
assert len(monthly_billing) == 1
assert monthly_billing[0].service_id == sample_template.service_id
assert monthly_billing[0].start_date == datetime(2017, 6, 30, 23)
assert monthly_billing[0].end_date == datetime(2017, 7, 31, 22, 59, 59, 99999)
assert monthly_billing[0].notification_type == 'sms'
assert len(monthly_billing[0].monthly_totals) == 1
assert sorted(monthly_billing[0].monthly_totals[0]) == sorted({'international': False,
'rate_multiplier': 1,
'billing_units': 3,
'rate': 0.0123,
'total_cost': 0.0369})

View File

@@ -1,4 +1,8 @@
from app.dao.date_util import get_financial_year, get_april_fools from datetime import datetime
import pytest
from app.dao.date_util import get_financial_year, get_april_fools, get_month_start_end_date
def test_get_financial_year(): def test_get_financial_year():
@@ -11,3 +15,17 @@ def test_get_april_fools():
april_fools = get_april_fools(2016) april_fools = get_april_fools(2016)
assert str(april_fools) == '2016-03-31 23:00:00' assert str(april_fools) == '2016-03-31 23:00:00'
assert april_fools.tzinfo is None assert april_fools.tzinfo is None
@pytest.mark.parametrize("month, year, expected_start, expected_end",
[
(7, 2017, datetime(2017, 6, 30, 23, 00, 00), datetime(2017, 7, 31, 22, 59, 59, 99999)),
(2, 2016, datetime(2016, 2, 1, 00, 00, 00), datetime(2016, 2, 29, 23, 59, 59, 99999)),
(2, 2017, datetime(2017, 2, 1, 00, 00, 00), datetime(2017, 2, 28, 23, 59, 59, 99999)),
(9, 2018, datetime(2018, 8, 31, 23, 00, 00), datetime(2018, 9, 30, 22, 59, 59, 99999)),
(12, 2019, datetime(2019, 12, 1, 00, 00, 00), datetime(2019, 12, 31, 23, 59, 59, 99999))])
def test_get_month_start_end_date(month, year, expected_start, expected_end):
month_year = datetime(year, month, 10, 13, 30, 00)
result = get_month_start_end_date(month_year)
assert result[0] == expected_start
assert result[1] == expected_end

View File

@@ -0,0 +1,138 @@
from datetime import datetime
from freezegun import freeze_time
from freezegun.api import FakeDatetime
from app.dao.monthly_billing_dao import (
create_or_update_monthly_billing_sms,
get_monthly_billing_sms,
get_service_ids_that_need_sms_billing_populated
)
from app.models import MonthlyBilling
from tests.app.db import create_notification, create_rate, create_service, create_template
def test_add_monthly_billing(sample_template):
jan = datetime(2017, 1, 1)
feb = datetime(2017, 2, 15)
create_rate(start_date=jan, value=0.0158, notification_type='sms')
create_rate(start_date=datetime(2017, 3, 31, 23, 00, 00), value=0.123, notification_type='sms')
create_notification(template=sample_template, created_at=jan, billable_units=1, status='delivered')
create_notification(template=sample_template, created_at=feb, billable_units=2, status='delivered')
create_or_update_monthly_billing_sms(service_id=sample_template.service_id,
billing_month=jan)
create_or_update_monthly_billing_sms(service_id=sample_template.service_id,
billing_month=feb)
monthly_billing = MonthlyBilling.query.all()
assert len(monthly_billing) == 2
assert monthly_billing[0].start_date == datetime(2017, 1, 1)
assert monthly_billing[1].start_date == datetime(2017, 2, 1)
january = get_monthly_billing_sms(service_id=sample_template.service_id, billing_month=jan)
expected_jan = {"billing_units": 1,
"rate_multiplier": 1,
"international": False,
"rate": 0.0158,
"total_cost": 1 * 0.0158}
assert_monthly_billing(january, sample_template.service_id, 1, expected_jan,
start_date=datetime(2017, 1, 1), end_date=datetime(2017, 1, 31))
february = get_monthly_billing_sms(service_id=sample_template.service_id, billing_month=feb)
expected_feb = {"billing_units": 2,
"rate_multiplier": 1,
"international": False,
"rate": 0.0158,
"total_cost": 2 * 0.0158}
assert_monthly_billing(february, sample_template.service_id, 1, expected_feb,
start_date=datetime(2017, 2, 1), end_date=datetime(2017, 2, 28))
def test_add_monthly_billing_multiple_rates_in_a_month(sample_template):
rate_1 = datetime(2016, 12, 1)
rate_2 = datetime(2017, 1, 15)
create_rate(start_date=rate_1, value=0.0158, notification_type='sms')
create_rate(start_date=rate_2, value=0.0124, notification_type='sms')
create_notification(template=sample_template, created_at=datetime(2017, 1, 1), billable_units=1, status='delivered')
create_notification(template=sample_template, created_at=datetime(2017, 1, 14, 23, 59), billable_units=1,
status='delivered')
create_notification(template=sample_template, created_at=datetime(2017, 1, 15), billable_units=2,
status='delivered')
create_notification(template=sample_template, created_at=datetime(2017, 1, 17, 13, 30, 57), billable_units=4,
status='delivered')
create_or_update_monthly_billing_sms(service_id=sample_template.service_id,
billing_month=rate_2)
monthly_billing = MonthlyBilling.query.all()
assert len(monthly_billing) == 1
assert monthly_billing[0].start_date == datetime(2017, 1, 1)
january = get_monthly_billing_sms(service_id=sample_template.service_id, billing_month=rate_2)
first_row = {"billing_units": 2,
"rate_multiplier": 1,
"international": False,
"rate": 0.0158,
"total_cost": 3 * 0.0158}
assert_monthly_billing(january, sample_template.service_id, 2, first_row,
start_date=datetime(2017, 1, 1), end_date=datetime(2017, 1, 1))
second_row = {"billing_units": 6,
"rate_multiplier": 1,
"international": False,
"rate": 0.0124,
"total_cost": 1 * 0.0124}
assert sorted(january.monthly_totals[1]) == sorted(second_row)
def test_update_monthly_billing_overwrites_old_totals(sample_template):
july = datetime(2017, 7, 1)
create_rate(july, 0.123, 'sms')
create_notification(template=sample_template, created_at=datetime(2017, 7, 2), billable_units=1, status='delivered')
with freeze_time('2017-07-20 02:30:00'):
create_or_update_monthly_billing_sms(sample_template.service_id, july)
first_update = get_monthly_billing_sms(sample_template.service_id, july)
expected = {"billing_units": 1,
"rate_multiplier": 1,
"international": False,
"rate": 0.123,
"total_cost": 1 * 0.123}
assert_monthly_billing(first_update, sample_template.service_id, 1, expected,
start_date=datetime(2017, 6, 30, 23), end_date=datetime(2017, 7, 31, 23, 59, 59, 99999))
first_updated_at = first_update.updated_at
with freeze_time('2017-07-20 03:30:00'):
create_notification(template=sample_template, created_at=datetime(2017, 7, 5), billable_units=2,
status='delivered')
create_or_update_monthly_billing_sms(sample_template.service_id, july)
second_update = get_monthly_billing_sms(sample_template.service_id, july)
expected_update = {"billing_units": 3,
"rate_multiplier": 1,
"international": False,
"rate": 0.123,
"total_cost": 3 * 0.123}
assert_monthly_billing(second_update, sample_template.service_id, 1, expected_update,
start_date=datetime(2017, 6, 30, 23), end_date=datetime(2017, 7, 31, 23, 59, 59, 99999))
assert second_update.updated_at == FakeDatetime(2017, 7, 20, 3, 30)
assert first_updated_at != second_update.updated_at
def assert_monthly_billing(monthly_billing, service_id, expected_len, first_row, start_date, end_date):
assert monthly_billing.service_id == service_id
assert len(monthly_billing.monthly_totals) == expected_len
assert sorted(monthly_billing.monthly_totals[0]) == sorted(first_row)
def test_get_service_id(notify_db_session):
service_1 = create_service(service_name="Service One")
template_1 = create_template(service=service_1)
service_2 = create_service(service_name="Service Two")
template_2 = create_template(service=service_2)
create_notification(template=template_1, created_at=datetime(2017, 6, 30, 13, 30), status='delivered')
create_notification(template=template_1, created_at=datetime(2017, 7, 1, 14, 30), status='delivered')
create_notification(template=template_2, created_at=datetime(2017, 7, 15, 13, 30))
create_notification(template=template_2, created_at=datetime(2017, 7, 31, 13, 30))
services = get_service_ids_that_need_sms_billing_populated(start_date=datetime(2017, 7, 1),
end_date=datetime(2017, 7, 16))
expected_services = [service_1.id, service_2.id]
assert sorted([x.service_id for x in services]) == sorted(expected_services)

View File

@@ -6,7 +6,7 @@ from flask import current_app
from app.dao.date_util import get_financial_year from app.dao.date_util import get_financial_year
from app.dao.notification_usage_dao import ( from app.dao.notification_usage_dao import (
get_rates_for_year, get_rates_for_daterange,
get_yearly_billing_data, get_yearly_billing_data,
get_monthly_billing_data, get_monthly_billing_data,
get_total_billable_units_for_sent_sms_notifications_in_date_range, get_total_billable_units_for_sent_sms_notifications_in_date_range,
@@ -24,22 +24,22 @@ from freezegun import freeze_time
from tests.conftest import set_config from tests.conftest import set_config
def test_get_rates_for_year(notify_db, notify_db_session): def test_get_rates_for_daterange(notify_db, notify_db_session):
set_up_rate(notify_db, datetime(2016, 5, 18), 0.016) set_up_rate(notify_db, datetime(2016, 5, 18), 0.016)
set_up_rate(notify_db, datetime(2017, 3, 31, 23), 0.0158) set_up_rate(notify_db, datetime(2017, 3, 31, 23), 0.0158)
start_date, end_date = get_financial_year(2017) start_date, end_date = get_financial_year(2017)
rates = get_rates_for_year(start_date, end_date, 'sms') rates = get_rates_for_daterange(start_date, end_date, 'sms')
assert len(rates) == 1 assert len(rates) == 1
assert datetime.strftime(rates[0].valid_from, '%Y-%m-%d %H:%M:%S') == "2017-03-31 23:00:00" assert datetime.strftime(rates[0].valid_from, '%Y-%m-%d %H:%M:%S') == "2017-03-31 23:00:00"
assert rates[0].rate == 0.0158 assert rates[0].rate == 0.0158
def test_get_rates_for_year_multiple_result_per_year(notify_db, notify_db_session): def test_get_rates_for_daterange_multiple_result_per_year(notify_db, notify_db_session):
set_up_rate(notify_db, datetime(2016, 4, 1), 0.015) set_up_rate(notify_db, datetime(2016, 4, 1), 0.015)
set_up_rate(notify_db, datetime(2016, 5, 18), 0.016) set_up_rate(notify_db, datetime(2016, 5, 18), 0.016)
set_up_rate(notify_db, datetime(2017, 4, 1), 0.0158) set_up_rate(notify_db, datetime(2017, 4, 1), 0.0158)
start_date, end_date = get_financial_year(2016) start_date, end_date = get_financial_year(2016)
rates = get_rates_for_year(start_date, end_date, 'sms') rates = get_rates_for_daterange(start_date, end_date, 'sms')
assert len(rates) == 2 assert len(rates) == 2
assert datetime.strftime(rates[0].valid_from, '%Y-%m-%d %H:%M:%S') == "2016-04-01 00:00:00" assert datetime.strftime(rates[0].valid_from, '%Y-%m-%d %H:%M:%S') == "2016-04-01 00:00:00"
assert rates[0].rate == 0.015 assert rates[0].rate == 0.015
@@ -47,12 +47,12 @@ def test_get_rates_for_year_multiple_result_per_year(notify_db, notify_db_sessio
assert rates[1].rate == 0.016 assert rates[1].rate == 0.016
def test_get_rates_for_year_returns_correct_rates(notify_db, notify_db_session): def test_get_rates_for_daterange_returns_correct_rates(notify_db, notify_db_session):
set_up_rate(notify_db, datetime(2016, 4, 1), 0.015) set_up_rate(notify_db, datetime(2016, 4, 1), 0.015)
set_up_rate(notify_db, datetime(2016, 9, 1), 0.016) set_up_rate(notify_db, datetime(2016, 9, 1), 0.016)
set_up_rate(notify_db, datetime(2017, 6, 1), 0.0175) set_up_rate(notify_db, datetime(2017, 6, 1), 0.0175)
start_date, end_date = get_financial_year(2017) start_date, end_date = get_financial_year(2017)
rates_2017 = get_rates_for_year(start_date, end_date, 'sms') rates_2017 = get_rates_for_daterange(start_date, end_date, 'sms')
assert len(rates_2017) == 2 assert len(rates_2017) == 2
assert datetime.strftime(rates_2017[0].valid_from, '%Y-%m-%d %H:%M:%S') == "2016-09-01 00:00:00" assert datetime.strftime(rates_2017[0].valid_from, '%Y-%m-%d %H:%M:%S') == "2016-09-01 00:00:00"
assert rates_2017[0].rate == 0.016 assert rates_2017[0].rate == 0.016
@@ -60,43 +60,56 @@ def test_get_rates_for_year_returns_correct_rates(notify_db, notify_db_session):
assert rates_2017[1].rate == 0.0175 assert rates_2017[1].rate == 0.0175
def test_get_rates_for_year_in_the_future(notify_db, notify_db_session): def test_get_rates_for_daterange_in_the_future(notify_db, notify_db_session):
set_up_rate(notify_db, datetime(2016, 4, 1), 0.015) set_up_rate(notify_db, datetime(2016, 4, 1), 0.015)
set_up_rate(notify_db, datetime(2017, 6, 1), 0.0175) set_up_rate(notify_db, datetime(2017, 6, 1), 0.0175)
start_date, end_date = get_financial_year(2018) start_date, end_date = get_financial_year(2018)
rates = get_rates_for_year(start_date, end_date, 'sms') rates = get_rates_for_daterange(start_date, end_date, 'sms')
assert datetime.strftime(rates[0].valid_from, '%Y-%m-%d %H:%M:%S') == "2017-06-01 00:00:00" assert datetime.strftime(rates[0].valid_from, '%Y-%m-%d %H:%M:%S') == "2017-06-01 00:00:00"
assert rates[0].rate == 0.0175 assert rates[0].rate == 0.0175
def test_get_rates_for_year_returns_empty_list_if_year_is_before_earliest_rate(notify_db, notify_db_session): def test_get_rates_for_daterange_returns_empty_list_if_year_is_before_earliest_rate(notify_db, notify_db_session):
set_up_rate(notify_db, datetime(2016, 4, 1), 0.015) set_up_rate(notify_db, datetime(2016, 4, 1), 0.015)
set_up_rate(notify_db, datetime(2017, 6, 1), 0.0175) set_up_rate(notify_db, datetime(2017, 6, 1), 0.0175)
start_date, end_date = get_financial_year(2015) start_date, end_date = get_financial_year(2015)
rates = get_rates_for_year(start_date, end_date, 'sms') rates = get_rates_for_daterange(start_date, end_date, 'sms')
assert rates == [] assert rates == []
def test_get_rates_for_year_early_rate(notify_db, notify_db_session): def test_get_rates_for_daterange_early_rate(notify_db, notify_db_session):
set_up_rate(notify_db, datetime(2015, 6, 1), 0.014) set_up_rate(notify_db, datetime(2015, 6, 1), 0.014)
set_up_rate(notify_db, datetime(2016, 6, 1), 0.015) set_up_rate(notify_db, datetime(2016, 6, 1), 0.015)
set_up_rate(notify_db, datetime(2016, 9, 1), 0.016) set_up_rate(notify_db, datetime(2016, 9, 1), 0.016)
set_up_rate(notify_db, datetime(2017, 6, 1), 0.0175) set_up_rate(notify_db, datetime(2017, 6, 1), 0.0175)
start_date, end_date = get_financial_year(2016) start_date, end_date = get_financial_year(2016)
rates = get_rates_for_year(start_date, end_date, 'sms') rates = get_rates_for_daterange(start_date, end_date, 'sms')
assert len(rates) == 3 assert len(rates) == 3
def test_get_rates_for_year_edge_case(notify_db, notify_db_session): def test_get_rates_for_daterange_edge_case(notify_db, notify_db_session):
set_up_rate(notify_db, datetime(2016, 3, 31, 23, 00), 0.015) set_up_rate(notify_db, datetime(2016, 3, 31, 23, 00), 0.015)
set_up_rate(notify_db, datetime(2017, 3, 31, 23, 00), 0.0175) set_up_rate(notify_db, datetime(2017, 3, 31, 23, 00), 0.0175)
start_date, end_date = get_financial_year(2016) start_date, end_date = get_financial_year(2016)
rates = get_rates_for_year(start_date, end_date, 'sms') rates = get_rates_for_daterange(start_date, end_date, 'sms')
assert len(rates) == 1 assert len(rates) == 1
assert datetime.strftime(rates[0].valid_from, '%Y-%m-%d %H:%M:%S') == "2016-03-31 23:00:00" assert datetime.strftime(rates[0].valid_from, '%Y-%m-%d %H:%M:%S') == "2016-03-31 23:00:00"
assert rates[0].rate == 0.015 assert rates[0].rate == 0.015
def test_get_rates_for_daterange_where_daterange_is_one_month_that_falls_between_rate_valid_from(
notify_db, notify_db_session
):
set_up_rate(notify_db, datetime(2017, 1, 1), 0.175)
set_up_rate(notify_db, datetime(2017, 3, 31), 0.123)
start_date = datetime(2017, 2, 1, 00, 00, 00)
end_date = datetime(2017, 2, 28, 23, 59, 59, 99999)
rates = get_rates_for_daterange(start_date, end_date, 'sms')
assert len(rates) == 1
assert datetime.strftime(rates[0].valid_from, '%Y-%m-%d %H:%M:%S') == "2017-01-01 00:00:00"
assert rates[0].rate == 0.175
def test_get_yearly_billing_data(notify_db, notify_db_session, sample_template, sample_email_template): def test_get_yearly_billing_data(notify_db, notify_db_session, sample_template, sample_email_template):
set_up_rate(notify_db, datetime(2016, 4, 1), 0.014) set_up_rate(notify_db, datetime(2016, 4, 1), 0.014)
set_up_rate(notify_db, datetime(2016, 6, 1), 0.0158) set_up_rate(notify_db, datetime(2016, 6, 1), 0.0158)
@@ -254,8 +267,7 @@ def test_get_monthly_billing_data_with_multiple_rates(notify_db, notify_db_sessi
assert results[3] == ('June', 4, 1, False, 'sms', 0.0175) assert results[3] == ('June', 4, 1, False, 'sms', 0.0175)
def test_get_monthly_billing_data_with_no_notifications_for_year(notify_db, notify_db_session, sample_template, def test_get_monthly_billing_data_with_no_notifications_for_daterange(notify_db, notify_db_session, sample_template):
sample_email_template):
set_up_rate(notify_db, datetime(2016, 4, 1), 0.014) set_up_rate(notify_db, datetime(2016, 4, 1), 0.014)
results = get_monthly_billing_data(sample_template.service_id, 2016) results = get_monthly_billing_data(sample_template.service_id, 2016)
assert len(results) == 0 assert len(results) == 0

View File

@@ -1,3 +1,4 @@
import uuid
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from app.dao.provider_rates_dao import create_provider_rates from app.dao.provider_rates_dao import create_provider_rates

View File

@@ -1,7 +1,7 @@
from datetime import datetime from datetime import datetime
import uuid import uuid
from app import db
from app.dao.jobs_dao import dao_create_job from app.dao.jobs_dao import dao_create_job
from app.dao.service_inbound_api_dao import save_service_inbound_api from app.dao.service_inbound_api_dao import save_service_inbound_api
from app.models import ( from app.models import (
@@ -11,6 +11,7 @@ from app.models import (
Notification, Notification,
ScheduledNotification, ScheduledNotification,
ServicePermission, ServicePermission,
Rate,
Job, Job,
InboundSms, InboundSms,
Organisation, Organisation,
@@ -239,3 +240,10 @@ def create_organisation(colour='blue', logo='test_x2.png', name='test_org_1'):
dao_create_organisation(organisation) dao_create_organisation(organisation)
return organisation return organisation
def create_rate(start_date, value, notification_type):
rate = Rate(id=uuid.uuid4(), valid_from=start_date, rate=value, notification_type=notification_type)
db.session.add(rate)
db.session.commit()
return rate

View File

@@ -435,29 +435,22 @@ def test_get_html_email_renderer_prepends_logo_path(notify_api):
renderer = send_to_providers.get_html_email_options(service) renderer = send_to_providers.get_html_email_options(service)
assert renderer['brand_logo'] == 'http://localhost:6012/static/images/email-template/crests/justice-league.png' assert renderer['brand_logo'] == 'http://static-logos.notify.tools/justice-league.png'
@pytest.mark.parametrize('base_url, expected_url', [ @pytest.mark.parametrize('base_url, expected_url', [
# don't change localhost to prevent errors when testing locally # don't change localhost to prevent errors when testing locally
('http://localhost:6012', 'http://localhost:6012/static/sub-path/filename.png'), ('http://localhost:6012', 'http://static-logos.notify.tools/filename.png'),
# on other environments, replace www with staging ('https://www.notifications.service.gov.uk', 'https://static-logos.notifications.service.gov.uk/filename.png'),
('https://www.notifications.service.gov.uk', 'https://static.notifications.service.gov.uk/sub-path/filename.png'), ('https://notify.works', 'https://static-logos.notify.works/filename.png'),
('https://staging-notify.works', 'https://static-logos.staging-notify.works/filename.png'),
# staging and preview do not have cloudfront running, so should act as localhost ('https://www.notify.works', 'https://static-logos.notify.works/filename.png'),
pytest.mark.xfail(('https://www.notify.works', 'https://static.notify.works/sub-path/filename.png')), ('https://www.staging-notify.works', 'https://static-logos.staging-notify.works/filename.png'),
pytest.mark.xfail(('https://www.staging-notify.works', 'https://static.notify.works/sub-path/filename.png')),
pytest.mark.xfail(('https://notify.works', 'https://static.notify.works/sub-path/filename.png')),
pytest.mark.xfail(('https://staging-notify.works', 'https://static.notify.works/sub-path/filename.png')),
# these tests should be removed when cloudfront works on staging/preview
('https://www.notify.works', 'https://www.notify.works/static/sub-path/filename.png'),
('https://www.staging-notify.works', 'https://www.staging-notify.works/static/sub-path/filename.png'),
]) ])
def test_get_logo_url_works_for_different_environments(base_url, expected_url): def test_get_logo_url_works_for_different_environments(base_url, expected_url):
branding_path = '/sub-path/'
logo_file = 'filename.png' logo_file = 'filename.png'
logo_url = send_to_providers.get_logo_url(base_url, branding_path, logo_file) logo_url = send_to_providers.get_logo_url(base_url, logo_file)
assert logo_url == expected_url assert logo_url == expected_url

View File

@@ -51,10 +51,18 @@ def test_persist_notification_creates_and_save_to_db(sample_template, sample_api
assert Notification.query.count() == 0 assert Notification.query.count() == 0
assert NotificationHistory.query.count() == 0 assert NotificationHistory.query.count() == 0
notification = persist_notification(sample_template.id, sample_template.version, '+447111111111', notification = persist_notification(
sample_template.service, {}, 'sms', sample_api_key.id, template_id=sample_template.id,
sample_api_key.key_type, job_id=sample_job.id, template_version=sample_template.version,
job_row_number=100, reference="ref") recipient='+447111111111',
service=sample_template.service,
personalisation={},
notification_type='sms',
api_key_id=sample_api_key.id,
key_type=sample_api_key.key_type,
job_id=sample_job.id,
job_row_number=100,
reference="ref")
assert Notification.query.get(notification.id) is not None assert Notification.query.get(notification.id) is not None
assert NotificationHistory.query.get(notification.id) is not None assert NotificationHistory.query.get(notification.id) is not None
@@ -127,14 +135,14 @@ def test_persist_notification_does_not_increment_cache_if_test_key(
assert Notification.query.count() == 0 assert Notification.query.count() == 0
assert NotificationHistory.query.count() == 0 assert NotificationHistory.query.count() == 0
persist_notification( persist_notification(
sample_template.id, template_id=sample_template.id,
sample_template.version, template_version=sample_template.version,
'+447111111111', recipient='+447111111111',
sample_template.service, service=sample_template.service,
{}, personalisation={},
'sms', notification_type='sms',
api_key.id, api_key_id=api_key.id,
api_key.key_type, key_type=api_key.key_type,
job_id=sample_job.id, job_id=sample_job.id,
job_row_number=100, job_row_number=100,
reference="ref", reference="ref",
@@ -193,18 +201,33 @@ def test_persist_notification_increments_cache_if_key_exists(sample_template, sa
mock_incr = mocker.patch('app.notifications.process_notifications.redis_store.incr') mock_incr = mocker.patch('app.notifications.process_notifications.redis_store.incr')
mock_incr_hash_value = mocker.patch('app.notifications.process_notifications.redis_store.increment_hash_value') mock_incr_hash_value = mocker.patch('app.notifications.process_notifications.redis_store.increment_hash_value')
persist_notification(sample_template.id, sample_template.version, '+447111111111', persist_notification(
sample_template.service, {}, 'sms', sample_api_key.id, template_id=sample_template.id,
sample_api_key.key_type, reference="ref") template_version=sample_template.version,
recipient='+447111111111',
service=sample_template.service,
personalisation={},
notification_type='sms',
api_key_id=sample_api_key.id,
key_type=sample_api_key.key_type,
reference="ref"
)
mock_incr.assert_not_called() mock_incr.assert_not_called()
mock_incr_hash_value.assert_not_called() mock_incr_hash_value.assert_not_called()
mocker.patch('app.notifications.process_notifications.redis_store.get', return_value=1) mocker.patch('app.notifications.process_notifications.redis_store.get', return_value=1)
mocker.patch('app.notifications.process_notifications.redis_store.get_all_from_hash', mocker.patch('app.notifications.process_notifications.redis_store.get_all_from_hash',
return_value={sample_template.id, 1}) return_value={sample_template.id, 1})
persist_notification(sample_template.id, sample_template.version, '+447111111122', persist_notification(
sample_template.service, {}, 'sms', sample_api_key.id, template_id=sample_template.id,
sample_api_key.key_type, reference="ref2") template_version=sample_template.version,
recipient='+447111111122',
service=sample_template.service,
personalisation={},
notification_type='sms',
api_key_id=sample_api_key.id,
key_type=sample_api_key.key_type,
reference="ref2")
mock_incr.assert_called_once_with(str(sample_template.service_id) + "-2016-01-01-count", ) mock_incr.assert_called_once_with(str(sample_template.service_id) + "-2016-01-01-count", )
mock_incr_hash_value.assert_called_once_with(cache_key_for_service_template_counter(sample_template.service_id), mock_incr_hash_value.assert_called_once_with(cache_key_for_service_template_counter(sample_template.service_id),
sample_template.id) sample_template.id)

View File

@@ -0,0 +1,166 @@
import uuid
from flask import url_for, json
import pytest
from app.models import Job, Notification, SMS_TYPE, EMAIL_TYPE, LETTER_TYPE
from app.v2.errors import RateLimitError
from tests import create_authorization_header
from tests.app.db import create_service, create_template
pytestmark = pytest.mark.skip('Leters not currently implemented')
def letter_request(client, data, service_id, _expected_status=201):
resp = client.post(
url_for('v2_notifications.post_notification', notification_type='letter'),
data=json.dumps(data),
headers=[('Content-Type', 'application/json'), create_authorization_header(service_id=service_id)]
)
json_resp = json.loads(resp.get_data(as_text=True))
assert resp.status_code == _expected_status, json_resp
return json_resp
@pytest.mark.parametrize('reference', [None, 'reference_from_client'])
def test_post_letter_notification_returns_201(client, sample_letter_template, mocker, reference):
mocked = mocker.patch('app.celery.tasks.build_dvla_file.apply_async')
data = {
'template_id': str(sample_letter_template.id),
'personalisation': {
'address_line_1': 'Her Royal Highness Queen Elizabeth II',
'address_line_2': 'Buckingham Palace',
'address_line_3': 'London',
'postcode': 'SW1 1AA',
'name': 'Lizzie'
}
}
if reference:
data.update({'reference': reference})
resp_json = letter_request(client, data, service_id=sample_letter_template.service_id)
job = Job.query.one()
notification = Notification.query.all()
notification_id = notification.id
assert resp_json['id'] == str(notification_id)
assert resp_json['reference'] == reference
assert resp_json['content']['subject'] == sample_letter_template.subject
assert resp_json['content']['body'] == sample_letter_template.content
assert 'v2/notifications/{}'.format(notification_id) in resp_json['uri']
assert resp_json['template']['id'] == str(sample_letter_template.id)
assert resp_json['template']['version'] == sample_letter_template.version
assert (
'services/{}/templates/{}'.format(
sample_letter_template.service_id,
sample_letter_template.id
) in resp_json['template']['uri']
)
assert not resp_json['scheduled_for']
mocked.assert_called_once_with((str(job.id), ), queue='job-tasks')
def test_post_letter_notification_returns_400_and_missing_template(
client,
sample_service
):
data = {
'template_id': str(uuid.uuid4()),
'personalisation': {'address_line_1': '', 'postcode': ''}
}
error_json = letter_request(client, data, service_id=sample_service.id, _expected_status=400)
assert error_json['status_code'] == 400
assert error_json['errors'] == [{'error': 'BadRequestError', 'message': 'Template not found'}]
def test_post_notification_returns_403_and_well_formed_auth_error(
client,
sample_letter_template
):
data = {
'template_id': str(sample_letter_template.id),
'personalisation': {'address_line_1': '', 'postcode': ''}
}
error_json = letter_request(client, data, service_id=sample_letter_template.service_id, _expected_status=401)
assert error_json['status_code'] == 401
assert error_json['errors'] == [{
'error': 'AuthError',
'message': 'Unauthorized, authentication token must be provided'
}]
def test_notification_returns_400_for_schema_problems(
client,
sample_service
):
data = {
'personalisation': {'address_line_1': '', 'postcode': ''}
}
error_json = letter_request(client, data, service_id=sample_service.id, _expected_status=400)
assert error_json['status_code'] == 400
assert error_json['errors'] == [{
'error': 'ValidationError',
'message': 'template_id is a required property'
}]
def test_returns_a_429_limit_exceeded_if_rate_limit_exceeded(
client,
sample_letter_template,
mocker
):
persist_mock = mocker.patch('app.v2.notifications.post_notifications.persist_notification')
mocker.patch(
'app.v2.notifications.post_notifications.check_rate_limiting',
side_effect=RateLimitError('LIMIT', 'INTERVAL', 'TYPE')
)
data = {
'template_id': str(sample_letter_template.id),
'personalisation': {'address_line_1': '', 'postcode': ''}
}
error_json = letter_request(client, data, service_id=sample_letter_template.service_id, _expected_status=429)
assert error_json['status_code'] == 429
assert error_json['errors'] == [{
'error': 'RateLimitError',
'message': 'Exceeded rate limit for key type TYPE of LIMIT requests per INTERVAL seconds'
}]
assert not persist_mock.called
@pytest.mark.parametrize('service_args', [
{'service_permissions': [EMAIL_TYPE, SMS_TYPE]},
{'restricted': True}
])
def test_post_letter_notification_returns_403_if_not_allowed_to_send_notification(
client,
notify_db_session,
service_args
):
service = create_service(**service_args)
template = create_template(service, template_type=LETTER_TYPE)
data = {
'template_id': str(template.id),
'personalisation': {'address_line_1': '', 'postcode': ''}
}
error_json = letter_request(client, data, service_id=service.id, _expected_status=400)
assert error_json['status_code'] == 403
assert error_json['errors'] == [
{'error': 'BadRequestError', 'message': 'Cannot send letters'}
]

View File

@@ -58,8 +58,8 @@ def test_post_sms_notification_returns_201(client, sample_template_with_placehol
@pytest.mark.parametrize("notification_type, key_send_to, send_to", @pytest.mark.parametrize("notification_type, key_send_to, send_to",
[("sms", "phone_number", "+447700900855"), [("sms", "phone_number", "+447700900855"),
("email", "email_address", "sample@email.com")]) ("email", "email_address", "sample@email.com")])
def test_post_sms_notification_returns_400_and_missing_template(client, sample_service, def test_post_notification_returns_400_and_missing_template(client, sample_service,
notification_type, key_send_to, send_to): notification_type, key_send_to, send_to):
data = { data = {
key_send_to: send_to, key_send_to: send_to,
'template_id': str(uuid.uuid4()) 'template_id': str(uuid.uuid4())
@@ -434,3 +434,15 @@ def test_post_notification_raises_bad_request_if_service_not_invited_to_schedule
error_json = json.loads(response.get_data(as_text=True)) error_json = json.loads(response.get_data(as_text=True))
assert error_json['errors'] == [ assert error_json['errors'] == [
{"error": "BadRequestError", "message": 'Cannot schedule notifications (this feature is invite-only)'}] {"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(
'/v2/notifications/foo',
data='{}',
headers=[('Content-Type', 'application/json'), auth_header]
)
assert response.status_code == 404
error_json = json.loads(response.get_data(as_text=True))
assert 'The requested URL was not found on the server.' in error_json['message']

View File

@@ -1,13 +1,6 @@
import os
from app import create_app from app import create_app
from credstash import getAllSecrets
# On AWS get secrets and export to env, skip this on Cloud Foundry
if os.getenv('VCAP_SERVICES') is None:
os.environ.update(getAllSecrets(region="eu-west-1"))
application = create_app() application = create_app()
if __name__ == "__main__": if __name__ == "__main__":