From 864820a50a3c9adf5d5f25d308f834f3dd86b4c9 Mon Sep 17 00:00:00 2001 From: Pablo Date: Mon, 19 Apr 2021 17:43:11 -0500 Subject: [PATCH] fix: TT-207 Dont allow projects deleting --- .../projects/projects_namespace_test.py | 314 ++++++++++-------- time_tracker_api/projects/projects_model.py | 21 +- .../projects/projects_namespace.py | 133 +++++--- 3 files changed, 275 insertions(+), 193 deletions(-) diff --git a/tests/time_tracker_api/projects/projects_namespace_test.py b/tests/time_tracker_api/projects/projects_namespace_test.py index f415c7d6..8d510df4 100644 --- a/tests/time_tracker_api/projects/projects_namespace_test.py +++ b/tests/time_tracker_api/projects/projects_namespace_test.py @@ -13,245 +13,299 @@ "description": fake.paragraph(), 'customer_id': fake.uuid4(), 'project_type_id': fake.uuid4(), - 'technologies': ["python", "faker", "openapi"] - + 'technologies': ["python", "faker", "openapi"], } -fake_project = ({ - "id": fake.random_int(1, 9999) -}).update(valid_project_data) +fake_project = ({"id": fake.random_int(1, 9999)}).update(valid_project_data) -def test_create_project_should_succeed_with_valid_request(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): +def test_create_project_should_succeed_with_valid_request( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): from time_tracker_api.projects.projects_namespace import project_dao - repository_create_mock = mocker.patch.object(project_dao.repository, - 'create', - return_value=fake_project) - response = client.post("/projects", - headers=valid_header, - json=valid_project_data, - follow_redirects=True) + + repository_create_mock = mocker.patch.object( + project_dao.repository, 'create', return_value=fake_project + ) + response = client.post( + "/projects", + headers=valid_header, + json=valid_project_data, + follow_redirects=True, + ) assert HTTPStatus.CREATED == response.status_code repository_create_mock.assert_called_once() -def test_create_project_should_reject_bad_request(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): +def test_create_project_should_reject_bad_request( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): from time_tracker_api.projects.projects_namespace import project_dao + invalid_project_data = valid_project_data.copy() - invalid_project_data.update({ - "project_type_id": fake.pyint(min_value=1, max_value=100), - }) - repository_create_mock = mocker.patch.object(project_dao.repository, - 'create', - return_value=fake_project) - - response = client.post("/projects", - headers=valid_header, - json=invalid_project_data, - follow_redirects=True) + invalid_project_data.update( + { + "project_type_id": fake.pyint(min_value=1, max_value=100), + } + ) + repository_create_mock = mocker.patch.object( + project_dao.repository, 'create', return_value=fake_project + ) + + response = client.post( + "/projects", + headers=valid_header, + json=invalid_project_data, + follow_redirects=True, + ) assert HTTPStatus.BAD_REQUEST == response.status_code repository_create_mock.assert_not_called() -def test_list_all_projects(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): +def test_list_all_projects( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): from time_tracker_api.projects.projects_namespace import project_dao - repository_find_all_mock = mocker.patch.object(project_dao.repository, - 'find_all', - return_value=[]) - response = client.get("/projects", - headers=valid_header, - follow_redirects=True) + repository_find_all_mock = mocker.patch.object( + project_dao.repository, 'find_all', return_value=[] + ) + + response = client.get( + "/projects", headers=valid_header, follow_redirects=True + ) assert HTTPStatus.OK == response.status_code assert [] == json.loads(response.data) repository_find_all_mock.assert_called_once() -def test_get_project_should_succeed_with_valid_id(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): +def test_list_all_active_projects( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): from time_tracker_api.projects.projects_namespace import project_dao + + repository_find_all_mock = mocker.patch.object( + project_dao.repository, 'find_all', return_value=[] + ) + + response = client.get( + "/projects?status=active", headers=valid_header, follow_redirects=True + ) + + assert HTTPStatus.OK == response.status_code + assert [] == json.loads(response.data) + repository_find_all_mock.assert_called_once() + + +def test_get_project_should_succeed_with_valid_id( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + from time_tracker_api.projects.projects_namespace import project_dao + valid_id = fake.random_int(1, 9999) - repository_find_mock = mocker.patch.object(project_dao.repository, - 'find', - return_value=fake_project) + repository_find_mock = mocker.patch.object( + project_dao.repository, 'find', return_value=fake_project + ) - response = client.get("/projects/%s" % valid_id, - headers=valid_header, - follow_redirects=True) + response = client.get( + "/projects/%s" % valid_id, headers=valid_header, follow_redirects=True + ) assert HTTPStatus.OK == response.status_code fake_project == json.loads(response.data) repository_find_mock.assert_called_once_with(str(valid_id), ANY) -def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): +def test_get_project_should_return_not_found_with_invalid_id( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import NotFound invalid_id = fake.random_int(1, 9999) - repository_find_mock = mocker.patch.object(project_dao.repository, - 'find', - side_effect=NotFound) + repository_find_mock = mocker.patch.object( + project_dao.repository, 'find', side_effect=NotFound + ) - response = client.get("/projects/%s" % invalid_id, - headers=valid_header, - follow_redirects=True) + response = client.get( + "/projects/%s" % invalid_id, + headers=valid_header, + follow_redirects=True, + ) assert HTTPStatus.NOT_FOUND == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), ANY) -def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): +def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_format( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import UnprocessableEntity invalid_id = fake.company() - repository_find_mock = mocker.patch.object(project_dao.repository, - 'find', - side_effect=UnprocessableEntity) + repository_find_mock = mocker.patch.object( + project_dao.repository, 'find', side_effect=UnprocessableEntity + ) - response = client.get("/projects/%s" % invalid_id, - headers=valid_header, - follow_redirects=True) + response = client.get( + "/projects/%s" % invalid_id, + headers=valid_header, + follow_redirects=True, + ) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), ANY) -def test_update_project_should_succeed_with_valid_data(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): +def test_update_project_should_succeed_with_valid_data( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): from time_tracker_api.projects.projects_namespace import project_dao - repository_update_mock = mocker.patch.object(project_dao.repository, - 'partial_update', - return_value=fake_project) + repository_update_mock = mocker.patch.object( + project_dao.repository, 'partial_update', return_value=fake_project + ) valid_id = fake.random_int(1, 9999) - response = client.put("/projects/%s" % valid_id, - headers=valid_header, - json=valid_project_data, - follow_redirects=True) + response = client.put( + "/projects/%s" % valid_id, + headers=valid_header, + json=valid_project_data, + follow_redirects=True, + ) assert HTTPStatus.OK == response.status_code fake_project == json.loads(response.data) - repository_update_mock.assert_called_once_with(str(valid_id), valid_project_data, ANY) + repository_update_mock.assert_called_once_with( + str(valid_id), valid_project_data, ANY + ) -def test_update_project_should_reject_bad_request(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): +def test_update_project_should_reject_bad_request( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): from time_tracker_api.projects.projects_namespace import project_dao + invalid_project_data = valid_project_data.copy() - invalid_project_data.update({ - "project_type_id": fake.pyint(min_value=1, max_value=100), - }) - repository_update_mock = mocker.patch.object(project_dao.repository, - 'partial_update', - return_value=fake_project) + invalid_project_data.update( + { + "project_type_id": fake.pyint(min_value=1, max_value=100), + } + ) + repository_update_mock = mocker.patch.object( + project_dao.repository, 'partial_update', return_value=fake_project + ) valid_id = fake.random_int(1, 9999) - response = client.put("/projects/%s" % valid_id, - headers=valid_header, - json=invalid_project_data, - follow_redirects=True) + response = client.put( + "/projects/%s" % valid_id, + headers=valid_header, + json=invalid_project_data, + follow_redirects=True, + ) assert HTTPStatus.BAD_REQUEST == response.status_code repository_update_mock.assert_not_called() -def test_update_project_should_return_not_found_with_invalid_id(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): +def test_update_project_should_return_not_found_with_invalid_id( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import NotFound invalid_id = fake.random_int(1, 9999) - repository_update_mock = mocker.patch.object(project_dao.repository, - 'partial_update', - side_effect=NotFound) + repository_update_mock = mocker.patch.object( + project_dao.repository, 'partial_update', side_effect=NotFound + ) - response = client.put("/projects/%s" % invalid_id, - headers=valid_header, - json=valid_project_data, - follow_redirects=True) + response = client.put( + "/projects/%s" % invalid_id, + headers=valid_header, + json=valid_project_data, + follow_redirects=True, + ) assert HTTPStatus.NOT_FOUND == response.status_code - repository_update_mock.assert_called_once_with(str(invalid_id), valid_project_data, ANY) + repository_update_mock.assert_called_once_with( + str(invalid_id), valid_project_data, ANY + ) -def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): +def test_delete_project_should_succeed_with_valid_id( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): from time_tracker_api.projects.projects_namespace import project_dao valid_id = fake.random_int(1, 9999) - repository_remove_mock = mocker.patch.object(project_dao.repository, - 'delete', - return_value=None) + repository_remove_mock = mocker.patch.object( + project_dao.repository, 'partial_update', return_value=None + ) - response = client.delete("/projects/%s" % valid_id, - headers=valid_header, - follow_redirects=True) + response = client.delete( + "/projects/%s" % valid_id, headers=valid_header, follow_redirects=True + ) assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data - repository_remove_mock.assert_called_once_with(str(valid_id), ANY) + repository_remove_mock.assert_called_once_with( + str(valid_id), {'status': 'inactive'}, ANY + ) -def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): +def test_delete_project_should_return_not_found_with_invalid_id( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import NotFound invalid_id = fake.random_int(1, 9999) - repository_remove_mock = mocker.patch.object(project_dao.repository, - 'delete', - side_effect=NotFound) + repository_remove_mock = mocker.patch.object( + project_dao.repository, 'partial_update', side_effect=NotFound + ) - response = client.delete("/projects/%s" % invalid_id, - headers=valid_header, - follow_redirects=True) + response = client.delete( + "/projects/%s" % invalid_id, + headers=valid_header, + follow_redirects=True, + ) assert HTTPStatus.NOT_FOUND == response.status_code - repository_remove_mock.assert_called_once_with(str(invalid_id), ANY) + repository_remove_mock.assert_called_once_with( + str(invalid_id), {'status': 'inactive'}, ANY + ) -def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): +def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import UnprocessableEntity invalid_id = fake.company() - repository_remove_mock = mocker.patch.object(project_dao.repository, - 'delete', - side_effect=UnprocessableEntity) + repository_remove_mock = mocker.patch.object( + project_dao.repository, + 'partial_update', + side_effect=UnprocessableEntity, + ) - response = client.delete("/projects/%s" % invalid_id, - headers=valid_header, - follow_redirects=True) + response = client.delete( + "/projects/%s" % invalid_id, + headers=valid_header, + follow_redirects=True, + ) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_remove_mock.assert_called_once_with(str(invalid_id), ANY) + repository_remove_mock.assert_called_once_with( + str(invalid_id), {'status': 'inactive'}, ANY + ) diff --git a/time_tracker_api/projects/projects_model.py b/time_tracker_api/projects/projects_model.py index 0defd8b4..ca3fded5 100644 --- a/time_tracker_api/projects/projects_model.py +++ b/time_tracker_api/projects/projects_model.py @@ -24,7 +24,9 @@ class ProjectDao(CRUDDao): 'id': 'project', 'partition_key': PartitionKey(path='/tenant_id'), 'unique_key_policy': { - 'uniqueKeys': [{'paths': ['/name', '/customer_id', '/deleted']},] + 'uniqueKeys': [ + {'paths': ['/name', '/customer_id', '/deleted']}, + ] }, } @@ -37,6 +39,7 @@ class ProjectCosmosDBModel(CosmosDBModel): project_type_id: int customer_id: str deleted: str + status: str tenant_id: str technologies: list @@ -71,11 +74,12 @@ def find_all_v2( project_ids: List[str], customer_ids: List[str] = None, visible_only=True, - mapper: Callable = None, + mapper: Callable = None, ): - query_builder = (CosmosDBQueryBuilder() - .add_sql_in_condition("id",project_ids) - .add_sql_in_condition("customer_id",customer_ids) + query_builder = ( + CosmosDBQueryBuilder() + .add_sql_in_condition("id", project_ids) + .add_sql_in_condition("customer_id", customer_ids) .add_sql_visibility_condition(visible_only) .build() ) @@ -121,10 +125,9 @@ def get_all(self, conditions: dict = None, **kwargs) -> list: add_customer_name_to_projects(projects, customers) return projects - def get_all_with_id_in_list(self,id_list): - event_ctx = self.create_event_context("read-many") - return self.repository.find_all_v2(event_ctx, id_list) - + def get_all_with_id_in_list(self, id_list): + event_ctx = self.create_event_context("read-many") + return self.repository.find_all_v2(event_ctx, id_list) def create_dao() -> ProjectDao: diff --git a/time_tracker_api/projects/projects_namespace.py b/time_tracker_api/projects/projects_namespace.py index c64039c8..3757b878 100644 --- a/time_tracker_api/projects/projects_namespace.py +++ b/time_tracker_api/projects/projects_namespace.py @@ -2,8 +2,14 @@ from flask_restplus import Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import common_fields, create_attributes_filter, UUID, api, remove_required_constraint, \ - NullableString +from time_tracker_api.api import ( + common_fields, + create_attributes_filter, + UUID, + api, + remove_required_constraint, + NullableString, +) from time_tracker_api.projects.projects_model import create_dao faker = Faker() @@ -11,42 +17,58 @@ ns = api.namespace('projects', description='Namespace of the API for projects') # Project Model -project_input = ns.model('ProjectInput', { - 'name': fields.String( - required=True, - title='Name', - max_length=50, - description='Name of the project', - example=faker.company(), - ), - 'description': NullableString( - title='Description', - required=False, - max_length=250, - description='Description about the project', - example=faker.paragraph(), - ), - 'customer_id': UUID( - title='Identifier of the Customer', - required=False, - description='Customer this project type belongs to. ' - 'If not specified, it will be considered an internal project of the tenant.', - example=faker.uuid4(), - ), - 'project_type_id': UUID( - title='Identifier of the project type', - required=False, - description='Id of the project type it belongs. This allows grouping the projects.', - example=faker.uuid4(), - ), - 'technologies': fields.List( - fields.String, - title='List of technologies this project involves', - required=False, - description='List of technologies this project involves', - example="['python', 'restplus', 'openapi']", - ) -}) +project_input = ns.model( + 'ProjectInput', + { + 'name': fields.String( + required=True, + title='Name', + max_length=50, + description='Name of the project', + example=faker.company(), + ), + 'description': NullableString( + title='Description', + required=False, + max_length=250, + description='Description about the project', + example=faker.paragraph(), + ), + 'customer_id': UUID( + title='Identifier of the Customer', + required=False, + description='Customer this project type belongs to. ' + 'If not specified, it will be considered an internal project of the tenant.', + example=faker.uuid4(), + ), + 'project_type_id': UUID( + title='Identifier of the project type', + required=False, + description='Id of the project type it belongs. This allows grouping the projects.', + example=faker.uuid4(), + ), + 'technologies': fields.List( + fields.String, + title='List of technologies this project involves', + required=False, + description='List of technologies this project involves', + example="['python', 'restplus', 'openapi']", + ), + 'status': fields.String( + required=False, + title='Status', + description='Status active or inactive projects', + example=Faker().words( + 2, + [ + 'active', + 'inactive', + ], + unique=True, + ), + ), + }, +) project_response_fields = { 'customer_name': fields.String( @@ -59,18 +81,13 @@ } project_response_fields.update(common_fields) -project = ns.inherit( - 'Project', - project_input, - project_response_fields -) +project = ns.inherit('Project', project_input, project_response_fields) project_dao = create_dao() -attributes_filter = create_attributes_filter(ns, project, [ - "customer_id", - "project_type_id", -]) +attributes_filter = create_attributes_filter( + ns, project, ["customer_id", "project_type_id", "status"] +) @ns.route('') @@ -85,8 +102,10 @@ def get(self): @ns.doc('create_project') @ns.response(HTTPStatus.CONFLICT, 'This project already exists') - @ns.response(HTTPStatus.BAD_REQUEST, 'Invalid format or structure ' - 'of the attributes of the project') + @ns.response( + HTTPStatus.BAD_REQUEST, + 'Invalid format or structure ' 'of the attributes of the project', + ) @ns.expect(project_input) @ns.marshal_with(project, code=HTTPStatus.CREATED) def post(self): @@ -100,16 +119,22 @@ def post(self): @ns.param('id', 'The project identifier') class Project(Resource): @ns.doc('get_project') - @ns.response(HTTPStatus.UNPROCESSABLE_ENTITY, 'The id has an invalid format') + @ns.response( + HTTPStatus.UNPROCESSABLE_ENTITY, 'The id has an invalid format' + ) @ns.marshal_with(project) def get(self, id): """Get a project""" return project_dao.get(id) @ns.doc('update_project') - @ns.response(HTTPStatus.BAD_REQUEST, 'Invalid format or structure ' - 'of the attributes of the project') - @ns.response(HTTPStatus.CONFLICT, 'A project already exists with this new data') + @ns.response( + HTTPStatus.BAD_REQUEST, + 'Invalid format or structure ' 'of the attributes of the project', + ) + @ns.response( + HTTPStatus.CONFLICT, 'A project already exists with this new data' + ) @ns.expect(remove_required_constraint(project_input)) @ns.marshal_with(project) def put(self, id): @@ -120,5 +145,5 @@ def put(self, id): @ns.response(HTTPStatus.NO_CONTENT, 'Project successfully deleted') def delete(self, id): """Delete a project""" - project_dao.delete(id) + project_dao.update(id, {'status': 'inactive'}) return None, HTTPStatus.NO_CONTENT