diff --git a/app/__init__.py b/app/__init__.py index 2ae2d497e..b4a470ac0 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -114,6 +114,7 @@ def register_blueprint(application): from app.organisation.invite_rest import organisation_invite_blueprint from app.complaint.complaint_rest import complaint_blueprint from app.platform_stats.rest import platform_stats_blueprint + from app.template_folder.rest import template_folder_blueprint service_blueprint.before_request(requires_admin_auth) application.register_blueprint(service_blueprint, url_prefix='/service') @@ -196,6 +197,9 @@ def register_blueprint(application): platform_stats_blueprint.before_request(requires_admin_auth) application.register_blueprint(platform_stats_blueprint, url_prefix='/platform-stats') + template_folder_blueprint.before_request(requires_admin_auth) + application.register_blueprint(template_folder_blueprint) + def register_v2_blueprints(application): from app.v2.inbound_sms.get_inbound_sms import v2_inbound_sms_blueprint as get_inbound_sms diff --git a/app/dao/template_folder_dao.py b/app/dao/template_folder_dao.py new file mode 100644 index 000000000..7df47c6ff --- /dev/null +++ b/app/dao/template_folder_dao.py @@ -0,0 +1,22 @@ +from app import db +from app.dao.dao_utils import transactional +from app.models import TemplateFolder + + +def dao_get_template_folder_by_id(template_folder_id): + return TemplateFolder.query.filter(TemplateFolder.id == template_folder_id).one() + + +@transactional +def dao_create_template_folder(template_folder): + db.session.add(template_folder) + + +@transactional +def dao_update_template_folder(template_folder): + db.session.add(template_folder) + + +@transactional +def dao_delete_template_folder(template_folder): + db.session.delete(template_folder) diff --git a/app/models.py b/app/models.py index 1087634e7..3f2877dff 100644 --- a/app/models.py +++ b/app/models.py @@ -714,8 +714,16 @@ class TemplateFolder(db.Model): name = db.Column(db.String, nullable=False) parent_id = db.Column(UUID(as_uuid=True), db.ForeignKey('template_folder.id'), nullable=True) - service = db.relationship('Service') - parent = db.relationship('TemplateFolder', remote_side=[id], backref='children') + service = db.relationship('Service', backref='all_template_folders') + parent = db.relationship('TemplateFolder', remote_side=[id], backref='subfolders') + + def serialize(self): + return { + 'id': self.id, + 'name': self.name, + 'parent_id': self.parent_id, + 'service_id': self.service_id + } template_folder_map = db.Table( @@ -864,7 +872,7 @@ class Template(TemplateBase): uselist=False, # eagerly load the folder whenever the template object is fetched lazy='joined', - backref=db.backref('templates', lazy='dynamic') + backref=db.backref('templates') ) def get_link(self): diff --git a/app/schema_validation/definitions.py b/app/schema_validation/definitions.py index c576c6e0a..8ab38d4b8 100644 --- a/app/schema_validation/definitions.py +++ b/app/schema_validation/definitions.py @@ -11,6 +11,14 @@ uuid = { "link": "link to our error documentation not yet implemented" } +nullable_uuid = { + "type": ["string", "null"], + "format": "validate_uuid", + "validationMessage": "is not a valid UUID", + "code": "1001", # yet to be implemented + "link": "link to our error documentation not yet implemented" +} + personalisation = { "type": "object", diff --git a/app/template_folder/__init__.py b/app/template_folder/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/app/template_folder/rest.py b/app/template_folder/rest.py new file mode 100644 index 000000000..e4b2779d5 --- /dev/null +++ b/app/template_folder/rest.py @@ -0,0 +1,84 @@ +from flask import Blueprint, jsonify, request +from sqlalchemy.exc import IntegrityError + +from app.dao.template_folder_dao import ( + dao_create_template_folder, + dao_get_template_folder_by_id, + dao_update_template_folder, + dao_delete_template_folder +) +from app.dao.services_dao import dao_fetch_service_by_id +from app.errors import register_errors +from app.models import TemplateFolder +from app.template_folder.template_folder_schema import ( + post_create_template_folder_schema, + post_rename_template_folder_schema +) +from app.schema_validation import validate + +template_folder_blueprint = Blueprint( + 'template_folder', + __name__, + url_prefix='/service//template-folder' +) +register_errors(template_folder_blueprint) + + +@template_folder_blueprint.errorhandler(IntegrityError) +def handle_integrity_error(exc): + if 'template_folder_parent_id_fkey' in str(exc): + return jsonify(result='error', message='parent_id not found'), 400 + + raise + + +@template_folder_blueprint.route('', methods=['GET']) +def get_template_folders_for_service(service_id): + service = dao_fetch_service_by_id(service_id) + + template_folders = [o.serialize() for o in service.all_template_folders] + return jsonify(template_folders=template_folders) + + +@template_folder_blueprint.route('', methods=['POST']) +def create_template_folder(service_id): + data = request.get_json() + + validate(data, post_create_template_folder_schema) + + template_folder = TemplateFolder( + service_id=service_id, + name=data['name'].strip(), + parent_id=data['parent_id'] + ) + + dao_create_template_folder(template_folder) + + return jsonify(data=template_folder.serialize()), 201 + + +@template_folder_blueprint.route('//rename', methods=['POST']) +def rename_template_folder(service_id, template_folder_id): + data = request.get_json() + + validate(data, post_rename_template_folder_schema) + + template_folder = dao_get_template_folder_by_id(template_folder_id) + template_folder.name = data['name'] + + dao_update_template_folder(template_folder) + + return jsonify(data=template_folder.serialize()), 200 + + +@template_folder_blueprint.route('/', methods=['DELETE']) +def delete_template_folder(service_id, template_folder_id): + template_folder = dao_get_template_folder_by_id(template_folder_id) + + # don't allow deleting if there's anything in the folder (even if it's just more empty subfolders) + if template_folder.subfolders or template_folder.templates: + return jsonify(result='error', message='Folder is not empty'), 400 + + dao_delete_template_folder(template_folder) + + return '', 204 diff --git a/app/template_folder/template_folder_schema.py b/app/template_folder/template_folder_schema.py new file mode 100644 index 000000000..6c974f051 --- /dev/null +++ b/app/template_folder/template_folder_schema.py @@ -0,0 +1,22 @@ +from app.schema_validation.definitions import nullable_uuid + +post_create_template_folder_schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "POST schema for getting template_folder", + "type": "object", + "properties": { + "name": {"type": "string", "minLength": 1}, + "parent_id": nullable_uuid + }, + "required": ["name", "parent_id"] +} + +post_rename_template_folder_schema = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "POST schema for renaming template_folder", + "type": "object", + "properties": { + "name": {"type": "string", "minLength": 1}, + }, + "required": ["name"] +} diff --git a/tests/app/db.py b/tests/app/db.py index a0d68a8b9..a6c2d6c64 100644 --- a/tests/app/db.py +++ b/tests/app/db.py @@ -48,7 +48,8 @@ from app.models import ( FactBilling, FactNotificationStatus, Complaint, - InvitedUser + InvitedUser, + TemplateFolder, ) @@ -690,3 +691,10 @@ def create_invited_user(service=None, invited_user = InvitedUser(**data) save_invited_user(invited_user) return invited_user + + +def create_template_folder(service, name='foo', parent=None): + tf = TemplateFolder(name=name, service=service, parent=parent) + db.session.add(tf) + db.session.commit() + return tf diff --git a/tests/app/template_folder/test_template_folder_rest.py b/tests/app/template_folder/test_template_folder_rest.py new file mode 100644 index 000000000..726b7d135 --- /dev/null +++ b/tests/app/template_folder/test_template_folder_rest.py @@ -0,0 +1,178 @@ +import uuid + +import pytest + +from app.models import TemplateFolder + +from tests.app.db import create_service, create_template_folder + + +def test_get_folders_for_service(admin_request, notify_db_session): + s1 = create_service(service_name='a') + s2 = create_service(service_name='b') + + tf1 = create_template_folder(s1) + tf2 = create_template_folder(s1) + + create_template_folder(s2) + + resp = admin_request.get('template_folder.get_template_folders_for_service', service_id=s1.id) + assert set(resp.keys()) == {'template_folders'} + assert sorted(resp['template_folders'], key=lambda x: x['id']) == sorted([ + {'id': str(tf1.id), 'name': 'foo', 'service_id': str(s1.id), 'parent_id': None}, + {'id': str(tf2.id), 'name': 'foo', 'service_id': str(s1.id), 'parent_id': None}, + ], key=lambda x: x['id']) + + +def test_get_folders_for_service_with_no_folders(sample_service, admin_request): + resp = admin_request.get('template_folder.get_template_folders_for_service', service_id=sample_service.id) + assert resp == {'template_folders': []} + + +@pytest.mark.parametrize('has_parent', [True, False]) +def test_create_template_folder(admin_request, sample_service, has_parent): + existing_folder = create_template_folder(sample_service) + + parent_id = str(existing_folder.id) if has_parent else None + + resp = admin_request.post( + 'template_folder.create_template_folder', + service_id=sample_service.id, + _data={ + 'name': 'foo', + 'parent_id': parent_id + }, + _expected_status=201 + ) + + assert resp['data']['name'] == 'foo' + assert resp['data']['service_id'] == str(sample_service.id) + assert resp['data']['parent_id'] == parent_id + + +@pytest.mark.parametrize('missing_field', ['name', 'parent_id']) +def test_create_template_folder_fails_if_missing_fields(admin_request, sample_service, missing_field): + data = { + 'name': 'foo', + 'parent_id': None + } + data.pop(missing_field) + + resp = admin_request.post( + 'template_folder.create_template_folder', + service_id=sample_service.id, + _data=data, + _expected_status=400 + ) + + assert resp == { + 'status_code': 400, + 'errors': [ + {'error': 'ValidationError', 'message': '{} is a required property'.format(missing_field)} + ] + } + + +def test_create_template_folder_fails_if_unknown_parent_id(admin_request, sample_service): + # create existing folder + create_template_folder(sample_service) + + resp = admin_request.post( + 'template_folder.create_template_folder', + service_id=sample_service.id, + _data={'name': 'bar', 'parent_id': str(uuid.uuid4())}, + _expected_status=400 + ) + + assert resp['result'] == 'error' + assert resp['message'] == 'parent_id not found' + + +def test_rename_template_folder(admin_request, sample_service): + existing_folder = create_template_folder(sample_service) + + resp = admin_request.post( + 'template_folder.rename_template_folder', + service_id=sample_service.id, + template_folder_id=existing_folder.id, + _data={ + 'name': 'bar' + } + ) + + assert resp['data']['name'] == 'bar' + assert existing_folder.name == 'bar' + + +@pytest.mark.parametrize('data, err', [ + ({}, 'name is a required property'), + ({'name': None}, 'name None is not of type string'), + ({'name': ''}, 'name is too short'), +]) +def test_rename_template_folder_fails_if_missing_name(admin_request, sample_service, data, err): + existing_folder = create_template_folder(sample_service) + + resp = admin_request.post( + 'template_folder.rename_template_folder', + service_id=sample_service.id, + template_folder_id=existing_folder.id, + _data=data, + _expected_status=400 + ) + + assert resp == { + 'status_code': 400, + 'errors': [ + {'error': 'ValidationError', 'message': err} + ] + } + + +def test_delete_template_folder(admin_request, sample_service): + existing_folder = create_template_folder(sample_service) + + admin_request.delete( + 'template_folder.delete_template_folder', + service_id=sample_service.id, + template_folder_id=existing_folder.id, + ) + + assert TemplateFolder.query.all() == [] + + +def test_delete_template_folder_fails_if_folder_has_subfolders(admin_request, sample_service): + existing_folder = create_template_folder(sample_service) + existing_subfolder = create_template_folder(sample_service, parent=existing_folder) # noqa + + resp = admin_request.delete( + 'template_folder.delete_template_folder', + service_id=sample_service.id, + template_folder_id=existing_folder.id, + _expected_status=400 + ) + + assert resp == { + 'result': 'error', + 'message': 'Folder is not empty' + } + + assert TemplateFolder.query.count() == 2 + + +def test_delete_template_folder_fails_if_folder_contains_templates(admin_request, sample_service, sample_template): + existing_folder = create_template_folder(sample_service) + sample_template.folder = existing_folder + + resp = admin_request.delete( + 'template_folder.delete_template_folder', + service_id=sample_service.id, + template_folder_id=existing_folder.id, + _expected_status=400 + ) + + assert resp == { + 'result': 'error', + 'message': 'Folder is not empty' + } + + assert TemplateFolder.query.count() == 1