From fc59210ef7ea9fc3408fa8611f14998b6790ca7b Mon Sep 17 00:00:00 2001 From: EliuX Date: Tue, 14 Apr 2020 21:23:11 -0500 Subject: [PATCH] Close #58 Create time entry model for Cosmos DB --- README.md | 37 +--- commons/data_access_layer/cosmos_db.py | 82 ++++++--- requirements/azure_cosmos.txt | 5 +- tests/conftest.py | 16 +- tests/time_tracker_api/smoke_test.py | 6 +- .../time_entries/time_entries_model_test.py | 79 +++++++++ .../time_entries_namespace_test.py | 115 ++++++++---- time_tracker_api/api.py | 15 +- .../time_entries/time_entries_model.py | 165 +++++++++++++----- .../time_entries/time_entries_namespace.py | 77 +++++--- 10 files changed, 434 insertions(+), 163 deletions(-) create mode 100644 tests/time_tracker_api/time_entries/time_entries_model_test.py diff --git a/README.md b/README.md index 545c8473..a4fdaff7 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This is the mono-repository for the backend services and their common codebase ## Getting started Follow the following instructions to get the project ready to use ASAP. + ### Requirements Be sure you have installed in your system @@ -46,35 +47,6 @@ automatically [pip](https://pip.pypa.io/en/stable/) as well. Remember to do it with Python 3. - -- Install the [Microsoft ODBC Driver for SQL Server](https://docs.microsoft.com/en-us/sql/connect/odbc/microsoft-odbc-driver-for-sql-server?view=sql-server-ver15) -in your operative system. Then you have to check out what is the name of the SQL Driver installation. -Check it out with: - -```bash -vim /usr/local/etc/odbcinst.ini -``` - -It may display something like - -```.ini -[ODBC Driver 17 for SQL Server] -Description=Microsoft ODBC Driver 17 for SQL Server -Driver=/usr/local/lib/libmsodbcsql.17.dylib -UsageCount=2 -``` - -Then specify the driver name, in this case _DBC Driver 17 for SQL Server_ in the `SQL_DATABASE_URI`, e.g.: - -```.dotenv -SQL_DATABASE_URI=mssql+pyodbc://:@time-tracker-srv.database.windows.net/?driver\=ODBC Driver 17 for SQL Server -``` - -To troubleshoot issues regarding this part please check out: -- [Install the Microsoft ODBC driver for SQL Server (macOS)](https://docs.microsoft.com/en-us/sql/connect/odbc/linux-mac/install-microsoft-odbc-driver-sql-server-macos?view=sql-server-ver15). -- Github issue [odbcinst: SQLRemoveDriver failed with Unable to find component name](https://github.com/Microsoft/homebrew-mssql-preview/issues/2). -- Stack overflow solution to [Can't open lib 'ODBC Driver 13 for SQL Server'? Sym linking issue?](https://stackoverflow.com/questions/44527452/cant-open-lib-odbc-driver-13-for-sql-server-sym-linking-issue). - ### How to use it - Set the env var `FLASK_APP` to `time_tracker_api` and start the app: @@ -93,6 +65,13 @@ To troubleshoot issues regarding this part please check out: a link to the swagger.json with the definition of the api. +### Important notes +Due to the used technology and particularities on the implementation of this API, it is important that you respect the +following notes regarding to the manipulation of the data from and towards the API: + +- The [recommended](https://docs.microsoft.com/en-us/azure/cosmos-db/working-with-dates#storing-datetimes) format for +DateTime strings in Azure Cosmos DB is `YYYY-MM-DDThh:mm:ss.fffffffZ` which follows the ISO 8601 **UTC standard**. + ## Development ### Test diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index 73ae4731..b7a318cc 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -1,15 +1,17 @@ import dataclasses import logging import uuid +from datetime import datetime from typing import Callable import azure.cosmos.cosmos_client as cosmos_client import azure.cosmos.exceptions as exceptions from azure.cosmos import ContainerProxy, PartitionKey from flask import Flask +from werkzeug.exceptions import HTTPException from commons.data_access_layer.database import CRUDDao -from time_tracker_api.security import current_user_tenant_id +from time_tracker_api.security import current_user_tenant_id, current_user_id class CosmosDBFacade: @@ -75,12 +77,14 @@ class CosmosDBRepository: def __init__(self, container_id: str, partition_key_attribute: str, mapper: Callable = None, + order_fields: list = [], custom_cosmos_helper: CosmosDBFacade = None): global cosmos_helper self.cosmos_helper = custom_cosmos_helper or cosmos_helper if self.cosmos_helper is None: # pragma: no cover raise ValueError("The cosmos_db module has not been initialized!") self.mapper = mapper + self.order_fields = order_fields self.container: ContainerProxy = self.cosmos_helper.db.get_container_client(container_id) self.partition_key_attribute: str = partition_key_attribute @@ -93,7 +97,23 @@ def from_definition(cls, container_definition: dict, mapper=mapper, custom_cosmos_helper=custom_cosmos_helper) + @staticmethod + def create_sql_condition_for_visibility(visible_only: bool, container_name='c') -> str: + if visible_only: + # We are considering that `deleted == null` is not a choice + return 'AND NOT IS_DEFINED(%s.deleted)' % container_name + return '' + + @staticmethod + def check_visibility(item, throw_not_found_if_deleted): + if throw_not_found_if_deleted and item.get('deleted') is not None: + raise exceptions.CosmosResourceNotFoundError(message='Deleted item', + status_code=404) + + return item + def create(self, data: dict, mapper: Callable = None): + self.on_create(data) function_mapper = self.get_mapper_or_dict(mapper) return function_mapper(self.container.create_item(body=data)) @@ -108,10 +128,12 @@ def find_all(self, partition_key_value: str, max_count=None, offset=0, max_count = self.get_page_size_or(max_count) result = self.container.query_items( query=""" - SELECT * FROM c WHERE c.{partition_key_attribute}=@partition_key_value AND {visibility_condition} + SELECT * FROM c WHERE c.{partition_key_attribute}=@partition_key_value + {visibility_condition} {order_clause} OFFSET @offset LIMIT @max_count """.format(partition_key_attribute=self.partition_key_attribute, - visibility_condition=self.create_sql_condition_for_visibility(visible_only)), + visibility_condition=self.create_sql_condition_for_visibility(visible_only), + order_clause=self.create_sql_order_clause()), parameters=[ {"name": "@partition_key_value", "value": partition_key_value}, {"name": "@offset", "value": offset}, @@ -130,6 +152,7 @@ def partial_update(self, id: str, changes: dict, partition_key_value: str, return self.update(id, item_data, mapper=mapper) def update(self, id: str, item_data: dict, mapper: Callable = None): + self.on_update(item_data) function_mapper = self.get_mapper_or_dict(mapper) return function_mapper(self.container.replace_item(id, body=item_data)) @@ -141,19 +164,6 @@ def delete(self, id: str, partition_key_value: str, mapper: Callable = None): def delete_permanently(self, id: str, partition_key_value: str) -> None: self.container.delete_item(id, partition_key_value) - def check_visibility(self, item, throw_not_found_if_deleted): - if throw_not_found_if_deleted and item.get('deleted') is not None: - raise exceptions.CosmosResourceNotFoundError(message='Deleted item', - status_code=404) - - return item - - def create_sql_condition_for_visibility(self, visible_only: bool, container_name='c') -> str: - if visible_only: - # We are considering that `deleted == null` is not a choice - return 'NOT IS_DEFINED(%s.deleted)' % container_name - return 'true' - def get_mapper_or_dict(self, alternative_mapper: Callable) -> Callable: return alternative_mapper or self.mapper or dict @@ -162,11 +172,28 @@ def get_page_size_or(self, custom_page_size: int) -> int: # or any other repository for the settings return custom_page_size or 100 + def on_create(self, new_item_data: dict): + if new_item_data.get('id') is None: + new_item_data['id'] = str(uuid.uuid4()) + + def on_update(self, update_item_data: dict): + pass + + def create_sql_order_clause(self): + if len(self.order_fields) > 0: + return "ORDER BY c.{}".format(", c.".join(self.order_fields)) + else: + return "" + class CosmosDBDao(CRUDDao): def __init__(self, repository: CosmosDBRepository): self.repository = repository + @property + def partition_key_value(self): + return current_user_tenant_id() + def get_all(self) -> list: tenant_id: str = self.partition_key_value return self.repository.find_all(partition_key_value=tenant_id) @@ -176,8 +203,8 @@ def get(self, id): return self.repository.find(id, partition_key_value=tenant_id) def create(self, data: dict): - data['id'] = str(uuid.uuid4()) - data['tenant_id'] = self.partition_key_value + data[self.repository.partition_key_attribute] = self.partition_key_value + data['owner_id'] = current_user_id() return self.repository.create(data) def update(self, id, data: dict): @@ -189,9 +216,22 @@ def delete(self, id): tenant_id: str = current_user_tenant_id() self.repository.delete(id, partition_key_value=tenant_id) - @property - def partition_key_value(self): - return current_user_tenant_id() + +class CustomError(HTTPException): + def __init__(self, status_code: int, description: str = None): + self.code = status_code + self.description = description + + +def current_datetime(): + return datetime.utcnow() + + +def datetime_str(value: datetime): + if value is not None: + return value.isoformat() + else: + return None def init_app(app: Flask) -> None: diff --git a/requirements/azure_cosmos.txt b/requirements/azure_cosmos.txt index 53ab3e98..ed253c84 100644 --- a/requirements/azure_cosmos.txt +++ b/requirements/azure_cosmos.txt @@ -11,4 +11,7 @@ idna==2.8 six==1.13.0 urllib3==1.25.7 virtualenv==16.7.9 -virtualenv-clone==0.5.3 \ No newline at end of file +virtualenv-clone==0.5.3 + +# Dataclasses +dataclasses==0.6 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index ed9034e1..4903547f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,8 @@ from commons.data_access_layer.cosmos_db import CosmosDBRepository from time_tracker_api import create_app +from time_tracker_api.security import current_user_tenant_id, current_user_id +from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository fake = Faker() Faker.seed() @@ -92,11 +94,16 @@ def cosmos_db_repository(app: Flask, cosmos_db_model) -> CosmosDBRepository: @pytest.fixture(scope="session") def tenant_id() -> str: - return fake.uuid4() + return current_user_tenant_id() + + +@pytest.fixture(scope="session") +def another_tenant_id(tenant_id) -> str: + return str(tenant_id) + "2" @pytest.fixture(scope="session") -def another_tenant_id() -> str: +def owner_id() -> str: return fake.uuid4() @@ -109,3 +116,8 @@ def sample_item(cosmos_db_repository: CosmosDBRepository, tenant_id: str) -> dic tenant_id=tenant_id) return cosmos_db_repository.create(sample_item_data) + + +@pytest.yield_fixture(scope="module") +def time_entry_repository(cosmos_db_repository: CosmosDBRepository) -> TimeEntryCosmosDBRepository: + return TimeEntryCosmosDBRepository() diff --git a/tests/time_tracker_api/smoke_test.py b/tests/time_tracker_api/smoke_test.py index aa1a75d1..0d425473 100644 --- a/tests/time_tracker_api/smoke_test.py +++ b/tests/time_tracker_api/smoke_test.py @@ -4,7 +4,11 @@ from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture -unexpected_errors_to_be_handled = [CosmosHttpResponseError, CosmosResourceNotFoundError, CosmosResourceExistsError] +from commons.data_access_layer.cosmos_db import CustomError + +unexpected_errors_to_be_handled = [CustomError(HTTPStatus.BAD_REQUEST, "Anything"), + CosmosHttpResponseError, CosmosResourceNotFoundError, + CosmosResourceExistsError, AttributeError] def test_app_exists(app): diff --git a/tests/time_tracker_api/time_entries/time_entries_model_test.py b/tests/time_tracker_api/time_entries/time_entries_model_test.py new file mode 100644 index 00000000..d28faa95 --- /dev/null +++ b/tests/time_tracker_api/time_entries/time_entries_model_test.py @@ -0,0 +1,79 @@ +from datetime import datetime, timedelta + +import pytest +from faker import Faker + +from commons.data_access_layer.cosmos_db import current_datetime, datetime_str +from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository, TimeEntryCosmosDBModel + +fake = Faker() + +now = current_datetime() +yesterday = current_datetime() - timedelta(days=1) +two_days_ago = current_datetime() - timedelta(days=2) + + +def create_time_entry(start_date: datetime, + end_date: datetime, + owner_id: str, + tenant_id: str, + time_entry_repository: TimeEntryCosmosDBRepository) -> TimeEntryCosmosDBModel: + data = { + "project_id": fake.uuid4(), + "activity_id": fake.uuid4(), + "description": fake.paragraph(nb_sentences=2), + "start_date": datetime_str(start_date), + "end_date": datetime_str(end_date), + "owner_id": owner_id, + "tenant_id": tenant_id + } + + created_item = time_entry_repository.create(data, mapper=TimeEntryCosmosDBModel) + return created_item + + +@pytest.mark.parametrize( + 'start_date,end_date', [(two_days_ago, yesterday), (now, None)] +) +def test_find_interception_with_date_range_should_find(start_date: datetime, + end_date: datetime, + owner_id: str, + tenant_id: str, + time_entry_repository: TimeEntryCosmosDBRepository): + existing_item = create_time_entry(start_date, end_date, owner_id, tenant_id, time_entry_repository) + + result = time_entry_repository.find_interception_with_date_range(datetime_str(yesterday), datetime_str(now), + owner_id=owner_id, + partition_key_value=tenant_id) + + time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id) + + assert result is not None + assert len(result) >= 0 + assert any([existing_item.id == item.id for item in result]) + + +def test_find_interception_should_ignore_id_of_existing_item(owner_id: str, + tenant_id: str, + time_entry_repository: TimeEntryCosmosDBRepository): + start_date = datetime_str(yesterday) + end_date = datetime_str(now) + existing_item = create_time_entry(yesterday, now, owner_id, tenant_id, time_entry_repository) + + colliding_result = time_entry_repository.find_interception_with_date_range(start_date, end_date, + owner_id=owner_id, + partition_key_value=tenant_id) + + non_colliding_result = time_entry_repository.find_interception_with_date_range(start_date, end_date, + owner_id=owner_id, + partition_key_value=tenant_id, + ignore_id=existing_item.id) + + time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id) + + colliding_result is not None + assert any([existing_item.id == item.id for item in colliding_result]) + + non_colliding_result is not None + assert not any([existing_item.id == item.id for item in non_colliding_result]) + diff --git a/tests/time_tracker_api/time_entries/time_entries_namespace_test.py b/tests/time_tracker_api/time_entries/time_entries_namespace_test.py index e45e4510..0d209988 100644 --- a/tests/time_tracker_api/time_entries/time_entries_namespace_test.py +++ b/tests/time_tracker_api/time_entries/time_entries_namespace_test.py @@ -1,26 +1,66 @@ +from datetime import timedelta +from unittest.mock import ANY + from faker import Faker from flask import json from flask.testing import FlaskClient from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture +from commons.data_access_layer.cosmos_db import current_datetime +from time_tracker_api.security import current_user_tenant_id + fake = Faker() +yesterday = current_datetime() - timedelta(days=1) valid_time_entry_input = { "project_id": fake.uuid4(), "activity_id": fake.uuid4(), "description": fake.paragraph(nb_sentences=2), - "start_date": fake.iso8601(end_datetime=None), - "end_date": fake.iso8601(end_datetime=None), + "start_date": str(yesterday.isoformat()), "owner_id": fake.uuid4(), "tenant_id": fake.uuid4() } + fake_time_entry = ({ "id": fake.random_int(1, 9999), "running": True, }).update(valid_time_entry_input) +def test_create_time_entry_with_invalid_date_range_should_raise_bad_request_error(client: FlaskClient, + mocker: MockFixture): + from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao + repository_container_create_item_mock = mocker.patch.object(time_entries_dao.repository.container, + 'create_item', + return_value=fake_time_entry) + + invalid_time_entry_input = valid_time_entry_input.copy() + invalid_time_entry_input.update({ + "end_date": str(yesterday.isoformat()) + }) + response = client.post("/time-entries", json=invalid_time_entry_input, follow_redirects=True) + + assert HTTPStatus.BAD_REQUEST == response.status_code + repository_container_create_item_mock.assert_not_called() + + +def test_create_time_entry_with_end_date_in_future_should_raise_bad_request_error(client: FlaskClient, + mocker: MockFixture): + from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao + repository_container_create_item_mock = mocker.patch.object(time_entries_dao.repository.container, + 'create_item', + return_value=fake_time_entry) + invalid_time_entry_input = valid_time_entry_input.copy() + invalid_time_entry_input.update({ + "end_date": str(fake.future_datetime().isoformat()) + }) + response = client.post("/time-entries", json=invalid_time_entry_input, follow_redirects=True) + + assert HTTPStatus.BAD_REQUEST == response.status_code + repository_container_create_item_mock.assert_not_called() + + def test_create_time_entry_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_create_mock = mocker.patch.object(time_entries_dao.repository, @@ -30,7 +70,7 @@ def test_create_time_entry_should_succeed_with_valid_request(client: FlaskClient response = client.post("/time-entries", json=valid_time_entry_input, follow_redirects=True) assert HTTPStatus.CREATED == response.status_code - repository_create_mock.assert_called_once_with(valid_time_entry_input) + repository_create_mock.assert_called_once() def test_create_time_entry_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): @@ -64,16 +104,16 @@ def test_list_all_time_entries(client: FlaskClient, mocker: MockFixture): def test_get_time_entry_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao - valid_id = fake.random_int(1, 9999) repository_find_mock = mocker.patch.object(time_entries_dao.repository, 'find', return_value=fake_time_entry) + valid_id = fake.random_int(1, 9999) response = client.get("/time-entries/%s" % valid_id, follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_time_entry == json.loads(response.data) - repository_find_mock.assert_called_once_with(str(valid_id)) + repository_find_mock.assert_called_once_with(str(valid_id), partition_key_value=current_user_tenant_id()) def test_get_time_entry_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient, @@ -90,13 +130,13 @@ def test_get_time_entry_should_response_with_unprocessable_entity_for_invalid_id response = client.get("/time-entries/%s" % invalid_id, follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_find_mock.assert_called_once_with(str(invalid_id)) + repository_find_mock.assert_called_once_with(str(invalid_id), partition_key_value=current_user_tenant_id()) def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, - 'update', + 'partial_update', return_value=fake_time_entry) valid_id = fake.random_int(1, 9999) @@ -106,7 +146,9 @@ def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, m assert HTTPStatus.OK == response.status_code fake_time_entry == json.loads(response.data) - repository_update_mock.assert_called_once_with(str(valid_id), valid_time_entry_input) + repository_update_mock.assert_called_once_with(str(valid_id), + changes=valid_time_entry_input, + partition_key_value=current_user_tenant_id()) def test_update_time_entry_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): @@ -132,7 +174,7 @@ def test_update_time_entry_should_return_not_found_with_invalid_id(client: Flask from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import NotFound repository_update_mock = mocker.patch.object(time_entries_dao.repository, - 'update', + 'partial_update', side_effect=NotFound) invalid_id = fake.random_int(1, 9999) @@ -141,13 +183,15 @@ def test_update_time_entry_should_return_not_found_with_invalid_id(client: Flask follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_update_mock.assert_called_once_with(str(invalid_id), valid_time_entry_input) + repository_update_mock.assert_called_once_with(str(invalid_id), + changes=valid_time_entry_input, + partition_key_value=current_user_tenant_id()) def test_delete_time_entry_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_remove_mock = mocker.patch.object(time_entries_dao.repository, - 'remove', + 'delete', return_value=None) valid_id = fake.random_int(1, 9999) @@ -155,7 +199,8 @@ def test_delete_time_entry_should_succeed_with_valid_id(client: FlaskClient, moc assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data - repository_remove_mock.assert_called_once_with(str(valid_id)) + repository_remove_mock.assert_called_once_with(str(valid_id), + partition_key_value=current_user_tenant_id()) def test_delete_time_entry_should_return_not_found_with_invalid_id(client: FlaskClient, @@ -163,14 +208,15 @@ def test_delete_time_entry_should_return_not_found_with_invalid_id(client: Flask from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import NotFound repository_remove_mock = mocker.patch.object(time_entries_dao.repository, - 'remove', + 'delete', side_effect=NotFound) invalid_id = fake.random_int(1, 9999) response = client.delete("/time-entries/%s" % invalid_id, follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_remove_mock.assert_called_once_with(str(invalid_id)) + repository_remove_mock.assert_called_once_with(str(invalid_id), + partition_key_value=current_user_tenant_id()) def test_delete_time_entry_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient, @@ -178,73 +224,74 @@ def test_delete_time_entry_should_return_unprocessable_entity_for_invalid_id_for from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import UnprocessableEntity repository_remove_mock = mocker.patch.object(time_entries_dao.repository, - 'remove', + 'delete', side_effect=UnprocessableEntity) invalid_id = fake.word() response = client.delete("/time-entries/%s" % invalid_id, follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_remove_mock.assert_called_once_with(str(invalid_id)) + repository_remove_mock.assert_called_once_with(str(invalid_id), + partition_key_value=current_user_tenant_id()) def test_stop_time_entry_with_valid_id(client: FlaskClient, mocker: MockFixture): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, - 'update', + 'partial_update', return_value=fake_time_entry) valid_id = fake.random_int(1, 9999) response = client.post("/time-entries/%s/stop" % valid_id, follow_redirects=True) assert HTTPStatus.OK == response.status_code - repository_update_mock.assert_called_once_with(str(valid_id), { - "end_date": mocker.ANY - }) + repository_update_mock.assert_called_once_with(str(valid_id), + changes={"end_date": mocker.ANY}, + partition_key_value=current_user_tenant_id()) -def test_stop_time_entry_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_stop_time_entry_with_id_with_invalid_format(client: FlaskClient, mocker: MockFixture): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import UnprocessableEntity repository_update_mock = mocker.patch.object(time_entries_dao.repository, - 'update', + 'partial_update', side_effect=UnprocessableEntity) invalid_id = fake.word() response = client.post("/time-entries/%s/stop" % invalid_id, follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_update_mock.assert_called_once_with(invalid_id, { - "end_date": mocker.ANY - }) + repository_update_mock.assert_called_once_with(invalid_id, + changes={"end_date": ANY}, + partition_key_value=current_user_tenant_id()) def test_restart_time_entry_with_valid_id(client: FlaskClient, mocker: MockFixture): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, - 'update', + 'partial_update', return_value=fake_time_entry) valid_id = fake.random_int(1, 9999) response = client.post("/time-entries/%s/restart" % valid_id, follow_redirects=True) assert HTTPStatus.OK == response.status_code - repository_update_mock.assert_called_once_with(str(valid_id), { - "end_date": None - }) + repository_update_mock.assert_called_once_with(str(valid_id), + changes={"end_date": None}, + partition_key_value=current_user_tenant_id()) -def test_restart_time_entry_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, mocker: MockFixture): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import UnprocessableEntity repository_update_mock = mocker.patch.object(time_entries_dao.repository, - 'update', + 'partial_update', side_effect=UnprocessableEntity) invalid_id = fake.word() response = client.post("/time-entries/%s/restart" % invalid_id, follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_update_mock.assert_called_once_with(invalid_id, { - "end_date": None - }) + repository_update_mock.assert_called_once_with(invalid_id, + changes={"end_date": None}, + partition_key_value=current_user_tenant_id()) diff --git a/time_tracker_api/api.py b/time_tracker_api/api.py index 339d54c1..b8b026ce 100644 --- a/time_tracker_api/api.py +++ b/time_tracker_api/api.py @@ -4,6 +4,7 @@ from flask_restplus import Api, fields from flask_restplus._http import HTTPStatus +from commons.data_access_layer.cosmos_db import CustomError from time_tracker_api import __version__ faker = Faker() @@ -52,12 +53,12 @@ @api.errorhandler(CosmosResourceExistsError) def handle_cosmos_resource_exists_error(error): - return {'message': 'This item already exists'}, HTTPStatus.CONFLICT + return {'message': 'It already exists'}, HTTPStatus.CONFLICT @api.errorhandler(CosmosResourceNotFoundError) def handle_cosmos_resource_not_found_error(error): - return {'message': 'This item was not found'}, HTTPStatus.NOT_FOUND + return {'message': 'It was not found'}, HTTPStatus.NOT_FOUND @api.errorhandler(CosmosHttpResponseError) @@ -65,6 +66,16 @@ def handle_cosmos_http_response_error(error): return {'message': 'Invalid request. Please verify your data.'}, HTTPStatus.BAD_REQUEST +@api.errorhandler(AttributeError) +def handle_attribute_error(error): + return {'message': "There are missing attributes"}, HTTPStatus.UNPROCESSABLE_ENTITY + + +@api.errorhandler(CustomError) +def handle_custom_error(error): + return {'message': error.description}, error.code + + @api.errorhandler def default_error_handler(error): app.logger.error(error) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 9cdb9024..b385ebe9 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -1,53 +1,20 @@ +import abc +from dataclasses import dataclass, field +from typing import List, Callable + from azure.cosmos import PartitionKey -from sqlalchemy_utils import ScalarListType +from flask_restplus._http import HTTPStatus +from commons.data_access_layer.cosmos_db import CosmosDBDao, CosmosDBRepository, CustomError, CosmosDBModel, \ + current_datetime, datetime_str from commons.data_access_layer.database import CRUDDao +from time_tracker_api.security import current_user_tenant_id class TimeEntriesDao(CRUDDao): - pass - - -def create_dao() -> TimeEntriesDao: - from commons.data_access_layer.sql import db - from commons.data_access_layer.database import COMMENTS_MAX_LENGTH - from sqlalchemy_utils import UUIDType - import uuid - from commons.data_access_layer.sql import SQLCRUDDao - - class TimeEntrySQLModel(db.Model): - __tablename__ = 'time_entry' - id = db.Column(UUIDType(binary=False), primary_key=True, default=uuid.uuid4) - description = db.Column(db.String(COMMENTS_MAX_LENGTH)) - start_date = db.Column(db.DateTime, server_default=db.func.now()) - end_date = db.Column(db.DateTime) - project_id = db.Column(UUIDType(binary=False), - db.ForeignKey('project.id'), - nullable=False) - activity_id = db.Column(UUIDType(binary=False), - db.ForeignKey('activity.id'), - nullable=False) - technologies = db.Column(ScalarListType()) - uri = db.Column(db.String(500)) - owner_id = db.Column(UUIDType(binary=False), default=uuid.uuid4) - deleted = db.Column(UUIDType(binary=False), default=uuid.uuid4) - tenant_id = db.Column(UUIDType(binary=False), default=uuid.uuid4) - - @property - def running(self): - return self.end_date is None - - def __repr__(self): - return '