From c02b304d1eceb835bd335b2c74dc143292c45d9c Mon Sep 17 00:00:00 2001 From: Adam Shimali Date: Tue, 23 Feb 2016 16:21:47 +0000 Subject: [PATCH 1/4] Add model class and migration script for invited user. --- README.md | 1 + app/models.py | 35 ++++++++++++++++ migrations/versions/0022_add_invite_users.py | 42 ++++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 migrations/versions/0022_add_invite_users.py 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/models.py b/app/models.py index 84726164a..e0065e3e3 100644 --- a/app/models.py +++ b/app/models.py @@ -224,3 +224,38 @@ 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') + _token = db.Column(db.String, nullable=False) + 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='invited') + + @property + def token(self): + raise AttributeError("Token not readable") + + @token.setter + def token(self, token): + self._token = hashpw(token) + + def check_token(self, token): + return check_hash(token, self._token) 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 ### From 12a2d8db0ab5f623e53dbbb0943fcb054e90f29d Mon Sep 17 00:00:00 2001 From: Rebecca Law Date: Tue, 23 Feb 2016 17:52:55 +0000 Subject: [PATCH 2/4] New endpoint to get users for service id. /service//users returns a list of all users associated with the service --- app/service/rest.py | 14 ++++++++++++-- tests/app/service/test_rest.py | 25 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/app/service/rest.py b/app/service/rest.py index 51eae44cf..b3fb67dfa 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(result="error", message="Service not found"), 404 + print(fetched.users) + result = users_schema.dump(fetched.users) + return jsonify(data=result.data) diff --git a/tests/app/service/test_rest.py b/tests/app/service/test_rest.py index 324b77d2a..999993715 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,27 @@ 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 From f1fdfbb308aae5ab70a048803ff1dc82d3782140 Mon Sep 17 00:00:00 2001 From: Rebecca Law Date: Wed, 24 Feb 2016 10:30:00 +0000 Subject: [PATCH 3/4] Return empty list when there are no users for the service. Added a test for when there are no users for the service. Added a test_url_for - do we want to add this test and use url_for in our tests? Or explictly write the url in the test? --- app/service/rest.py | 4 ++-- tests/app/service/test_rest.py | 22 +++++++++++++++++ tests/app/service/test_url_for.py | 40 +++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 tests/app/service/test_url_for.py diff --git a/app/service/rest.py b/app/service/rest.py index b3fb67dfa..3363e210e 100644 --- a/app/service/rest.py +++ b/app/service/rest.py @@ -161,7 +161,7 @@ def get_api_keys(service_id, key_id=None): def get_users_for_service(service_id): fetched = dao_fetch_service_by_id(service_id) if not fetched: - return jsonify(result="error", message="Service not found"), 404 - print(fetched.users) + return jsonify(data=[]) + result = users_schema.dump(fetched.users) return jsonify(data=result.data) diff --git a/tests/app/service/test_rest.py b/tests/app/service/test_rest.py index 999993715..8df0b526f 100644 --- a/tests/app/service/test_rest.py +++ b/tests/app/service/test_rest.py @@ -366,3 +366,25 @@ def test_get_users_by_service(notify_api, notify_db, notify_db_session, sample_u 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) From e6fe10cbdcb6276c333ffb36f24a0dfa2b3153ce Mon Sep 17 00:00:00 2001 From: Adam Shimali Date: Wed, 24 Feb 2016 14:01:19 +0000 Subject: [PATCH 4/4] [WIP] added endpoint and dao to create invites for users. Droped token as later code to send email invite can generate timebased url to send to user. That can then be checked against configurable time threshold for expiry. Therefore no need to store a token. --- app/__init__.py | 2 + app/dao/invited_user_dao.py | 6 +++ app/invite/__init__.py | 0 app/invite/rest.py | 24 +++++++++ app/models.py | 14 +----- app/schemas.py | 12 +++++ migrations/versions/0023_drop_token.py | 26 ++++++++++ tests/app/dao/test_invited_user_dao.py | 23 +++++++++ tests/app/invite/__init__.py | 0 tests/app/invite/test_invite_rest.py | 70 ++++++++++++++++++++++++++ 10 files changed, 164 insertions(+), 13 deletions(-) create mode 100644 app/dao/invited_user_dao.py create mode 100644 app/invite/__init__.py create mode 100644 app/invite/rest.py create mode 100644 migrations/versions/0023_drop_token.py create mode 100644 tests/app/dao/test_invited_user_dao.py create mode 100644 tests/app/invite/__init__.py create mode 100644 tests/app/invite/test_invite_rest.py diff --git a/app/__init__.py b/app/__init__.py index 87863e235..6ca701570 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -45,6 +45,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') @@ -52,6 +53,7 @@ def create_app(): application.register_blueprint(status_blueprint, url_prefix='/status') application.register_blueprint(notifications_blueprint, url_prefix='/notifications') 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 e0065e3e3..170a8949e 100644 --- a/app/models.py +++ b/app/models.py @@ -239,7 +239,6 @@ class InvitedUser(db.Model): 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') - _token = db.Column(db.String, nullable=False) created_at = db.Column( db.DateTime, index=False, @@ -247,15 +246,4 @@ class InvitedUser(db.Model): nullable=False, default=datetime.datetime.now) status = db.Column( - db.Enum(*INVITED_USER_STATUS_TYPES, name='invited_users_status_types'), nullable=False, default='invited') - - @property - def token(self): - raise AttributeError("Token not readable") - - @token.setter - def token(self, token): - self._token = hashpw(token) - - def check_token(self, token): - return check_hash(token, self._token) + 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 c024c2be1..9851fc3d1 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -118,6 +118,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) @@ -142,3 +152,5 @@ email_notification_schema = EmailNotificationSchema() 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/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']}