diff --git a/README.md b/README.md index 9e4023038..690625b41 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ export NOTIFICATION_QUEUE_PREFIX='[unique-to-environment]-notification_developme export SECRET_KEY='dev-notify-secret-key' export SQLALCHEMY_DATABASE_URI = 'postgresql://localhost/notification_api' export VERIFY_CODE_FROM_EMAIL_ADDRESS='no-reply@notify.works' +export NOTIFY_EMAIL_DOMAIN='dev.notify.com' "> environment.sh ``` diff --git a/app/__init__.py b/app/__init__.py index c94608710..c1f3a9741 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -46,6 +46,7 @@ def create_app(): from app.status.healthcheck import status as status_blueprint from app.job.rest import job as job_blueprint from app.notifications.rest import notifications as notifications_blueprint + from app.invite.rest import invite as invite_blueprint application.register_blueprint(service_blueprint, url_prefix='/service') application.register_blueprint(user_blueprint, url_prefix='/user') @@ -53,6 +54,7 @@ def create_app(): application.register_blueprint(status_blueprint, url_prefix='/status') application.register_blueprint(notifications_blueprint) application.register_blueprint(job_blueprint) + application.register_blueprint(invite_blueprint) return application diff --git a/app/dao/invited_user_dao.py b/app/dao/invited_user_dao.py new file mode 100644 index 000000000..b17faa2f2 --- /dev/null +++ b/app/dao/invited_user_dao.py @@ -0,0 +1,6 @@ +from app import db + + +def save_invited_user(invited_user): + db.session.add(invited_user) + db.session.commit() diff --git a/app/invite/__init__.py b/app/invite/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/invite/rest.py b/app/invite/rest.py new file mode 100644 index 000000000..25ed891eb --- /dev/null +++ b/app/invite/rest.py @@ -0,0 +1,24 @@ +from flask import ( + Blueprint, + request, + jsonify +) + +from app.dao.invited_user_dao import save_invited_user +from app.schemas import invited_user_schema + +invite = Blueprint('invite', __name__, url_prefix='/service//invite') + +from app.errors import register_errors +register_errors(invite) + + +@invite.route('', methods=['POST']) +def create_invite_user(service_id): + invited_user, errors = invited_user_schema.load(request.get_json()) + if errors: + return jsonify(result="error", message=errors), 400 + + save_invited_user(invited_user) + + return jsonify(data=invited_user_schema.dump(invited_user).data), 201 diff --git a/app/models.py b/app/models.py index 14da343d5..ec6f54d8f 100644 --- a/app/models.py +++ b/app/models.py @@ -239,3 +239,26 @@ class Notification(db.Model): onupdate=datetime.datetime.now) status = db.Column( db.Enum(*NOTIFICATION_STATUS_TYPES, name='notification_status_types'), nullable=False, default='sent') + + +INVITED_USER_STATUS_TYPES = ['pending', 'accepted', 'cancelled'] + + +class InvitedUser(db.Model): + + __tablename__ = 'invited_users' + + id = db.Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email_address = db.Column(db.String(255), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), index=True, nullable=False) + from_user = db.relationship('User') + service_id = db.Column(UUID(as_uuid=True), db.ForeignKey('services.id'), index=True, unique=False) + service = db.relationship('Service') + created_at = db.Column( + db.DateTime, + index=False, + unique=False, + nullable=False, + default=datetime.datetime.now) + status = db.Column( + db.Enum(*INVITED_USER_STATUS_TYPES, name='invited_users_status_types'), nullable=False, default='pending') diff --git a/app/schemas.py b/app/schemas.py index 68770f20f..11ecfb08b 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -128,6 +128,16 @@ class NotificationStatusSchema(BaseSchema): model = models.Notification +class InvitedUserSchema(BaseSchema): + class Meta: + model = models.InvitedUser + + @validates('email_address') + def validate_to(self, value): + if not email_regex.match(value): + raise ValidationError('Invalid email') + + user_schema = UserSchema() user_schema_load_json = UserSchema(load_json=True) users_schema = UserSchema(many=True) @@ -153,3 +163,5 @@ job_email_template_notification_schema = JobEmailTemplateNotificationSchema() notification_status_schema = NotificationStatusSchema() notifications_status_schema = NotificationStatusSchema(many=True) notification_status_schema_load_json = NotificationStatusSchema(load_json=True) +invited_user_schema = InvitedUserSchema() +invited_users_schema = InvitedUserSchema(many=True) diff --git a/app/service/rest.py b/app/service/rest.py index 51eae44cf..3363e210e 100644 --- a/app/service/rest.py +++ b/app/service/rest.py @@ -27,8 +27,8 @@ from app.models import ApiKey from app.schemas import ( services_schema, service_schema, - api_keys_schema -) + api_keys_schema, + users_schema) from app import email_safe from flask import Blueprint @@ -155,3 +155,13 @@ def get_api_keys(service_id, key_id=None): return jsonify(result="error", message="API key not found"), 404 return jsonify(apiKeys=api_keys_schema.dump(api_keys).data), 200 + + +@service.route('//users', methods=['GET']) +def get_users_for_service(service_id): + fetched = dao_fetch_service_by_id(service_id) + if not fetched: + return jsonify(data=[]) + + result = users_schema.dump(fetched.users) + return jsonify(data=result.data) diff --git a/migrations/versions/0022_add_invite_users.py b/migrations/versions/0022_add_invite_users.py new file mode 100644 index 000000000..b77be1d73 --- /dev/null +++ b/migrations/versions/0022_add_invite_users.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 0022_add_invite_users +Revises: 0021_add_job_metadata +Create Date: 2016-02-23 16:41:40.481468 + +""" + +# revision identifiers, used by Alembic. +revision = '0022_add_invite_users' +down_revision = '0021_add_job_metadata' + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.create_table('invited_users', + sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('email_address', sa.String(length=255), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('service_id', postgresql.UUID(as_uuid=True), nullable=True), + sa.Column('_token', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('status', sa.Enum('pending', 'accepted', 'cancelled', name='invited_users_status_types'), nullable=False), + sa.ForeignKeyConstraint(['service_id'], ['services.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_invited_users_service_id'), 'invited_users', ['service_id'], unique=False) + op.create_index(op.f('ix_invited_users_user_id'), 'invited_users', ['user_id'], unique=False) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_invited_users_user_id'), table_name='invited_users') + op.drop_index(op.f('ix_invited_users_service_id'), table_name='invited_users') + op.drop_table('invited_users') + op.execute('DROP TYPE invited_users_status_types') + ### end Alembic commands ### diff --git a/migrations/versions/0023_drop_token.py b/migrations/versions/0023_drop_token.py new file mode 100644 index 000000000..bf6f705d7 --- /dev/null +++ b/migrations/versions/0023_drop_token.py @@ -0,0 +1,26 @@ +"""empty message + +Revision ID: 0023_drop_token +Revises: 0022_add_invite_users +Create Date: 2016-02-24 13:58:04.440296 + +""" + +# revision identifiers, used by Alembic. +revision = '0023_drop_token' +down_revision = '0022_add_invite_users' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('invited_users', '_token') + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('invited_users', sa.Column('_token', sa.VARCHAR(), autoincrement=False, nullable=False)) + ### end Alembic commands ### diff --git a/tests/app/dao/test_invited_user_dao.py b/tests/app/dao/test_invited_user_dao.py new file mode 100644 index 000000000..5d81ad488 --- /dev/null +++ b/tests/app/dao/test_invited_user_dao.py @@ -0,0 +1,23 @@ + +from app.models import InvitedUser + +from app.dao.invited_user_dao import save_invited_user + + +def test_create_invited_user(notify_db, notify_db_session, sample_service): + assert InvitedUser.query.count() == 0 + email_address = 'invited_user@service.gov.uk' + invite_from = sample_service.users[0] + + data = { + 'service': sample_service, + 'email_address': email_address, + 'from_user': invite_from + } + + invited_user = InvitedUser(**data) + save_invited_user(invited_user) + + assert InvitedUser.query.count() == 1 + assert invited_user.email_address == email_address + assert invited_user.from_user == invite_from diff --git a/tests/app/invite/__init__.py b/tests/app/invite/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/app/invite/test_invite_rest.py b/tests/app/invite/test_invite_rest.py new file mode 100644 index 000000000..1ba4a69ee --- /dev/null +++ b/tests/app/invite/test_invite_rest.py @@ -0,0 +1,70 @@ +import json + +from tests import create_authorization_header + + +def test_create_invited_user(notify_api, sample_service): + with notify_api.test_request_context(): + with notify_api.test_client() as client: + + email_address = 'invited_user@service.gov.uk' + invite_from = sample_service.users[0] + + data = { + 'service': str(sample_service.id), + 'email_address': email_address, + 'from_user': invite_from.id + } + + data = json.dumps(data) + + auth_header = create_authorization_header( + path='/service/{}/invite'.format(sample_service.id), + method='POST', + request_body=data + ) + + response = client.post( + '/service/{}/invite'.format(sample_service.id), + headers=[('Content-Type', 'application/json'), auth_header], + data=data + ) + assert response.status_code == 201 + json_resp = json.loads(response.get_data(as_text=True)) + + assert json_resp['data']['service'] == str(sample_service.id) + assert json_resp['data']['email_address'] == email_address + assert json_resp['data']['from_user'] == invite_from.id + assert json_resp['data']['id'] + + +def test_create_invited_user_invalid_email(notify_api, sample_service): + with notify_api.test_request_context(): + with notify_api.test_client() as client: + + email_address = 'notanemail' + invite_from = sample_service.users[0] + + data = { + 'service': str(sample_service.id), + 'email_address': email_address, + 'from_user': invite_from.id + } + + data = json.dumps(data) + + auth_header = create_authorization_header( + path='/service/{}/invite'.format(sample_service.id), + method='POST', + request_body=data + ) + + response = client.post( + '/service/{}/invite'.format(sample_service.id), + headers=[('Content-Type', 'application/json'), auth_header], + data=data + ) + assert response.status_code == 400 + json_resp = json.loads(response.get_data(as_text=True)) + assert json_resp['result'] == 'error' + assert json_resp['message'] == {'email_address': ['Invalid email']} diff --git a/tests/app/service/test_rest.py b/tests/app/service/test_rest.py index 324b77d2a..8df0b526f 100644 --- a/tests/app/service/test_rest.py +++ b/tests/app/service/test_rest.py @@ -4,6 +4,7 @@ import uuid from app.dao.users_dao import save_model_user from app.models import User from tests import create_authorization_header +from tests.app.conftest import sample_service as create_service def test_get_service_list(notify_api, service_factory): @@ -341,3 +342,49 @@ def test_update_service_should_404_if_id_is_invalid(notify_api, notify_db): headers=[('Content-Type', 'application/json'), auth_header] ) assert resp.status_code == 404 + + +def test_get_users_by_service(notify_api, notify_db, notify_db_session, sample_user): + with notify_api.test_request_context(): + with notify_api.test_client() as client: + + sample_service = create_service(notify_db=notify_db, notify_db_session=notify_db_session, + service_name='Sample service', user=sample_user) + auth_header = create_authorization_header( + path='/service/{}/users'.format(sample_service.id), + method='GET' + ) + + resp = client.get( + '/service/{}/users'.format(sample_service.id), + headers=[('Content-Type', 'application/json'), auth_header] + ) + + assert resp.status_code == 200 + result = json.loads(resp.get_data(as_text=True)) + assert len(result['data']) == 1 + assert result['data'][0]['name'] == sample_user.name + assert result['data'][0]['email_address'] == sample_user.email_address + assert result['data'][0]['mobile_number'] == sample_user.mobile_number + + +def test_get_users_for_service_returns_empty_list_if_no_users_associated_with_service(notify_api, + notify_db, + notify_db_session, + sample_service, + sample_user): + with notify_api.test_request_context(): + with notify_api.test_client() as client: + sample_service.users.remove(sample_user) + auth_header = create_authorization_header( + path='/service/{}/users'.format(sample_service.id), + method='GET' + ) + + response = client.get( + '/service/{}/users'.format(sample_service.id), + headers=[('Content-Type', 'application/json'), auth_header] + ) + assert response.status_code == 200 + result = json.loads(response.get_data(as_text=True)) + assert result['data'] == [] diff --git a/tests/app/service/test_url_for.py b/tests/app/service/test_url_for.py new file mode 100644 index 000000000..286b949d1 --- /dev/null +++ b/tests/app/service/test_url_for.py @@ -0,0 +1,40 @@ +import uuid + +from flask import url_for + +service_id = str(uuid.uuid4()) + + +def test_url_for_get_services(notify_api): + with notify_api.test_request_context(): + url = url_for('service.get_services') + assert str(url) == '/service' + url_with_user_id = url_for('service.get_services', user_id=1) + assert str(url_with_user_id) == '/service?user_id=1' + + +def test_url_for_get_service_by_id(notify_api): + with notify_api.test_request_context(): + url = url_for('service.get_service_by_id', service_id=service_id) + assert str(url) == '/service/{}'.format(service_id) + + url_with_user_id = url_for('service.get_service_by_id', service_id=service_id, user_id=1) + assert str(url_with_user_id) == '/service/{0}?user_id={1}'.format(service_id, 1) + + +def test_url_for_create_service(notify_api): + with notify_api.test_request_context(): + url = url_for('service.create_service') + assert str(url) == '/service'.format(service_id) + + +def test_url_for_update_service(notify_api): + with notify_api.test_request_context(): + url = url_for('service.update_service', service_id=service_id) + assert str(url) == '/service/{}'.format(service_id) + + +def test_url_for_renew_api_key(notify_api): + with notify_api.test_request_context(): + url = url_for('service.renew_api_key', service_id=service_id) + assert str(url) == '/service/{}/api-key'.format(service_id)