diff --git a/app/__init__.py b/app/__init__.py index 31a601f3c..ed6e8e603 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -124,7 +124,7 @@ def register_blueprint(application): application.register_blueprint(sms_callback_blueprint) # inbound sms - receive_notifications_blueprint.before_request(restrict_ip_sms) + receive_notifications_blueprint.before_request(requires_no_auth) application.register_blueprint(receive_notifications_blueprint) notifications_blueprint.before_request(requires_auth) @@ -174,6 +174,7 @@ def register_blueprint(application): def register_v2_blueprints(application): + from app.v2.inbound_sms.get_inbound_sms import v2_inbound_sms_blueprint as get_inbound_sms from app.v2.notifications.post_notifications import v2_notification_blueprint as post_notifications from app.v2.notifications.get_notifications import v2_notification_blueprint as get_notifications from app.v2.template.get_template import v2_template_blueprint as get_template @@ -196,6 +197,9 @@ def register_v2_blueprints(application): post_template.before_request(requires_auth) application.register_blueprint(post_template) + get_inbound_sms.before_request(requires_auth) + application.register_blueprint(get_inbound_sms) + def init_app(app): @app.before_request diff --git a/app/billing/billing_schemas.py b/app/billing/billing_schemas.py index 631c782f7..d5a840184 100644 --- a/app/billing/billing_schemas.py +++ b/app/billing/billing_schemas.py @@ -8,7 +8,6 @@ create_or_update_free_sms_fragment_limit_schema = { "title": "Create", "properties": { "free_sms_fragment_limit": {"type": "integer", "minimum": 1}, - "financial_year_start": {"type": "integer", "minimum": 2016} }, "required": ["free_sms_fragment_limit"] } diff --git a/app/celery/scheduled_tasks.py b/app/celery/scheduled_tasks.py index 2e0413fe8..82dc30f76 100644 --- a/app/celery/scheduled_tasks.py +++ b/app/celery/scheduled_tasks.py @@ -11,6 +11,10 @@ from notifications_utils.s3 import s3upload from app.aws import s3 from app import notify_celery +from app.dao.services_dao import ( + dao_fetch_monthly_historical_stats_by_template +) +from app.dao.stats_template_usage_by_month_dao import insert_or_update_stats_for_template from app.performance_platform import total_sent_notifications, processing_time from app import performance_platform_client from app.dao.date_util import get_month_start_and_end_date_in_utc @@ -402,3 +406,18 @@ def check_job_status(): queue=QueueNames.JOBS ) raise JobIncompleteError("Job(s) {} have not completed.".format(job_ids)) + + +@notify_celery.task(name='daily-stats-template_usage_by_month') +@statsd(namespace="tasks") +def daily_stats_template_usage_by_month(): + results = dao_fetch_monthly_historical_stats_by_template() + + for result in results: + if result.template_id: + insert_or_update_stats_for_template( + result.template_id, + result.month, + result.year, + result.count + ) diff --git a/app/config.py b/app/config.py index 59be8ad02..16892ac28 100644 --- a/app/config.py +++ b/app/config.py @@ -241,6 +241,11 @@ class Config(object): 'task': 'check-job-status', 'schedule': crontab(), 'options': {'queue': QueueNames.PERIODIC} + }, + 'daily-stats-template_usage_by_month': { + 'task': 'daily-stats-template_usage_by_month', + 'schedule': crontab(hour=0, minute=50), + 'options': {'queue': QueueNames.PERIODIC} } } CELERY_QUEUES = [] diff --git a/app/dao/inbound_sms_dao.py b/app/dao/inbound_sms_dao.py index 18060cced..f7d54281e 100644 --- a/app/dao/inbound_sms_dao.py +++ b/app/dao/inbound_sms_dao.py @@ -2,7 +2,8 @@ from datetime import ( timedelta, datetime ) - +from flask import current_app +from sqlalchemy import desc from app import db from app.dao.dao_utils import transactional @@ -31,6 +32,28 @@ def dao_get_inbound_sms_for_service(service_id, limit=None, user_number=None): return q.all() +def dao_get_paginated_inbound_sms_for_service( + service_id, + older_than=None, + page_size=None +): + if page_size is None: + page_size = current_app.config['PAGE_SIZE'] + + filters = [InboundSms.service_id == service_id] + + if older_than: + older_than_created_at = db.session.query( + InboundSms.created_at).filter(InboundSms.id == older_than).as_scalar() + filters.append(InboundSms.created_at < older_than_created_at) + + query = InboundSms.query.filter(*filters) + + return query.order_by(desc(InboundSms.created_at)).paginate( + per_page=page_size + ).items + + def dao_count_inbound_sms_for_service(service_id): return InboundSms.query.filter( InboundSms.service_id == service_id diff --git a/app/dao/monthly_billing_dao.py b/app/dao/monthly_billing_dao.py index 606c02cc9..4528f414d 100644 --- a/app/dao/monthly_billing_dao.py +++ b/app/dao/monthly_billing_dao.py @@ -26,6 +26,7 @@ def get_service_ids_that_need_billing_populated(start_date, end_date): ).distinct().all() +@statsd(namespace="dao") def create_or_update_monthly_billing(service_id, billing_month): start_date, end_date = get_month_start_and_end_date_in_utc(billing_month) _update_monthly_billing(service_id, start_date, end_date, SMS_TYPE) @@ -47,6 +48,7 @@ def _monthly_billing_data_to_json(billing_data): return results +@statsd(namespace="dao") @transactional def _update_monthly_billing(service_id, start_date, end_date, notification_type): billing_data = get_billing_data_for_month( diff --git a/app/dao/notification_usage_dao.py b/app/dao/notification_usage_dao.py index 7542fb0df..afb547287 100644 --- a/app/dao/notification_usage_dao.py +++ b/app/dao/notification_usage_dao.py @@ -105,6 +105,7 @@ def is_between(date, start_date, end_date): return start_date <= date <= end_date +@statsd(namespace="dao") def billing_data_per_month_query(rate, service_id, start_date, end_date, notification_type): month = get_london_month_from_utc_column(NotificationHistory.created_at) if notification_type == SMS_TYPE: diff --git a/app/dao/services_dao.py b/app/dao/services_dao.py index a481a32ed..fe57e5076 100644 --- a/app/dao/services_dao.py +++ b/app/dao/services_dao.py @@ -1,7 +1,7 @@ import uuid -from datetime import date, datetime, timedelta +from datetime import date, datetime, timedelta, time -from sqlalchemy import asc, func +from sqlalchemy import asc, func, extract from sqlalchemy.orm import joinedload from flask import current_app @@ -519,3 +519,25 @@ def dao_fetch_active_users_for_service(service_id): ) return query.all() + + +@statsd(namespace="dao") +def dao_fetch_monthly_historical_stats_by_template(): + month = get_london_month_from_utc_column(NotificationHistory.created_at) + year = func.date_trunc("year", NotificationHistory.created_at) + end_date = datetime.combine(date.today(), time.min) + + return db.session.query( + NotificationHistory.template_id, + extract('month', month).label('month'), + extract('year', year).label('year'), + func.count().label('count') + ).filter( + NotificationHistory.created_at < end_date + ).group_by( + NotificationHistory.template_id, + month, + year + ).order_by( + NotificationHistory.template_id + ).all() diff --git a/app/dao/stats_template_usage_by_month_dao.py b/app/dao/stats_template_usage_by_month_dao.py new file mode 100644 index 000000000..2884e2c53 --- /dev/null +++ b/app/dao/stats_template_usage_by_month_dao.py @@ -0,0 +1,24 @@ +from app import db +from app.models import StatsTemplateUsageByMonth + + +def insert_or_update_stats_for_template(template_id, month, year, count): + result = db.session.query( + StatsTemplateUsageByMonth + ).filter( + StatsTemplateUsageByMonth.template_id == template_id, + StatsTemplateUsageByMonth.month == month, + StatsTemplateUsageByMonth.year == year + ).update( + { + 'count': count + } + ) + if result == 0: + monthly_stats = StatsTemplateUsageByMonth( + template_id=template_id, + month=month, + year=year, + count=count + ) + db.session.add(monthly_stats) diff --git a/app/errors.py b/app/errors.py index a6119d98d..8ff935002 100644 --- a/app/errors.py +++ b/app/errors.py @@ -94,7 +94,7 @@ def register_errors(blueprint): current_app.logger.exception(e) if hasattr(e, 'orig') and hasattr(e.orig, 'pgerror') and e.orig.pgerror and \ ('duplicate key value violates unique constraint "services_name_key"' in e.orig.pgerror or - 'duplicate key value violates unique constraint "services_email_from_key"' in e.orig.pgerror): + 'duplicate key value violates unique constraint "services_email_from_key"' in e.orig.pgerror): return jsonify( result='error', message={'name': ["Duplicate service name '{}'".format( diff --git a/app/models.py b/app/models.py index 4ff22131c..1f9afbbec 100644 --- a/app/models.py +++ b/app/models.py @@ -9,7 +9,7 @@ from sqlalchemy.dialects.postgresql import ( UUID, JSON ) -from sqlalchemy import UniqueConstraint, and_ +from sqlalchemy import UniqueConstraint, CheckConstraint, and_ from sqlalchemy.orm import foreign, remote from notifications_utils.recipients import ( validate_email_address, @@ -97,7 +97,7 @@ class User(db.Model): nullable=True, onupdate=datetime.datetime.utcnow) _password = db.Column(db.String, index=False, unique=False, nullable=False) - mobile_number = db.Column(db.String, index=False, unique=False, nullable=False) + mobile_number = db.Column(db.String, index=False, unique=False, nullable=True) password_changed_at = db.Column(db.DateTime, index=False, unique=False, nullable=False, default=datetime.datetime.utcnow) logged_in_at = db.Column(db.DateTime, nullable=True) @@ -107,6 +107,9 @@ class User(db.Model): current_session_id = db.Column(UUID(as_uuid=True), nullable=True) auth_type = db.Column(db.String, db.ForeignKey('auth_type.name'), index=True, nullable=False, default=SMS_AUTH_TYPE) + # either email auth or a mobile number must be provided + CheckConstraint("auth_type = 'email_auth' or mobile_number is not null") + services = db.relationship( 'Service', secondary='user_to_service', @@ -1404,13 +1407,11 @@ class InboundSms(db.Model): def serialize(self): return { 'id': str(self.id), - 'created_at': self.created_at.isoformat(), + 'created_at': self.created_at.strftime(DATETIME_FORMAT), 'service_id': str(self.service_id), 'notify_number': self.notify_number, 'user_number': self.user_number, 'content': self.content, - 'provider_date': self.provider_date and self.provider_date.isoformat(), - 'provider_reference': self.provider_reference } @@ -1554,3 +1555,37 @@ class AuthType(db.Model): __tablename__ = 'auth_type' name = db.Column(db.String, primary_key=True) + + +class StatsTemplateUsageByMonth(db.Model): + __tablename__ = "stats_template_usage_by_month" + + template_id = db.Column( + UUID(as_uuid=True), + db.ForeignKey('templates.id'), + unique=False, + index=True, + nullable=False, + primary_key=True + ) + month = db.Column( + db.Integer, + nullable=False, + index=True, + unique=False, + primary_key=True, + default=datetime.datetime.month + ) + year = db.Column( + db.Integer, + nullable=False, + index=True, + unique=False, + primary_key=True, + default=datetime.datetime.year + ) + count = db.Column( + db.Integer, + nullable=False, + default=0 + ) diff --git a/app/notifications/receive_notifications.py b/app/notifications/receive_notifications.py index ac116b28a..fa9d11146 100644 --- a/app/notifications/receive_notifications.py +++ b/app/notifications/receive_notifications.py @@ -82,7 +82,11 @@ def receive_firetext_sms(): def format_mmg_message(message): - return unquote(message.replace('+', ' ')) + return unescape_string(unquote(message.replace('+', ' '))) + + +def unescape_string(string): + return string.encode('raw_unicode_escape').decode('unicode_escape') def format_mmg_datetime(date): diff --git a/app/notifications/validators.py b/app/notifications/validators.py index 4f05977ce..5aee7c55b 100644 --- a/app/notifications/validators.py +++ b/app/notifications/validators.py @@ -155,7 +155,7 @@ def check_service_sms_sender_id(service_id, sms_sender_id, notification_type): message = 'sms_sender_id is not a valid option for {} notification'.format(notification_type) raise BadRequestError(message=message) try: - dao_get_service_sms_senders_by_id(service_id, sms_sender_id) + return dao_get_service_sms_senders_by_id(service_id, sms_sender_id).sms_sender except NoResultFound: message = 'sms_sender_id {} does not exist in database for service id {}'\ .format(sms_sender_id, service_id) diff --git a/app/schemas.py b/app/schemas.py index e689bf83f..fe9d29b0f 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -105,6 +105,26 @@ class UserSchema(BaseSchema): "_password", "verify_codes") strict = True + @validates('name') + def validate_name(self, value): + if not value: + raise ValidationError('Invalid name') + + @validates('email_address') + def validate_email_address(self, value): + try: + validate_email_address(value) + except InvalidEmailError as e: + raise ValidationError(str(e)) + + @validates('mobile_number') + def validate_mobile_number(self, value): + try: + if value is not None: + validate_phone_number(value, international=True) + except InvalidPhoneError as error: + raise ValidationError('Invalid phone number: {}'.format(error)) + class UserUpdateAttributeSchema(BaseSchema): auth_type = field_for(models.User, 'auth_type') @@ -132,7 +152,8 @@ class UserUpdateAttributeSchema(BaseSchema): @validates('mobile_number') def validate_mobile_number(self, value): try: - validate_phone_number(value, international=True) + if value is not None: + validate_phone_number(value, international=True) except InvalidPhoneError as error: raise ValidationError('Invalid phone number: {}'.format(error)) diff --git a/app/user/rest.py b/app/user/rest.py index b6c2c28e0..787c78f44 100644 --- a/app/user/rest.py +++ b/app/user/rest.py @@ -4,6 +4,7 @@ from datetime import datetime from urllib.parse import urlencode from flask import (jsonify, request, Blueprint, current_app, abort) +from sqlalchemy.exc import IntegrityError from app.config import QueueNames from app.dao.users_dao import ( @@ -52,6 +53,19 @@ user_blueprint = Blueprint('user', __name__) register_errors(user_blueprint) +@user_blueprint.errorhandler(IntegrityError) +def handle_integrity_error(exc): + """ + Handle integrity errors caused by the auth type/mobile number check constraint + """ + if 'ck_users_mobile_or_email_auth' in str(exc): + # we don't expect this to trip, so still log error + current_app.logger.exception('Check constraint ck_users_mobile_or_email_auth triggered') + return jsonify(result='error', message='Mobile number must be set if auth_type is set to sms_auth'), 400 + + raise + + @user_blueprint.route('', methods=['POST']) def create_user(): user_to_create, errors = user_schema.load(request.get_json()) @@ -63,23 +77,6 @@ def create_user(): return jsonify(data=user_schema.dump(user_to_create).data), 201 -@user_blueprint.route('/', methods=['PUT']) -def update_user(user_id): - user_to_update = get_user_by_id(user_id=user_id) - req_json = request.get_json() - update_dct, errors = user_schema_load_json.load(req_json) - # TODO don't let password be updated in this PUT method (currently used by the forgot password flow) - pwd = req_json.get('password', None) - if pwd is not None: - if not pwd: - errors.update({'password': ['Invalid data for field']}) - raise InvalidRequest(errors, status_code=400) - else: - reset_failed_login_count(user_to_update) - save_model_user(user_to_update, update_dict=update_dct, pwd=pwd) - return jsonify(data=user_schema.dump(user_to_update).data), 200 - - @user_blueprint.route('/', methods=['POST']) def update_user_attribute(user_id): user_to_update = get_user_by_id(user_id=user_id) @@ -91,6 +88,17 @@ def update_user_attribute(user_id): return jsonify(data=user_schema.dump(user_to_update).data), 200 +@user_blueprint.route('//activate', methods=['POST']) +def activate_user(user_id): + user = get_user_by_id(user_id=user_id) + if user.state == 'active': + raise InvalidRequest('User already active', status_code=400) + + user.state = 'active' + save_model_user(user) + return jsonify(data=user_schema.dump(user).data), 200 + + @user_blueprint.route('//reset-failed-login-count', methods=['POST']) def user_reset_failed_login_count(user_id): user_to_update = get_user_by_id(user_id=user_id) diff --git a/app/v2/inbound_sms/__init__.py b/app/v2/inbound_sms/__init__.py new file mode 100644 index 000000000..5c713b2ef --- /dev/null +++ b/app/v2/inbound_sms/__init__.py @@ -0,0 +1,6 @@ +from flask import Blueprint +from app.v2.errors import register_errors + +v2_inbound_sms_blueprint = Blueprint("v2_inbound_sms", __name__, url_prefix='/v2/received-text-messages') + +register_errors(v2_inbound_sms_blueprint) diff --git a/app/v2/inbound_sms/get_inbound_sms.py b/app/v2/inbound_sms/get_inbound_sms.py new file mode 100644 index 000000000..478e4f03d --- /dev/null +++ b/app/v2/inbound_sms/get_inbound_sms.py @@ -0,0 +1,44 @@ +from flask import jsonify, request, url_for, current_app + +from notifications_utils.recipients import validate_and_format_phone_number +from notifications_utils.recipients import InvalidPhoneError + +from app import authenticated_service +from app.dao import inbound_sms_dao +from app.schema_validation import validate +from app.v2.inbound_sms import v2_inbound_sms_blueprint +from app.v2.inbound_sms.inbound_sms_schemas import get_inbound_sms_request + + +@v2_inbound_sms_blueprint.route("", methods=['GET']) +def get_inbound_sms(): + data = validate(request.args.to_dict(), get_inbound_sms_request) + + paginated_inbound_sms = inbound_sms_dao.dao_get_paginated_inbound_sms_for_service( + authenticated_service.id, + older_than=data.get('older_than', None), + page_size=current_app.config.get('API_PAGE_SIZE') + ) + + return jsonify( + received_text_messages=[i.serialize() for i in paginated_inbound_sms], + links=_build_links(paginated_inbound_sms) + ), 200 + + +def _build_links(inbound_sms_list): + _links = { + 'current': url_for( + "v2_inbound_sms.get_inbound_sms", + _external=True, + ), + } + + if inbound_sms_list: + _links['next'] = url_for( + "v2_inbound_sms.get_inbound_sms", + older_than=inbound_sms_list[-1].id, + _external=True, + ) + + return _links diff --git a/app/v2/inbound_sms/inbound_sms_schemas.py b/app/v2/inbound_sms/inbound_sms_schemas.py new file mode 100644 index 000000000..e68952a23 --- /dev/null +++ b/app/v2/inbound_sms/inbound_sms_schemas.py @@ -0,0 +1,70 @@ +from app.schema_validation.definitions import uuid + + +get_inbound_sms_request = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "schema for query parameters allowed when getting list of received text messages", + "type": "object", + "properties": { + "older_than": uuid, + }, + "additionalProperties": False, +} + + +get_inbound_sms_single_response = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "GET inbound sms schema response", + "type": "object", + "title": "GET response v2/inbound_sms", + "properties": { + "user_number": {"type": "string"}, + "created_at": { + "format": "date-time", + "type": "string", + "description": "Date+time created at" + }, + "service_id": uuid, + "id": uuid, + "notify_number": {"type": "string"}, + "content": {"type": "string"}, + }, + "required": [ + "id", "user_number", "created_at", "service_id", + "notify_number", "content" + ], + "additionalProperties": False, +} + +get_inbound_sms_response = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "GET list of inbound sms response schema", + "type": "object", + "properties": { + "received_text_messages": { + "type": "array", + "items": { + "type": "object", + "$ref": "#/definitions/inbound_sms" + } + }, + "links": { + "type": "object", + "properties": { + "current": { + "type": "string" + }, + "next": { + "type": "string" + } + }, + "additionalProperties": False, + "required": ["current"] + } + }, + "required": ["received_text_messages", "links"], + "definitions": { + "inbound_sms": get_inbound_sms_single_response + }, + "additionalProperties": False, +} diff --git a/app/v2/notifications/post_notifications.py b/app/v2/notifications/post_notifications.py index 87d55159d..90cd2997f 100644 --- a/app/v2/notifications/post_notifications.py +++ b/app/v2/notifications/post_notifications.py @@ -71,7 +71,7 @@ def post_notification(notification_type): check_rate_limiting(authenticated_service, api_user) check_service_email_reply_to_id(str(authenticated_service.id), service_email_reply_to_id, notification_type) - check_service_sms_sender_id(str(authenticated_service.id), service_sms_sender_id, notification_type) + sms_sender = check_service_sms_sender_id(str(authenticated_service.id), service_sms_sender_id, notification_type) template, template_with_content = validate_template( form['template_id'], @@ -98,7 +98,7 @@ def post_notification(notification_type): if notification_type == SMS_TYPE: create_resp_partial = functools.partial( create_post_sms_response_from_notification, - from_number=authenticated_service.get_default_sms_sender() + from_number=sms_sender or authenticated_service.get_default_sms_sender() ) elif notification_type == EMAIL_TYPE: create_resp_partial = functools.partial( diff --git a/migrations/versions/0135_stats_template_usage.py b/migrations/versions/0135_stats_template_usage.py new file mode 100644 index 000000000..5a8f5ef7a --- /dev/null +++ b/migrations/versions/0135_stats_template_usage.py @@ -0,0 +1,38 @@ +""" + +Revision ID: 0135_stats_template_usage +Revises: 0134_add_email_2fa_template +Create Date: 2017-11-07 14:35:04.798561 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = '0135_stats_template_usage' +down_revision = '0134_add_email_2fa_template' + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('stats_template_usage_by_month', + sa.Column('template_id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('month', sa.Integer(), nullable=False), + sa.Column('year', sa.Integer(), nullable=False), + sa.Column('count', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['template_id'], ['templates.id'], ), + sa.PrimaryKeyConstraint('template_id', 'month', 'year') + ) + op.create_index(op.f('ix_stats_template_usage_by_month_month'), 'stats_template_usage_by_month', ['month'], unique=False) + op.create_index(op.f('ix_stats_template_usage_by_month_template_id'), 'stats_template_usage_by_month', ['template_id'], unique=False) + op.create_index(op.f('ix_stats_template_usage_by_month_year'), 'stats_template_usage_by_month', ['year'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_stats_template_usage_by_month_year'), table_name='stats_template_usage_by_month') + op.drop_index(op.f('ix_stats_template_usage_by_month_template_id'), table_name='stats_template_usage_by_month') + op.drop_index(op.f('ix_stats_template_usage_by_month_month'), table_name='stats_template_usage_by_month') + op.drop_table('stats_template_usage_by_month') + # ### end Alembic commands ### diff --git a/migrations/versions/0136_user_mobile_nullable.py b/migrations/versions/0136_user_mobile_nullable.py new file mode 100644 index 000000000..8ce4df31a --- /dev/null +++ b/migrations/versions/0136_user_mobile_nullable.py @@ -0,0 +1,28 @@ +""" + +Revision ID: 0136_user_mobile_nullable +Revises: 0135_stats_template_usage +Create Date: 2017-11-08 11:49:05.773974 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.sql import column +from sqlalchemy.dialects import postgresql + +revision = '0136_user_mobile_nullable' +down_revision = '0135_stats_template_usage' + + +def upgrade(): + op.alter_column('users', 'mobile_number', nullable=True) + + op.create_check_constraint( + 'ck_users_mobile_or_email_auth', + 'users', + "auth_type = 'email_auth' or mobile_number is not null" + ) + +def downgrade(): + op.alter_column('users', 'mobile_number', nullable=False) + op.drop_constraint('ck_users_mobile_or_email_auth', 'users') diff --git a/requirements.txt b/requirements.txt index 20c3f9112..a66ed696c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,7 +17,7 @@ monotonic==1.4 psycopg2==2.7.3.2 PyJWT==1.5.3 SQLAlchemy-Utils==0.32.19 -SQLAlchemy==1.1.14 +SQLAlchemy==1.1.15 statsd==3.2.1 notifications-python-client==4.6.0 diff --git a/scripts/check_if_new_migration.py b/scripts/check_if_new_migration.py index ba0519ea1..2cd1614d0 100644 --- a/scripts/check_if_new_migration.py +++ b/scripts/check_if_new_migration.py @@ -8,7 +8,8 @@ def get_latest_db_migration_to_apply(): project_dir = dirname(dirname(abspath(__file__))) # Get the main project directory migrations_dir = '{}/migrations/versions/'.format(project_dir) migration_files = [migration_file for migration_file in os.listdir(migrations_dir) if migration_file.endswith('py')] - latest_file = sorted(migration_files, reverse=True)[0].replace('.py', '') + # sometimes there's a trailing underscore, if script was created with `python app.py db migrate --rev-id=...` + latest_file = sorted(migration_files, reverse=True)[0].replace('_.py', '').replace('.py', '') return latest_file diff --git a/tests/app/celery/test_scheduled_tasks.py b/tests/app/celery/test_scheduled_tasks.py index a61c70807..01e40dd95 100644 --- a/tests/app/celery/test_scheduled_tasks.py +++ b/tests/app/celery/test_scheduled_tasks.py @@ -2,11 +2,13 @@ from datetime import datetime, timedelta from functools import partial from unittest.mock import call, patch, PropertyMock +import functools from flask import current_app import pytest from freezegun import freeze_time +from app import db from app.celery import scheduled_tasks from app.celery.scheduled_tasks import ( check_job_status, @@ -30,7 +32,8 @@ from app.celery.scheduled_tasks import ( send_total_sent_notifications_to_performance_platform, switch_current_sms_provider_on_slow_delivery, timeout_job_statistics, - timeout_notifications + timeout_notifications, + daily_stats_template_usage_by_month ) from app.clients.performance_platform.performance_platform_client import PerformancePlatformClient from app.config import QueueNames, TaskNames @@ -41,22 +44,28 @@ from app.dao.provider_details_dao import ( get_current_provider ) from app.models import ( - Service, Template, - SMS_TYPE, LETTER_TYPE, + MonthlyBilling, + NotificationHistory, + Service, + StatsTemplateUsageByMonth, + Template, JOB_STATUS_READY_TO_SEND, JOB_STATUS_IN_PROGRESS, JOB_STATUS_SENT_TO_DVLA, - NOTIFICATION_PENDING, - NOTIFICATION_CREATED, KEY_TYPE_TEST, - MonthlyBilling + LETTER_TYPE, + NOTIFICATION_CREATED, + NOTIFICATION_PENDING, + SMS_TYPE ) from app.utils import get_london_midnight_in_utc from app.v2.errors import JobIncompleteError from tests.app.db import create_notification, create_service, create_template, create_job, create_rate + from tests.app.conftest import ( sample_job as create_sample_job, sample_notification_history as create_notification_history, + sample_template as create_sample_template, create_custom_template, datetime_in_past ) @@ -834,3 +843,148 @@ def test_check_job_status_task_raises_job_incomplete_error_for_multiple_jobs(moc args=([str(job.id), str(job_2.id)],), queue=QueueNames.JOBS ) + + +def test_daily_stats_template_usage_by_month(notify_db, notify_db_session): + notification_history = functools.partial( + create_notification_history, + notify_db, + notify_db_session, + status='delivered' + ) + + template_one = create_sample_template(notify_db, notify_db_session) + template_two = create_sample_template(notify_db, notify_db_session) + + notification_history(created_at=datetime(2017, 10, 1), sample_template=template_one) + notification_history(created_at=datetime(2016, 4, 1), sample_template=template_two) + notification_history(created_at=datetime(2016, 4, 1), sample_template=template_two) + notification_history(created_at=datetime.now(), sample_template=template_two) + + daily_stats_template_usage_by_month() + + result = db.session.query( + StatsTemplateUsageByMonth + ).order_by( + StatsTemplateUsageByMonth.year, + StatsTemplateUsageByMonth.month + ).all() + + assert len(result) == 2 + + assert result[0].template_id == template_two.id + assert result[0].month == 4 + assert result[0].year == 2016 + assert result[0].count == 2 + + assert result[1].template_id == template_one.id + assert result[1].month == 10 + assert result[1].year == 2017 + assert result[1].count == 1 + + +def test_daily_stats_template_usage_by_month_no_data(): + daily_stats_template_usage_by_month() + + results = db.session.query(StatsTemplateUsageByMonth).all() + + assert len(results) == 0 + + +def test_daily_stats_template_usage_by_month_multiple_runs(notify_db, notify_db_session): + notification_history = functools.partial( + create_notification_history, + notify_db, + notify_db_session, + status='delivered' + ) + + template_one = create_sample_template(notify_db, notify_db_session) + template_two = create_sample_template(notify_db, notify_db_session) + + notification_history(created_at=datetime(2017, 11, 1), sample_template=template_one) + notification_history(created_at=datetime(2016, 4, 1), sample_template=template_two) + notification_history(created_at=datetime(2016, 4, 1), sample_template=template_two) + notification_history(created_at=datetime.now(), sample_template=template_two) + + daily_stats_template_usage_by_month() + + template_three = create_sample_template(notify_db, notify_db_session) + + notification_history(created_at=datetime(2017, 10, 1), sample_template=template_three) + notification_history(created_at=datetime(2017, 9, 1), sample_template=template_three) + notification_history(created_at=datetime(2016, 4, 1), sample_template=template_two) + notification_history(created_at=datetime(2016, 4, 1), sample_template=template_two) + notification_history(created_at=datetime.now(), sample_template=template_two) + + daily_stats_template_usage_by_month() + + result = db.session.query( + StatsTemplateUsageByMonth + ).order_by( + StatsTemplateUsageByMonth.year, + StatsTemplateUsageByMonth.month + ).all() + + assert len(result) == 4 + + assert result[0].template_id == template_two.id + assert result[0].month == 4 + assert result[0].year == 2016 + assert result[0].count == 4 + + assert result[1].template_id == template_three.id + assert result[1].month == 9 + assert result[1].year == 2017 + assert result[1].count == 1 + + assert result[2].template_id == template_three.id + assert result[2].month == 10 + assert result[2].year == 2017 + assert result[2].count == 1 + + assert result[3].template_id == template_one.id + assert result[3].month == 11 + assert result[3].year == 2017 + assert result[3].count == 1 + + +def test_dao_fetch_monthly_historical_stats_by_template_null_template_id_not_counted(notify_db, notify_db_session): + notification_history = functools.partial( + create_notification_history, + notify_db, + notify_db_session, + status='delivered' + ) + + template_one = create_sample_template(notify_db, notify_db_session, template_name='1') + history = notification_history(created_at=datetime(2017, 2, 1), sample_template=template_one) + + NotificationHistory.query.filter( + NotificationHistory.id == history.id + ).update( + { + 'template_id': None + } + ) + + daily_stats_template_usage_by_month() + + result = db.session.query( + StatsTemplateUsageByMonth + ).all() + + assert len(result) == 0 + + notification_history(created_at=datetime(2017, 2, 1), sample_template=template_one) + + daily_stats_template_usage_by_month() + + result = db.session.query( + StatsTemplateUsageByMonth + ).order_by( + StatsTemplateUsageByMonth.year, + StatsTemplateUsageByMonth.month + ).all() + + assert len(result) == 1 diff --git a/tests/app/conftest.py b/tests/app/conftest.py index 6e89c662d..43b17490d 100644 --- a/tests/app/conftest.py +++ b/tests/app/conftest.py @@ -47,6 +47,7 @@ from tests.app.db import ( create_api_key, create_inbound_number, create_letter_contact, + create_inbound_sms, ) @@ -1032,6 +1033,11 @@ def sample_inbound_numbers(notify_db, notify_db_session, sample_service): return inbound_numbers +@pytest.fixture +def sample_inbound_sms(notify_db, notify_db_session, sample_service): + return create_inbound_sms(sample_service) + + @pytest.fixture def restore_provider_details(notify_db, notify_db_session): """ diff --git a/tests/app/dao/test_inbound_sms_dao.py b/tests/app/dao/test_inbound_sms_dao.py index b26dc913e..7967d8522 100644 --- a/tests/app/dao/test_inbound_sms_dao.py +++ b/tests/app/dao/test_inbound_sms_dao.py @@ -6,7 +6,8 @@ from app.dao.inbound_sms_dao import ( dao_get_inbound_sms_for_service, dao_count_inbound_sms_for_service, delete_inbound_sms_created_more_than_a_week_ago, - dao_get_inbound_sms_by_id + dao_get_inbound_sms_by_id, + dao_get_paginated_inbound_sms_for_service ) from tests.app.db import create_inbound_sms, create_service @@ -89,9 +90,83 @@ def test_should_not_delete_inbound_sms_before_seven_days(sample_service): assert len(InboundSms.query.all()) == 2 -def test_get_inbound_sms_by_id_returns(sample_service): - inbound = create_inbound_sms(sample_service) +def test_get_inbound_sms_by_id_returns(sample_inbound_sms): + inbound_from_db = dao_get_inbound_sms_by_id(sample_inbound_sms.service.id, sample_inbound_sms.id) - inbound_from_db = dao_get_inbound_sms_by_id(sample_service.id, inbound.id) + assert sample_inbound_sms == inbound_from_db - assert inbound == inbound_from_db + +def test_dao_get_paginated_inbound_sms_for_service(sample_inbound_sms): + inbound_from_db = dao_get_paginated_inbound_sms_for_service(sample_inbound_sms.service.id) + + assert sample_inbound_sms == inbound_from_db[0] + + +def test_dao_get_paginated_inbound_sms_for_service_return_only_for_service(sample_inbound_sms): + another_service = create_service(service_name='another service') + another_inbound_sms = create_inbound_sms(another_service) + + inbound_from_db = dao_get_paginated_inbound_sms_for_service(sample_inbound_sms.service.id) + + assert sample_inbound_sms in inbound_from_db + assert another_inbound_sms not in inbound_from_db + + +def test_dao_get_paginated_inbound_sms_for_service_no_inbound_sms_returns_empty_list(sample_service): + inbound_from_db = dao_get_paginated_inbound_sms_for_service(sample_service.id) + + assert inbound_from_db == [] + + +def test_dao_get_paginated_inbound_sms_for_service_page_size_returns_correct_size(sample_service): + inbound_sms_list = [ + create_inbound_sms(sample_service), + create_inbound_sms(sample_service), + create_inbound_sms(sample_service), + create_inbound_sms(sample_service), + ] + reversed_inbound_sms = sorted(inbound_sms_list, key=lambda sms: sms.created_at, reverse=True) + + inbound_from_db = dao_get_paginated_inbound_sms_for_service( + sample_service.id, + older_than=reversed_inbound_sms[1].id, + page_size=2 + ) + + assert len(inbound_from_db) == 2 + + +def test_dao_get_paginated_inbound_sms_for_service_older_than_returns_correct_list(sample_service): + inbound_sms_list = [ + create_inbound_sms(sample_service), + create_inbound_sms(sample_service), + create_inbound_sms(sample_service), + create_inbound_sms(sample_service), + ] + reversed_inbound_sms = sorted(inbound_sms_list, key=lambda sms: sms.created_at, reverse=True) + + inbound_from_db = dao_get_paginated_inbound_sms_for_service( + sample_service.id, + older_than=reversed_inbound_sms[1].id, + page_size=2 + ) + + expected_inbound_sms = reversed_inbound_sms[2:] + + assert expected_inbound_sms == inbound_from_db + + +def test_dao_get_paginated_inbound_sms_for_service_older_than_end_returns_empty_list(sample_service): + inbound_sms_list = [ + create_inbound_sms(sample_service), + create_inbound_sms(sample_service), + ] + reversed_inbound_sms = sorted(inbound_sms_list, key=lambda sms: sms.created_at, reverse=True) + + inbound_from_db = dao_get_paginated_inbound_sms_for_service( + sample_service.id, + older_than=reversed_inbound_sms[1].id, + page_size=2 + ) + + assert inbound_from_db == [] diff --git a/tests/app/dao/test_services_dao.py b/tests/app/dao/test_services_dao.py index 8bedc045a..ce1163144 100644 --- a/tests/app/dao/test_services_dao.py +++ b/tests/app/dao/test_services_dao.py @@ -31,7 +31,8 @@ from app.dao.services_dao import ( dao_suspend_service, dao_resume_service, dao_fetch_active_users_for_service, - dao_fetch_service_by_inbound_number + dao_fetch_service_by_inbound_number, + dao_fetch_monthly_historical_stats_by_template ) from app.dao.service_permissions_dao import dao_add_service_permission, dao_remove_service_permission from app.dao.users_dao import save_model_user @@ -1005,3 +1006,34 @@ def _assert_service_permissions(service_permissions, expected): assert len(service_permissions) == len(expected) assert set(expected) == set(p.permission for p in service_permissions) + + +def test_dao_fetch_monthly_historical_stats_by_template(notify_db, notify_db_session): + notification_history = functools.partial( + create_notification_history, + notify_db, + notify_db_session, + status='delivered' + ) + + template_one = create_sample_template(notify_db, notify_db_session, template_name='1') + template_two = create_sample_template(notify_db, notify_db_session, template_name='2') + + notification_history(created_at=datetime(2017, 10, 1), sample_template=template_one) + notification_history(created_at=datetime(2016, 4, 1), sample_template=template_two) + notification_history(created_at=datetime(2016, 4, 1), sample_template=template_two) + notification_history(created_at=datetime.now(), sample_template=template_two) + + result = sorted(dao_fetch_monthly_historical_stats_by_template(), key=lambda x: (x.month, x.year)) + + assert len(result) == 2 + + assert result[0].template_id == template_two.id + assert result[0].month == 4 + assert result[0].year == 2016 + assert result[0].count == 2 + + assert result[1].template_id == template_one.id + assert result[1].month == 10 + assert result[1].year == 2017 + assert result[1].count == 1 diff --git a/tests/app/dao/test_stats_template_usage_by_month_dao.py b/tests/app/dao/test_stats_template_usage_by_month_dao.py new file mode 100644 index 000000000..ae46c682b --- /dev/null +++ b/tests/app/dao/test_stats_template_usage_by_month_dao.py @@ -0,0 +1,45 @@ +import pytest + +from app.dao.stats_template_usage_by_month_dao import insert_or_update_stats_for_template +from app.models import StatsTemplateUsageByMonth + +from tests.app.conftest import sample_notification, sample_email_template, sample_template, sample_job, sample_service + + +def test_create_stats_for_template(notify_db_session, sample_template): + assert StatsTemplateUsageByMonth.query.count() == 0 + + insert_or_update_stats_for_template(sample_template.id, 1, 2017, 10) + stats_by_month = StatsTemplateUsageByMonth.query.filter( + StatsTemplateUsageByMonth.template_id == sample_template.id + ).all() + + assert len(stats_by_month) == 1 + assert stats_by_month[0].template_id == sample_template.id + assert stats_by_month[0].month == 1 + assert stats_by_month[0].year == 2017 + assert stats_by_month[0].count == 10 + + +def test_update_stats_for_template(notify_db_session, sample_template): + assert StatsTemplateUsageByMonth.query.count() == 0 + + insert_or_update_stats_for_template(sample_template.id, 1, 2017, 10) + insert_or_update_stats_for_template(sample_template.id, 1, 2017, 20) + insert_or_update_stats_for_template(sample_template.id, 2, 2017, 30) + + stats_by_month = StatsTemplateUsageByMonth.query.filter( + StatsTemplateUsageByMonth.template_id == sample_template.id + ).order_by(StatsTemplateUsageByMonth.template_id).all() + + assert len(stats_by_month) == 2 + + assert stats_by_month[0].template_id == sample_template.id + assert stats_by_month[0].month == 1 + assert stats_by_month[0].year == 2017 + assert stats_by_month[0].count == 20 + + assert stats_by_month[1].template_id == sample_template.id + assert stats_by_month[1].month == 2 + assert stats_by_month[1].year == 2017 + assert stats_by_month[1].count == 30 diff --git a/tests/app/inbound_sms/test_rest.py b/tests/app/inbound_sms/test_rest.py index 560c85bba..f278251be 100644 --- a/tests/app/inbound_sms/test_rest.py +++ b/tests/app/inbound_sms/test_rest.py @@ -33,9 +33,7 @@ def test_get_inbound_sms_with_no_params(client, sample_service): 'service_id', 'notify_number', 'user_number', - 'content', - 'provider_date', - 'provider_reference' + 'content' } @@ -178,9 +176,7 @@ def test_get_inbound_sms(admin_request, sample_service): 'service_id', 'notify_number', 'user_number', - 'content', - 'provider_date', - 'provider_reference' + 'content' } diff --git a/tests/app/notifications/test_receive_notification.py b/tests/app/notifications/test_receive_notification.py index 53270b22d..8db765b2c 100644 --- a/tests/app/notifications/test_receive_notification.py +++ b/tests/app/notifications/test_receive_notification.py @@ -12,6 +12,7 @@ from app.notifications.receive_notifications import ( create_inbound_sms_object, strip_leading_forty_four, has_inbound_sms_permissions, + unescape_string, ) from app.models import InboundSms, EMAIL_TYPE, SMS_TYPE, INBOUND_SMS_TYPE @@ -166,6 +167,36 @@ def test_format_mmg_message(message, expected_output): assert format_mmg_message(message) == expected_output +@pytest.mark.parametrize('raw, expected', [ + ( + '😬', + '😬', + ), + ( + '1\\n2', + '1\n2', + ), + ( + '\\\'"\\\'', + '\'"\'', + ), + ( + """ + + """, + """ + + """, + ), + ( + '\x79 \\x79 \\\\x79', # we should never see the middle one + 'y y \\x79', + ), +]) +def test_unescape_string(raw, expected): + assert unescape_string(raw) == expected + + @pytest.mark.parametrize('provider_date, expected_output', [ ('2017-01-21+11%3A56%3A11', datetime(2017, 1, 21, 11, 56, 11)), ('2017-05-21+11%3A56%3A11', datetime(2017, 5, 21, 10, 56, 11)) diff --git a/tests/app/notifications/test_validators.py b/tests/app/notifications/test_validators.py index ede6d940d..a20251790 100644 --- a/tests/app/notifications/test_validators.py +++ b/tests/app/notifications/test_validators.py @@ -374,7 +374,7 @@ def test_check_service_sms_sender_id_where_sms_sender_id_is_none(notification_ty def test_check_service_sms_sender_id_where_sms_sender_id_is_found(sample_service): sms_sender = create_service_sms_sender(service=sample_service, sms_sender='123456') - assert check_service_sms_sender_id(sample_service.id, sms_sender.id, SMS_TYPE) is None + assert check_service_sms_sender_id(sample_service.id, sms_sender.id, SMS_TYPE) == '123456' def test_check_service_sms_sender_id_where_service_id_is_not_found(sample_service, fake_uuid): diff --git a/tests/app/user/test_rest.py b/tests/app/user/test_rest.py index 2a36d488e..00444ea64 100644 --- a/tests/app/user/test_rest.py +++ b/tests/app/user/test_rest.py @@ -159,40 +159,53 @@ def test_create_user_missing_attribute_password(client, notify_db, notify_db_ses assert {'password': ['Missing data for required field.']} == json_resp['message'] -def test_put_user(client, sample_service): - """ - Tests PUT endpoint '/' to update a user. - """ - assert User.query.count() == 1 - sample_user = sample_service.users[0] - sample_user.failed_login_count = 1 - new_email = 'new@digital.cabinet-office.gov.uk' +def test_can_create_user_with_email_auth_and_no_mobile(admin_request, notify_db_session): data = { - 'name': sample_user.name, - 'email_address': new_email, - 'mobile_number': sample_user.mobile_number + 'name': 'Test User', + 'email_address': 'user@digital.cabinet-office.gov.uk', + 'password': 'password', + 'mobile_number': None, + 'auth_type': EMAIL_AUTH_TYPE } - auth_header = create_authorization_header() - headers = [('Content-Type', 'application/json'), auth_header] - resp = client.put( - url_for('user.update_user', user_id=sample_user.id), - data=json.dumps(data), - headers=headers) - assert resp.status_code == 200 - assert User.query.count() == 1 - json_resp = json.loads(resp.get_data(as_text=True)) - assert json_resp['data']['email_address'] == new_email - expected_permissions = default_service_permissions - fetched = json_resp['data'] - assert str(sample_user.id) == fetched['id'] - assert sample_user.name == fetched['name'] - assert sample_user.mobile_number == fetched['mobile_number'] - assert new_email == fetched['email_address'] - assert sample_user.state == fetched['state'] - assert sorted(expected_permissions) == sorted(fetched['permissions'][str(sample_service.id)]) - # password wasn't updated, so failed_login_count stays the same - assert sample_user.failed_login_count == 1 + json_resp = admin_request.post('user.create_user', _data=data, _expected_status=201) + + assert json_resp['data']['auth_type'] == EMAIL_AUTH_TYPE + assert json_resp['data']['mobile_number'] is None + + +def test_cannot_create_user_with_sms_auth_and_no_mobile(admin_request, notify_db_session): + data = { + 'name': 'Test User', + 'email_address': 'user@digital.cabinet-office.gov.uk', + 'password': 'password', + 'mobile_number': None, + 'auth_type': SMS_AUTH_TYPE + } + + json_resp = admin_request.post('user.create_user', _data=data, _expected_status=400) + + assert json_resp['message'] == 'Mobile number must be set if auth_type is set to sms_auth' + + +def test_cannot_create_user_with_empty_strings(admin_request, notify_db_session): + data = { + 'name': '', + 'email_address': '', + 'password': 'password', + 'mobile_number': '', + 'auth_type': EMAIL_AUTH_TYPE + } + resp = admin_request.post( + 'user.create_user', + _data=data, + _expected_status=400 + ) + assert resp['message'] == { + 'email_address': ['Not a valid email address'], + 'mobile_number': ['Invalid phone number: Not enough digits'], + 'name': ['Invalid name'] + } @pytest.mark.parametrize('user_attribute, user_value', [ @@ -218,63 +231,6 @@ def test_post_user_attribute(client, sample_user, user_attribute, user_value): assert json_resp['data'][user_attribute] == user_value -def test_put_user_update_password(client, sample_service): - """ - Tests PUT endpoint '/' to update a user including their password. - """ - assert User.query.count() == 1 - sample_user = sample_service.users[0] - new_password = '1234567890' - data = { - 'name': sample_user.name, - 'email_address': sample_user.email_address, - 'mobile_number': sample_user.mobile_number, - 'password': new_password - } - auth_header = create_authorization_header() - headers = [('Content-Type', 'application/json'), auth_header] - resp = client.put( - url_for('user.update_user', user_id=sample_user.id), - data=json.dumps(data), - headers=headers) - assert resp.status_code == 200 - assert User.query.count() == 1 - json_resp = json.loads(resp.get_data(as_text=True)) - assert json_resp['data']['password_changed_at'] is not None - data = {'password': new_password} - auth_header = create_authorization_header() - headers = [('Content-Type', 'application/json'), auth_header] - resp = client.post( - url_for('user.verify_user_password', user_id=str(sample_user.id)), - data=json.dumps(data), - headers=headers) - assert resp.status_code == 204 - - -def test_put_user_not_exists(client, sample_user, fake_uuid): - """ - Tests PUT endpoint '/' to update a user doesn't exist. - """ - assert User.query.count() == 1 - new_email = 'new@digital.cabinet-office.gov.uk' - data = {'email_address': new_email} - auth_header = create_authorization_header() - headers = [('Content-Type', 'application/json'), auth_header] - resp = client.put( - url_for('user.update_user', user_id=fake_uuid), - data=json.dumps(data), - headers=headers) - assert resp.status_code == 404 - assert User.query.count() == 1 - user = User.query.filter_by(id=str(sample_user.id)).first() - json_resp = json.loads(resp.get_data(as_text=True)) - assert json_resp['result'] == "error" - assert json_resp['message'] == 'No result found' - - assert user == sample_user - assert user.email_address != new_email - - def test_get_user_by_email(client, sample_service): sample_user = sample_service.users[0] header = create_authorization_header() @@ -529,23 +485,20 @@ def test_update_user_password_saves_correctly(client, sample_service): assert resp.status_code == 204 -def test_update_user_resets_failed_login_count_if_updating_password(client, sample_service): - user = sample_service.users[0] - user.failed_login_count = 1 +def test_activate_user(admin_request, sample_user): + sample_user.state = 'pending' - resp = client.put( - url_for('user.update_user', user_id=user.id), - data=json.dumps({ - 'name': user.name, - 'email_address': user.email_address, - 'mobile_number': user.mobile_number, - 'password': 'foo' - }), - headers=[('Content-Type', 'application/json'), create_authorization_header()] - ) + resp = admin_request.post('user.activate_user', user_id=sample_user.id) - assert resp.status_code == 200 - assert user.failed_login_count == 0 + assert resp['data']['id'] == str(sample_user.id) + assert resp['data']['state'] == 'active' + assert sample_user.state == 'active' + + +def test_activate_user_fails_if_already_active(admin_request, sample_user): + resp = admin_request.post('user.activate_user', user_id=sample_user.id, _expected_status=400) + assert resp['message'] == 'User already active' + assert sample_user.state == 'active' def test_update_user_auth_type(admin_request, sample_user): @@ -558,3 +511,66 @@ def test_update_user_auth_type(admin_request, sample_user): assert resp['data']['id'] == str(sample_user.id) assert resp['data']['auth_type'] == 'email_auth' + + +def test_can_set_email_auth_and_remove_mobile_at_same_time(admin_request, sample_user): + sample_user.auth_type = SMS_AUTH_TYPE + + admin_request.post( + 'user.update_user_attribute', + user_id=sample_user.id, + _data={ + 'mobile_number': None, + 'auth_type': EMAIL_AUTH_TYPE, + } + ) + + assert sample_user.mobile_number is None + assert sample_user.auth_type == EMAIL_AUTH_TYPE + + +def test_cannot_remove_mobile_if_sms_auth(admin_request, sample_user): + sample_user.auth_type = SMS_AUTH_TYPE + + json_resp = admin_request.post( + 'user.update_user_attribute', + user_id=sample_user.id, + _data={'mobile_number': None}, + _expected_status=400 + ) + + assert json_resp['message'] == 'Mobile number must be set if auth_type is set to sms_auth' + + +def test_can_remove_mobile_if_email_auth(admin_request, sample_user): + sample_user.auth_type = EMAIL_AUTH_TYPE + + admin_request.post( + 'user.update_user_attribute', + user_id=sample_user.id, + _data={'mobile_number': None}, + ) + + assert sample_user.mobile_number is None + + +def test_cannot_update_user_with_mobile_number_as_empty_string(admin_request, sample_user): + sample_user.auth_type = EMAIL_AUTH_TYPE + + resp = admin_request.post( + 'user.update_user_attribute', + user_id=sample_user.id, + _data={'mobile_number': ''}, + _expected_status=400 + ) + assert resp['message']['mobile_number'] == ['Invalid phone number: Not enough digits'] + + +def test_cannot_update_user_password_using_attributes_method(admin_request, sample_user): + resp = admin_request.post( + 'user.update_user_attribute', + user_id=sample_user.id, + _data={'password': 'foo'}, + _expected_status=400 + ) + assert resp['message']['_schema'] == ['Unknown field name password'] diff --git a/tests/app/v2/inbound_sms/__init__.py b/tests/app/v2/inbound_sms/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/app/v2/inbound_sms/test_get_inbound_sms.py b/tests/app/v2/inbound_sms/test_get_inbound_sms.py new file mode 100644 index 000000000..e499089c3 --- /dev/null +++ b/tests/app/v2/inbound_sms/test_get_inbound_sms.py @@ -0,0 +1,154 @@ +from flask import json, url_for + +from tests import create_authorization_header +from tests.app.db import create_inbound_sms + + +def test_get_inbound_sms_returns_200( + client, sample_service +): + all_inbound_sms = [ + create_inbound_sms(service=sample_service, user_number='447700900111', content='Hi'), + create_inbound_sms(service=sample_service, user_number='447700900112'), + create_inbound_sms(service=sample_service, user_number='447700900111', content='Bye'), + create_inbound_sms(service=sample_service, user_number='07700900113') + ] + + auth_header = create_authorization_header(service_id=sample_service.id) + response = client.get( + path='/v2/received-text-messages', + headers=[('Content-Type', 'application/json'), auth_header]) + + assert response.status_code == 200 + assert response.headers['Content-type'] == 'application/json' + + json_response = json.loads(response.get_data(as_text=True))['received_text_messages'] + + reversed_all_inbound_sms = sorted(all_inbound_sms, key=lambda sms: sms.created_at, reverse=True) + + expected_response = [i.serialize() for i in reversed_all_inbound_sms] + + assert json_response == expected_response + + +def test_get_inbound_sms_generate_page_links(client, sample_service, mocker): + mocker.patch.dict( + "app.v2.inbound_sms.get_inbound_sms.current_app.config", + {"API_PAGE_SIZE": 2} + ) + all_inbound_sms = [ + create_inbound_sms(service=sample_service, user_number='447700900111', content='Hi'), + create_inbound_sms(service=sample_service, user_number='447700900111'), + create_inbound_sms(service=sample_service, user_number='447700900111', content='End'), + ] + + reversed_inbound_sms = sorted(all_inbound_sms, key=lambda sms: sms.created_at, reverse=True) + + auth_header = create_authorization_header(service_id=sample_service.id) + response = client.get( + url_for('v2_inbound_sms.get_inbound_sms'), + headers=[('Content-Type', 'application/json'), auth_header]) + + assert response.status_code == 200 + + json_response = json.loads(response.get_data(as_text=True)) + expected_inbound_sms_list = [i.serialize() for i in reversed_inbound_sms[:2]] + + assert json_response['received_text_messages'] == expected_inbound_sms_list + assert url_for( + 'v2_inbound_sms.get_inbound_sms', + _external=True) == json_response['links']['current'] + assert url_for( + 'v2_inbound_sms.get_inbound_sms', + older_than=reversed_inbound_sms[1].id, + _external=True) == json_response['links']['next'] + + +def test_get_next_inbound_sms_will_get_correct_inbound_sms_list(client, sample_service, mocker): + mocker.patch.dict( + "app.v2.inbound_sms.get_inbound_sms.current_app.config", + {"API_PAGE_SIZE": 2} + ) + all_inbound_sms = [ + create_inbound_sms(service=sample_service, user_number='447700900111', content='1'), + create_inbound_sms(service=sample_service, user_number='447700900111', content='2'), + create_inbound_sms(service=sample_service, user_number='447700900111', content='3'), + create_inbound_sms(service=sample_service, user_number='447700900111', content='4'), + ] + reversed_inbound_sms = sorted(all_inbound_sms, key=lambda sms: sms.created_at, reverse=True) + + auth_header = create_authorization_header(service_id=sample_service.id) + response = client.get( + path=url_for('v2_inbound_sms.get_inbound_sms', older_than=reversed_inbound_sms[1].id), + headers=[('Content-Type', 'application/json'), auth_header]) + + assert response.status_code == 200 + + json_response = json.loads(response.get_data(as_text=True)) + expected_inbound_sms_list = [i.serialize() for i in reversed_inbound_sms[2:]] + + assert json_response['received_text_messages'] == expected_inbound_sms_list + assert url_for( + 'v2_inbound_sms.get_inbound_sms', + _external=True) == json_response['links']['current'] + assert url_for( + 'v2_inbound_sms.get_inbound_sms', + older_than=reversed_inbound_sms[3].id, + _external=True) == json_response['links']['next'] + + +def test_get_next_inbound_sms_at_end_will_return_empty_inbound_sms_list(client, sample_inbound_sms, mocker): + mocker.patch.dict( + "app.v2.inbound_sms.get_inbound_sms.current_app.config", + {"API_PAGE_SIZE": 1} + ) + + auth_header = create_authorization_header(service_id=sample_inbound_sms.service.id) + response = client.get( + path=url_for('v2_inbound_sms.get_inbound_sms', older_than=sample_inbound_sms.id), + headers=[('Content-Type', 'application/json'), auth_header]) + + assert response.status_code == 200 + + json_response = json.loads(response.get_data(as_text=True)) + expected_inbound_sms_list = [] + assert json_response['received_text_messages'] == expected_inbound_sms_list + assert url_for( + 'v2_inbound_sms.get_inbound_sms', + _external=True) == json_response['links']['current'] + assert 'next' not in json_response['links'].keys() + + +def test_get_inbound_sms_for_no_inbound_sms_returns_empty_list( + client, sample_service +): + auth_header = create_authorization_header(service_id=sample_service.id) + response = client.get( + path='/v2/received-text-messages', + headers=[('Content-Type', 'application/json'), auth_header]) + + assert response.status_code == 200 + assert response.headers['Content-type'] == 'application/json' + + json_response = json.loads(response.get_data(as_text=True))['received_text_messages'] + + expected_response = [] + + assert json_response == expected_response + + +def test_get_inbound_sms_with_invalid_query_string_returns_400(client, sample_service): + auth_header = create_authorization_header(service_id=sample_service.id) + response = client.get( + path='/v2/received-text-messages?user_number=447700900000', + headers=[('Content-Type', 'application/json'), auth_header]) + + assert response.status_code == 400 + assert response.headers['Content-type'] == 'application/json' + + json_response = json.loads(response.get_data(as_text=True)) + + assert json_response['status_code'] == 400 + assert json_response['errors'][0]['error'] == 'ValidationError' + assert json_response['errors'][0]['message'] == \ + 'Additional properties are not allowed (user_number was unexpected)' diff --git a/tests/app/v2/inbound_sms/test_inbound_sms_schemas.py b/tests/app/v2/inbound_sms/test_inbound_sms_schemas.py new file mode 100644 index 000000000..c2e32f93a --- /dev/null +++ b/tests/app/v2/inbound_sms/test_inbound_sms_schemas.py @@ -0,0 +1,92 @@ +import pytest +from flask import json, url_for +from jsonschema.exceptions import ValidationError + +from app.v2.inbound_sms.inbound_sms_schemas import ( + get_inbound_sms_request, + get_inbound_sms_response, + get_inbound_sms_single_response +) +from app.schema_validation import validate + +from tests import create_authorization_header +from tests.app.db import create_inbound_sms + +valid_inbound_sms = { + "user_number": "447700900111", + "created_at": "2017-11-02T15:07:57.197546Z", + "service_id": "a5149c32-f03b-4711-af49-ad6993797d45", + "id": "342786aa-23ce-4695-9aad-7f79e68ee29a", + "notify_number": "testing", + "content": "Hello" +} + +valid_inbound_sms_list = { + "received_text_messages": [valid_inbound_sms], + "links": { + "current": valid_inbound_sms["id"] + } + +} + +invalid_inbound_sms = { + "user_number": "447700900111", + "created_at": "2017-11-02T15:07:57.197546", + "service_id": "a5149c32-f03b-4711-af49-ad6993797d45", + "id": "342786aa-23ce-4695-9aad-7f79e68ee29a", + "notify_number": "testing" +} + +invalid_inbound_sms_list = { + "received_text_messages": [invalid_inbound_sms] +} + + +def test_get_inbound_sms_contract(client, sample_service): + all_inbound_sms = [ + create_inbound_sms(service=sample_service, user_number='447700900113'), + create_inbound_sms(service=sample_service, user_number='447700900112'), + create_inbound_sms(service=sample_service, user_number='447700900111'), + ] + reversed_inbound_sms = sorted(all_inbound_sms, key=lambda sms: sms.created_at, reverse=True) + + auth_header = create_authorization_header(service_id=all_inbound_sms[0].service_id) + response = client.get('/v2/received-text-messages', headers=[auth_header]) + response_json = json.loads(response.get_data(as_text=True)) + + validated_resp = validate(response_json, get_inbound_sms_response) + assert validated_resp['received_text_messages'] == [i.serialize() for i in reversed_inbound_sms] + assert validated_resp['links']['current'] == url_for( + 'v2_inbound_sms.get_inbound_sms', _external=True) + assert validated_resp['links']['next'] == url_for( + 'v2_inbound_sms.get_inbound_sms', older_than=all_inbound_sms[0].id, _external=True) + + +@pytest.mark.parametrize('request_args', [ + {'older_than': "6ce466d0-fd6a-11e5-82f5-e0accb9d11a6"}, {}] +) +def test_valid_inbound_sms_request_json(client, request_args): + validate(request_args, get_inbound_sms_request) + + +def test_invalid_inbound_sms_request_json(client): + with pytest.raises(expected_exception=ValidationError): + validate({'user_number': '447700900111'}, get_inbound_sms_request) + + +def test_valid_inbound_sms_response_json(): + assert validate(valid_inbound_sms, get_inbound_sms_single_response) == valid_inbound_sms + + +def test_valid_inbound_sms_list_response_json(): + validate(valid_inbound_sms_list, get_inbound_sms_response) + + +def test_invalid_inbound_sms_response_json(): + with pytest.raises(expected_exception=ValidationError): + validate(invalid_inbound_sms, get_inbound_sms_single_response) + + +def test_invalid_inbound_sms_list_response_json(): + with pytest.raises(expected_exception=ValidationError): + validate(invalid_inbound_sms_list, get_inbound_sms_response) diff --git a/tests/app/v2/notifications/test_post_notifications.py b/tests/app/v2/notifications/test_post_notifications.py index 0c5971f25..a6ed18ff4 100644 --- a/tests/app/v2/notifications/test_post_notifications.py +++ b/tests/app/v2/notifications/test_post_notifications.py @@ -123,6 +123,7 @@ def test_post_sms_notification_returns_201_with_sms_sender_id( notification_to_sms_sender = NotificationSmsSender.query.all() assert len(notification_to_sms_sender) == 1 assert str(notification_to_sms_sender[0].notification_id) == resp_json['id'] + assert resp_json['content']['from_number'] == sms_sender.sms_sender assert notification_to_sms_sender[0].service_sms_sender_id == sms_sender.id mocked.assert_called_once_with([resp_json['id']], queue='send-sms-tasks')