diff --git a/README.md b/README.md index 54011751..a4fdaff7 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,15 @@ # time-tracker-api +[![Build status](https://dev.azure.com/IOET-DevOps/TimeTracker-API/_apis/build/status/TimeTracker-API%20-%20CI)](https://dev.azure.com/IOET-DevOps/TimeTracker-API/_build/latest?definitionId=1) + 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 @@ -42,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: @@ -89,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/cli.py b/cli.py index 6cf2a08b..2cfa0600 100644 --- a/cli.py +++ b/cli.py @@ -46,23 +46,6 @@ def gen_postman_collection(filename='timetracker-api-postman-collection.json', save_data(parsed_json, filename) -@cli_manager.command -def seed(): - from time_tracker_api.database import seeder as seed - seed() - - -@cli_manager.command -def re_create_db(): - print('This is going to drop all tables and seed again the database') - confirm_answer = input('Do you confirm (Y) you want to remove all your data?\n') - if confirm_answer.upper() == 'Y': - from time_tracker_api.database import seeder - seeder.fresh() - else: - print('\nThis action was cancelled!') - - def save_data(data: str, filename: str) -> None: """ Save text content to a file """ if filename: diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index b0b494e8..5e5e3ff6 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -1,12 +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, current_user_id class CosmosDBFacade: @@ -72,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 @@ -90,7 +97,29 @@ 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 create_sql_condition_for_owner_id(owner_id: str, container_name='c') -> str: + if owner_id: + return 'AND %s.owner_id=@owner_id' % 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)) @@ -99,20 +128,24 @@ def find(self, id: str, partition_key_value, visible_only=True, mapper: Callable function_mapper = self.get_mapper_or_dict(mapper) return function_mapper(self.check_visibility(found_item, visible_only)) - def find_all(self, partition_key_value: str, max_count=None, offset=0, + def find_all(self, partition_key_value: str, owner_id=None, max_count=None, offset=0, visible_only=True, mapper: Callable = None): # TODO Use the tenant_id param and change container alias 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 + {owner_condition} {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), + owner_condition=self.create_sql_condition_for_owner_id(owner_id), + order_clause=self.create_sql_order_clause()), parameters=[ {"name": "@partition_key_value", "value": partition_key_value}, {"name": "@offset", "value": offset}, {"name": "@max_count", "value": max_count}, + {"name": "@owner_id", "value": owner_id}, ], partition_key=partition_key_value, max_item_count=max_count) @@ -122,11 +155,12 @@ def find_all(self, partition_key_value: str, max_count=None, offset=0, def partial_update(self, id: str, changes: dict, partition_key_value: str, visible_only=True, mapper: Callable = None): - item_data = self.find(id, partition_key_value, visible_only=visible_only) + item_data = self.find(id, partition_key_value, visible_only=visible_only, mapper=dict) item_data.update(changes) 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)) @@ -138,19 +172,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 @@ -159,6 +180,68 @@ 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 + owner_id = current_user_id() + return self.repository.find_all(partition_key_value=tenant_id, owner_id=owner_id) + + def get(self, id): + tenant_id: str = self.partition_key_value + return self.repository.find(id, partition_key_value=tenant_id) + + def create(self, data: dict): + 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): + return self.repository.partial_update(id, + changes=data, + partition_key_value=self.partition_key_value) + + def delete(self, id): + tenant_id: str = current_user_tenant_id() + self.repository.delete(id, partition_key_value=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: global cosmos_helper diff --git a/time_tracker_api/database.py b/commons/data_access_layer/database.py similarity index 50% rename from time_tracker_api/database.py rename to commons/data_access_layer/database.py index c9a21b54..077780cf 100644 --- a/time_tracker_api/database.py +++ b/commons/data_access_layer/database.py @@ -35,45 +35,16 @@ def delete(self, id): raise NotImplementedError # pragma: no cover -class Seeder(abc.ABC): - @abc.abstractmethod - def run(self): - raise NotImplementedError # pragma: no cover - - @abc.abstractmethod - def fresh(self): - raise NotImplementedError # pragma: no cover - - def __call__(self, *args, **kwargs): - self.run() # pragma: no cover - - -seeder: Seeder = None - - def init_app(app: Flask) -> None: - init_sql(app) + init_sql(app) # TODO Delete after the migration to Cosmos DB has finished. + init_cosmos_db(app) def init_sql(app: Flask) -> None: - from commons.data_access_layer.sql import init_app, SQLSeeder + from commons.data_access_layer.sql import init_app init_app(app) - global seeder - seeder = SQLSeeder() def init_cosmos_db(app: Flask) -> None: - # from commons.data_access_layer.azure.cosmos_db import cosmos_helper - class CosmosSeeder(Seeder): - def run(self): - print("Provisioning namespace(database)...") - # cosmos_helper.create_container() - print("Database seeded!") - - def fresh(self): - print("Removing namespace(database)...") - # cosmos_helper.remove_container() - self.run() - - global seeder - seeder = CosmosSeeder() + from commons.data_access_layer.cosmos_db import init_app + init_app(app) diff --git a/commons/data_access_layer/sql.py b/commons/data_access_layer/sql.py index e9a02491..c1afb0e2 100644 --- a/commons/data_access_layer/sql.py +++ b/commons/data_access_layer/sql.py @@ -3,7 +3,7 @@ from flask import Flask from flask_sqlalchemy import SQLAlchemy -from time_tracker_api.database import CRUDDao, Seeder, ID_MAX_LENGTH +from commons.data_access_layer.database import CRUDDao, ID_MAX_LENGTH from time_tracker_api.security import current_user_id db: SQLAlchemy = None @@ -14,7 +14,7 @@ def handle_commit_issues(f): def rollback_if_necessary(*args, **kw): try: return f(*args, **kw) - except: # pragma: no cover + except: # pragma: no cover db.session.rollback() raise @@ -90,16 +90,3 @@ def update(self, id, data: dict): def delete(self, id): self.repository.remove(id) - - -class SQLSeeder(Seeder): # pragma: no cover - def run(self): - print("Provisioning database...") - db.create_all() - print("Database seeded!") - - def fresh(self): - print("Removing all existing data...") - db.drop_all() - - self.run() diff --git a/migrations/__init__.py b/migrations/__init__.py index 65cf435b..ece224aa 100644 --- a/migrations/__init__.py +++ b/migrations/__init__.py @@ -74,4 +74,4 @@ def remove_migration(self, name): self.repository.delete_permanently(name, self.app_id) -configure(storage=CosmosDBStorage("migrations", "time-tracker-api")) +configure(storage=CosmosDBStorage("migration", "time-tracker-api")) 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/requirements/migrations.txt b/requirements/migrations.txt index 6b95ae8e..5cce99ba 100644 --- a/requirements/migrations.txt +++ b/requirements/migrations.txt @@ -3,4 +3,4 @@ # For running any kind of data migration # Migration tool -migrate-anything==0.1.6 \ No newline at end of file +migrate-anything==0.1.6 diff --git a/requirements/time_tracker_api/dev.txt b/requirements/time_tracker_api/dev.txt index a9cc28ca..c242e4a1 100644 --- a/requirements/time_tracker_api/dev.txt +++ b/requirements/time_tracker_api/dev.txt @@ -12,4 +12,4 @@ pytest==5.2.0 pytest-mock==2.0.0 # Coverage -coverage==4.5.1 \ No newline at end of file +coverage==4.5.1 diff --git a/setup.cfg b/setup.cfg index a6500ee6..a4097447 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,3 +12,9 @@ branch = True source = time_tracker_api commons + +[report] +exclude_lines = + pragma: no cover + @dataclass() + raise EnvironmentError diff --git a/tests/conftest.py b/tests/conftest.py index ed9034e1..7dfd83ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ from commons.data_access_layer.cosmos_db import CosmosDBRepository from time_tracker_api import create_app +from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository fake = Faker() Faker.seed() @@ -96,7 +97,12 @@ def tenant_id() -> str: @pytest.fixture(scope="session") -def another_tenant_id() -> str: +def another_tenant_id(tenant_id) -> str: + return tenant_id[:-5] + 'fffff' + + +@pytest.fixture(scope="session") +def owner_id() -> str: return fake.uuid4() @@ -109,3 +115,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/activities/activities_namespace_test.py b/tests/time_tracker_api/activities/activities_namespace_test.py index 735a884b..1b69fd74 100644 --- a/tests/time_tracker_api/activities/activities_namespace_test.py +++ b/tests/time_tracker_api/activities/activities_namespace_test.py @@ -1,8 +1,10 @@ from faker import Faker from flask import json from flask.testing import FlaskClient -from pytest_mock import MockFixture from flask_restplus._http import HTTPStatus +from pytest_mock import MockFixture + +from time_tracker_api.security import current_user_tenant_id fake = Faker() @@ -26,7 +28,7 @@ def test_create_activity_should_succeed_with_valid_request(client: FlaskClient, response = client.post("/activities", json=valid_activity_data, follow_redirects=True) assert HTTPStatus.CREATED == response.status_code - repository_create_mock.assert_called_once_with(valid_activity_data) + repository_create_mock.assert_called_once() def test_create_activity_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): @@ -54,7 +56,7 @@ def test_list_all_activities(client: FlaskClient, mocker: MockFixture): assert [] == json_data repository_find_all_mock.assert_called_once() -#HEY + def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): from time_tracker_api.activities.activities_namespace import activity_dao @@ -68,7 +70,8 @@ def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: assert HTTPStatus.OK == response.status_code fake_activity == 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_activity_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): @@ -84,7 +87,8 @@ def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClien response = client.get("/activities/%s" % invalid_id, follow_redirects=True) assert HTTPStatus.NOT_FOUND == 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_get_activity_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture): @@ -100,14 +104,15 @@ def test_get_activity_should_return_422_for_invalid_id_format(client: FlaskClien response = client.get("/activities/%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_activity_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): from time_tracker_api.activities.activities_namespace import activity_dao repository_update_mock = mocker.patch.object(activity_dao.repository, - 'update', + 'partial_update', return_value=fake_activity) valid_id = fake.random_int(1, 9999) @@ -115,13 +120,15 @@ def test_update_activity_should_succeed_with_valid_data(client: FlaskClient, moc assert HTTPStatus.OK == response.status_code fake_activity == json.loads(response.data) - repository_update_mock.assert_called_once_with(str(valid_id), valid_activity_data) + repository_update_mock.assert_called_once_with(str(valid_id), + changes=valid_activity_data, + partition_key_value=current_user_tenant_id()) def test_update_activity_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): from time_tracker_api.activities.activities_namespace import activity_dao repository_update_mock = mocker.patch.object(activity_dao.repository, - 'update', + 'partial_update', return_value=fake_activity) valid_id = fake.random_int(1, 9999) @@ -138,7 +145,7 @@ def test_update_activity_should_return_not_found_with_invalid_id(client: FlaskCl invalid_id = fake.random_int(1, 9999) repository_update_mock = mocker.patch.object(activity_dao.repository, - 'update', + 'partial_update', side_effect=NotFound) response = client.put("/activities/%s" % invalid_id, @@ -146,7 +153,9 @@ def test_update_activity_should_return_not_found_with_invalid_id(client: FlaskCl follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_update_mock.assert_called_once_with(str(invalid_id), valid_activity_data) + repository_update_mock.assert_called_once_with(str(invalid_id), + changes=valid_activity_data, + partition_key_value=current_user_tenant_id()) def test_delete_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): @@ -155,14 +164,15 @@ def test_delete_activity_should_succeed_with_valid_id(client: FlaskClient, mocke valid_id = fake.random_int(1, 9999) repository_remove_mock = mocker.patch.object(activity_dao.repository, - 'remove', + 'delete', return_value=None) response = client.delete("/activities/%s" % valid_id, follow_redirects=True) 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_activity_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): @@ -172,13 +182,14 @@ def test_delete_activity_should_return_not_found_with_invalid_id(client: FlaskCl invalid_id = fake.random_int(1, 9999) repository_remove_mock = mocker.patch.object(activity_dao.repository, - 'remove', + 'delete', side_effect=NotFound) response = client.delete("/activities/%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_activity_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture): @@ -188,10 +199,11 @@ def test_delete_activity_should_return_422_for_invalid_id_format(client: FlaskCl invalid_id = fake.company() repository_remove_mock = mocker.patch.object(activity_dao.repository, - 'remove', + 'delete', side_effect=UnprocessableEntity) response = client.delete("/activities/%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()) diff --git a/tests/time_tracker_api/customers/__init__.py b/tests/time_tracker_api/customers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/time_tracker_api/customers/customers_namespace_test.py b/tests/time_tracker_api/customers/customers_namespace_test.py new file mode 100644 index 00000000..707df28a --- /dev/null +++ b/tests/time_tracker_api/customers/customers_namespace_test.py @@ -0,0 +1,209 @@ +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 time_tracker_api.security import current_user_tenant_id + +fake = Faker() + +valid_customer_data = { + "name": fake.company(), + "description": fake.paragraph(), + "tenant_id": fake.uuid4() +} + +fake_customer = ({ + "id": fake.random_int(1, 9999) +}).update(valid_customer_data) + + +def test_create_customer_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.customers.customers_namespace import customer_dao + repository_create_mock = mocker.patch.object(customer_dao.repository, + 'create', + return_value=fake_customer) + + response = client.post("/customers", json=valid_customer_data, follow_redirects=True) + + assert HTTPStatus.CREATED == response.status_code + repository_create_mock.assert_called_once() + + +def test_create_customer_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.customers.customers_namespace import customer_dao + repository_create_mock = mocker.patch.object(customer_dao.repository, + 'create', + return_value=fake_customer) + + response = client.post("/customers", json=None, follow_redirects=True) + + assert HTTPStatus.BAD_REQUEST == response.status_code + repository_create_mock.assert_not_called() + + +def test_list_all_customers(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.customers.customers_namespace import customer_dao + repository_find_all_mock = mocker.patch.object(customer_dao.repository, + 'find_all', + return_value=[]) + + response = client.get("/customers", follow_redirects=True) + + assert HTTPStatus.OK == response.status_code + json_data = json.loads(response.data) + assert [] == json_data + repository_find_all_mock.assert_called_once() + + +def test_get_customer_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.customers.customers_namespace import customer_dao + + valid_id = fake.random_int(1, 9999) + + repository_find_mock = mocker.patch.object(customer_dao.repository, + 'find', + return_value=fake_customer) + + response = client.get("/customers/%s" % valid_id, follow_redirects=True) + + assert HTTPStatus.OK == response.status_code + fake_customer == json.loads(response.data) + repository_find_mock.assert_called_once_with(str(valid_id), + partition_key_value=current_user_tenant_id()) + + +def test_get_customer_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.customers.customers_namespace import customer_dao + from werkzeug.exceptions import NotFound + + invalid_id = fake.random_int(1, 9999) + + repository_find_mock = mocker.patch.object(customer_dao.repository, + 'find', + side_effect=NotFound) + + response = client.get("/customers/%s" % invalid_id, follow_redirects=True) + + assert HTTPStatus.NOT_FOUND == response.status_code + repository_find_mock.assert_called_once_with(str(invalid_id), + partition_key_value=current_user_tenant_id()) + + +def test_get_customer_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.customers.customers_namespace import customer_dao + from werkzeug.exceptions import UnprocessableEntity + + invalid_id = fake.company() + + repository_find_mock = mocker.patch.object(customer_dao.repository, + 'find', + side_effect=UnprocessableEntity) + + response = client.get("/customers/%s" % invalid_id, follow_redirects=True) + + assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code + repository_find_mock.assert_called_once_with(str(invalid_id), + partition_key_value=current_user_tenant_id()) + + +def test_update_customer_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.customers.customers_namespace import customer_dao + + repository_update_mock = mocker.patch.object(customer_dao.repository, + 'partial_update', + return_value=fake_customer) + + valid_id = fake.random_int(1, 9999) + response = client.put("/customers/%s" % valid_id, json=valid_customer_data, follow_redirects=True) + + assert HTTPStatus.OK == response.status_code + fake_customer == json.loads(response.data) + repository_update_mock.assert_called_once_with(str(valid_id), + changes=valid_customer_data, + partition_key_value=current_user_tenant_id()) + + +def test_update_customer_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.customers.customers_namespace import customer_dao + repository_update_mock = mocker.patch.object(customer_dao.repository, + 'partial_update', + return_value=fake_customer) + + valid_id = fake.random_int(1, 9999) + response = client.put("/customers/%s" % valid_id, json=None, follow_redirects=True) + + assert HTTPStatus.BAD_REQUEST == response.status_code + repository_update_mock.assert_not_called() + + +def test_update_customer_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.customers.customers_namespace import customer_dao + from werkzeug.exceptions import NotFound + + invalid_id = fake.random_int(1, 9999) + + repository_update_mock = mocker.patch.object(customer_dao.repository, + 'partial_update', + side_effect=NotFound) + + response = client.put("/customers/%s" % invalid_id, + json=valid_customer_data, + follow_redirects=True) + + assert HTTPStatus.NOT_FOUND == response.status_code + repository_update_mock.assert_called_once_with(str(invalid_id), + changes=valid_customer_data, + partition_key_value=current_user_tenant_id()) + + +def test_delete_customer_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.customers.customers_namespace import customer_dao + + valid_id = fake.random_int(1, 9999) + + repository_remove_mock = mocker.patch.object(customer_dao.repository, + 'delete', + return_value=None) + + response = client.delete("/customers/%s" % valid_id, follow_redirects=True) + + assert HTTPStatus.NO_CONTENT == response.status_code + assert b'' == response.data + repository_remove_mock.assert_called_once_with(str(valid_id), + partition_key_value=current_user_tenant_id()) + + +def test_delete_customer_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.customers.customers_namespace import customer_dao + from werkzeug.exceptions import NotFound + + invalid_id = fake.random_int(1, 9999) + + repository_remove_mock = mocker.patch.object(customer_dao.repository, + 'delete', + side_effect=NotFound) + + response = client.delete("/customers/%s" % invalid_id, follow_redirects=True) + + assert HTTPStatus.NOT_FOUND == response.status_code + repository_remove_mock.assert_called_once_with(str(invalid_id), + partition_key_value=current_user_tenant_id()) + + +def test_delete_customer_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.customers.customers_namespace import customer_dao + from werkzeug.exceptions import UnprocessableEntity + + invalid_id = fake.company() + + repository_remove_mock = mocker.patch.object(customer_dao.repository, + 'delete', + side_effect=UnprocessableEntity) + + response = client.delete("/customers/%s" % invalid_id, follow_redirects=True) + + assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code + repository_remove_mock.assert_called_once_with(str(invalid_id), + partition_key_value=current_user_tenant_id()) diff --git a/tests/time_tracker_api/project_types/__init__.py b/tests/time_tracker_api/project_types/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/time_tracker_api/project_types/project_types_namespace_test.py b/tests/time_tracker_api/project_types/project_types_namespace_test.py new file mode 100644 index 00000000..613b0cdc --- /dev/null +++ b/tests/time_tracker_api/project_types/project_types_namespace_test.py @@ -0,0 +1,218 @@ +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 time_tracker_api.security import current_user_tenant_id + +fake = Faker() + +valid_project_type_data = { + "name": fake.company(), + "description": fake.paragraph(), + 'customer_id': fake.uuid4(), + 'parent_id': fake.uuid4(), +} + +fake_project_type = ({ + "id": fake.random_int(1, 9999), + "tenant_id": fake.uuid4(), +}).update(valid_project_type_data) + + +def test_create_project_type_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.project_types.project_types_namespace import project_type_dao + repository_create_mock = mocker.patch.object(project_type_dao.repository, + 'create', + return_value=fake_project_type) + + response = client.post("/project-types", json=valid_project_type_data, follow_redirects=True) + + assert HTTPStatus.CREATED == response.status_code + repository_create_mock.assert_called_once() + + +def test_create_project_type_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.project_types.project_types_namespace import project_type_dao + invalid_project_type_data = valid_project_type_data.copy() + invalid_project_type_data.update({ + "parent_id": None, + }) + repository_create_mock = mocker.patch.object(project_type_dao.repository, + 'create', + return_value=fake_project_type) + + response = client.post("/project-types", json=invalid_project_type_data, follow_redirects=True) + + assert HTTPStatus.BAD_REQUEST == response.status_code + repository_create_mock.assert_not_called() + + +def test_list_all_project_types(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.project_types.project_types_namespace import project_type_dao + repository_find_all_mock = mocker.patch.object(project_type_dao.repository, + 'find_all', + return_value=[]) + + response = client.get("/project-types", 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): + from time_tracker_api.project_types.project_types_namespace import project_type_dao + valid_id = fake.random_int(1, 9999) + repository_find_mock = mocker.patch.object(project_type_dao.repository, + 'find', + return_value=fake_project_type) + + response = client.get("/project-types/%s" % valid_id, follow_redirects=True) + + assert HTTPStatus.OK == response.status_code + fake_project_type == json.loads(response.data) + repository_find_mock.assert_called_once_with(str(valid_id), + partition_key_value=current_user_tenant_id()) + + +def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.project_types.project_types_namespace import project_type_dao + from werkzeug.exceptions import NotFound + + invalid_id = fake.random_int(1, 9999) + + repository_find_mock = mocker.patch.object(project_type_dao.repository, + 'find', + side_effect=NotFound) + + response = client.get("/project-types/%s" % invalid_id, follow_redirects=True) + + assert HTTPStatus.NOT_FOUND == response.status_code + repository_find_mock.assert_called_once_with(str(invalid_id), + partition_key_value=current_user_tenant_id()) + + +def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient, + mocker: MockFixture): + from time_tracker_api.project_types.project_types_namespace import project_type_dao + from werkzeug.exceptions import UnprocessableEntity + + invalid_id = fake.company() + + repository_find_mock = mocker.patch.object(project_type_dao.repository, + 'find', + side_effect=UnprocessableEntity) + + response = client.get("/project-types/%s" % invalid_id, follow_redirects=True) + + assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code + repository_find_mock.assert_called_once_with(str(invalid_id), + partition_key_value=current_user_tenant_id()) + + +def test_update_project_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.project_types.project_types_namespace import project_type_dao + + repository_update_mock = mocker.patch.object(project_type_dao.repository, + 'partial_update', + return_value=fake_project_type) + + valid_id = fake.random_int(1, 9999) + response = client.put("/project-types/%s" % valid_id, json=valid_project_type_data, follow_redirects=True) + + assert HTTPStatus.OK == response.status_code + fake_project_type == json.loads(response.data) + repository_update_mock.assert_called_once_with(str(valid_id), + changes=valid_project_type_data, + partition_key_value=current_user_tenant_id()) + + +def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.project_types.project_types_namespace import project_type_dao + invalid_project_type_data = valid_project_type_data.copy() + invalid_project_type_data.update({ + "parent_id": None, + }) + repository_update_mock = mocker.patch.object(project_type_dao.repository, + 'partial_update', + return_value=fake_project_type) + + valid_id = fake.random_int(1, 9999) + response = client.put("/project-types/%s" % valid_id, json=invalid_project_type_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): + from time_tracker_api.project_types.project_types_namespace import project_type_dao + from werkzeug.exceptions import NotFound + + invalid_id = fake.random_int(1, 9999) + + repository_update_mock = mocker.patch.object(project_type_dao.repository, + 'partial_update', + side_effect=NotFound) + + response = client.put("/project-types/%s" % invalid_id, + json=valid_project_type_data, + follow_redirects=True) + + assert HTTPStatus.NOT_FOUND == response.status_code + repository_update_mock.assert_called_once_with(str(invalid_id), + changes=valid_project_type_data, + partition_key_value=current_user_tenant_id()) + + +def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.project_types.project_types_namespace import project_type_dao + + valid_id = fake.random_int(1, 9999) + + repository_remove_mock = mocker.patch.object(project_type_dao.repository, + 'delete', + return_value=None) + + response = client.delete("/project-types/%s" % valid_id, follow_redirects=True) + + assert HTTPStatus.NO_CONTENT == response.status_code + assert b'' == response.data + repository_remove_mock.assert_called_once_with(str(valid_id), + partition_key_value=current_user_tenant_id()) + + +def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.project_types.project_types_namespace import project_type_dao + from werkzeug.exceptions import NotFound + + invalid_id = fake.random_int(1, 9999) + + repository_remove_mock = mocker.patch.object(project_type_dao.repository, + 'delete', + side_effect=NotFound) + + response = client.delete("/project-types/%s" % invalid_id, follow_redirects=True) + + assert HTTPStatus.NOT_FOUND == response.status_code + repository_remove_mock.assert_called_once_with(str(invalid_id), + partition_key_value=current_user_tenant_id()) + + +def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient, + mocker: MockFixture): + from time_tracker_api.project_types.project_types_namespace import project_type_dao + from werkzeug.exceptions import UnprocessableEntity + + invalid_id = fake.company() + + repository_remove_mock = mocker.patch.object(project_type_dao.repository, + 'delete', + side_effect=UnprocessableEntity) + + response = client.delete("/project-types/%s" % invalid_id, follow_redirects=True) + + assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code + repository_remove_mock.assert_called_once_with(str(invalid_id), + partition_key_value=current_user_tenant_id()) diff --git a/tests/time_tracker_api/projects/projects_namespace_test.py b/tests/time_tracker_api/projects/projects_namespace_test.py index 5d5459a3..e8707dc3 100644 --- a/tests/time_tracker_api/projects/projects_namespace_test.py +++ b/tests/time_tracker_api/projects/projects_namespace_test.py @@ -4,7 +4,7 @@ from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture -from time_tracker_api.projects.projects_model import PROJECT_TYPE +from time_tracker_api.security import current_user_tenant_id fake = Faker() @@ -12,7 +12,6 @@ "name": fake.company(), "description": fake.paragraph(), 'customer_id': fake.uuid4(), - 'tenant_id': fake.uuid4(), 'project_type_id': fake.uuid4() } @@ -30,7 +29,7 @@ def test_create_project_should_succeed_with_valid_request(client: FlaskClient, m response = client.post("/projects", json=valid_project_data, follow_redirects=True) assert HTTPStatus.CREATED == response.status_code - repository_create_mock.assert_called_once_with(valid_project_data) + repository_create_mock.assert_called_once() def test_create_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): @@ -73,7 +72,8 @@ def test_get_project_should_succeed_with_valid_id(client: FlaskClient, mocker: M assert HTTPStatus.OK == response.status_code fake_project == 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_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): @@ -89,7 +89,8 @@ def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient response = client.get("/projects/%s" % invalid_id, follow_redirects=True) assert HTTPStatus.NOT_FOUND == 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_get_project_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient, @@ -106,14 +107,15 @@ def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_fo response = client.get("/projects/%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_project_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): from time_tracker_api.projects.projects_namespace import project_dao repository_update_mock = mocker.patch.object(project_dao.repository, - 'update', + 'partial_update', return_value=fake_project) valid_id = fake.random_int(1, 9999) @@ -121,7 +123,9 @@ def test_update_project_should_succeed_with_valid_data(client: FlaskClient, mock 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) + repository_update_mock.assert_called_once_with(str(valid_id), + changes=valid_project_data, + partition_key_value=current_user_tenant_id()) def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): @@ -131,7 +135,7 @@ def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: M "project_type_id": fake.pyint(min_value=1, max_value=100), }) repository_update_mock = mocker.patch.object(project_dao.repository, - 'update', + 'partial_update', return_value=fake_project) valid_id = fake.random_int(1, 9999) @@ -148,7 +152,7 @@ def test_update_project_should_return_not_found_with_invalid_id(client: FlaskCli invalid_id = fake.random_int(1, 9999) repository_update_mock = mocker.patch.object(project_dao.repository, - 'update', + 'partial_update', side_effect=NotFound) response = client.put("/projects/%s" % invalid_id, @@ -156,7 +160,9 @@ def test_update_project_should_return_not_found_with_invalid_id(client: FlaskCli follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_update_mock.assert_called_once_with(str(invalid_id), valid_project_data) + repository_update_mock.assert_called_once_with(str(invalid_id), + changes=valid_project_data, + partition_key_value=current_user_tenant_id()) def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): @@ -165,14 +171,15 @@ def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker valid_id = fake.random_int(1, 9999) repository_remove_mock = mocker.patch.object(project_dao.repository, - 'remove', + 'delete', return_value=None) response = client.delete("/projects/%s" % valid_id, follow_redirects=True) 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_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): @@ -182,13 +189,14 @@ def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskCli invalid_id = fake.random_int(1, 9999) repository_remove_mock = mocker.patch.object(project_dao.repository, - 'remove', + 'delete', side_effect=NotFound) response = client.delete("/projects/%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_project_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient, @@ -199,10 +207,11 @@ def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format invalid_id = fake.company() repository_remove_mock = mocker.patch.object(project_dao.repository, - 'remove', + 'delete', side_effect=UnprocessableEntity) response = client.delete("/projects/%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()) diff --git a/tests/time_tracker_api/smoke_test.py b/tests/time_tracker_api/smoke_test.py index e2a6e35d..0d425473 100644 --- a/tests/time_tracker_api/smoke_test.py +++ b/tests/time_tracker_api/smoke_test.py @@ -1,11 +1,14 @@ -import pyodbc - import pytest +from azure.cosmos.exceptions import CosmosHttpResponseError, CosmosResourceExistsError, CosmosResourceNotFoundError from flask.testing import FlaskClient from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture -unexpected_errors_to_be_handled = [pyodbc.OperationalError] +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..fd094041 --- /dev/null +++ b/tests/time_tracker_api/time_entries/time_entries_model_test.py @@ -0,0 +1,80 @@ +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) + + try: + result = time_entry_repository.find_interception_with_date_range(datetime_str(yesterday), datetime_str(now), + owner_id=owner_id, + partition_key_value=tenant_id) + + assert result is not None + assert len(result) >= 0 + assert any([existing_item.id == item.id for item in result]) + finally: + time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id) + +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) + try: + + 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) + + 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]) + finally: + time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id) + 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/__init__.py b/time_tracker_api/__init__.py index b76493d4..b584fff8 100644 --- a/time_tracker_api/__init__.py +++ b/time_tracker_api/__init__.py @@ -37,7 +37,7 @@ def init_app_config(app: Flask, config_path: str, config_data: dict = None): def init_app(app: Flask): - from time_tracker_api.database import init_app as init_database + from commons.data_access_layer.database import init_app as init_database init_database(app) from time_tracker_api.api import api diff --git a/time_tracker_api/activities/activities_model.py b/time_tracker_api/activities/activities_model.py index ecb954fc..9a14c110 100644 --- a/time_tracker_api/activities/activities_model.py +++ b/time_tracker_api/activities/activities_model.py @@ -1,46 +1,50 @@ +from dataclasses import dataclass + from azure.cosmos import PartitionKey -from time_tracker_api.database import CRUDDao +from commons.data_access_layer.cosmos_db import CosmosDBModel, CosmosDBDao, CosmosDBRepository +from commons.data_access_layer.database import CRUDDao class ActivityDao(CRUDDao): pass -def create_dao() -> ActivityDao: - from sqlalchemy_utils import UUIDType - import uuid - from commons.data_access_layer.sql import db - from commons.data_access_layer.sql import SQLCRUDDao - - class ActivitySQLModel(db.Model): - __tablename__ = 'activity' - id = db.Column(UUIDType(binary=False), primary_key=True, default=uuid.uuid4) - name = db.Column(db.String(50), unique=True, nullable=False) - description = db.Column(db.String(250), unique=False, nullable=False) - deleted = db.Column(UUIDType(binary=False), default=uuid.uuid4) - tenant_id = db.Column(UUIDType(binary=False), default=uuid.uuid4) - - def __repr__(self): - return '' % self.name - - def __str___(self): - return "the activity \"%s\"" % self.name - - class ActivitySQLDao(ActivityDao, SQLCRUDDao): - def __init__(self): - SQLCRUDDao.__init__(self, ActivitySQLModel) - - return ActivitySQLDao() - - container_definition = { 'id': 'activity', 'partition_key': PartitionKey(path='/tenant_id'), 'unique_key_policy': { 'uniqueKeys': [ {'paths': ['/name']}, - {'paths': ['/deleted']}, ] } } + + +@dataclass() +class ActivityCosmosDBModel(CosmosDBModel): + id: str + name: str + description: str + deleted: str + tenant_id: str + + def __init__(self, data): + super(ActivityCosmosDBModel, self).__init__(data) # pragma: no cover + + def __repr__(self): + return '' % self.name # pragma: no cover + + def __str___(self): + return "the activity \"%s\"" % self.name # pragma: no cover + + +def create_dao() -> ActivityDao: + repository = CosmosDBRepository.from_definition(container_definition, + mapper=ActivityCosmosDBModel) + + class ActivityCosmosDBDao(CosmosDBDao, ActivityDao): + def __init__(self): + CosmosDBDao.__init__(self, repository) + + return ActivityCosmosDBDao() diff --git a/time_tracker_api/activities/activities_namespace.py b/time_tracker_api/activities/activities_namespace.py index e87f2ccd..4e6b3f6d 100644 --- a/time_tracker_api/activities/activities_namespace.py +++ b/time_tracker_api/activities/activities_namespace.py @@ -3,7 +3,7 @@ from flask_restplus._http import HTTPStatus from time_tracker_api.activities.activities_model import create_dao -from time_tracker_api.api import audit_fields +from time_tracker_api.api import common_fields faker = Faker() @@ -22,12 +22,6 @@ title='Description', description='Comments about the activity', example=faker.paragraph(), - ), - 'tenant_id': fields.String( - required=True, - title='Identifier of Tenant', - description='Tenant this activity belongs to', - example=faker.uuid4(), ) }) @@ -39,8 +33,14 @@ description='The unique identifier', example=faker.uuid4(), ), + 'tenant_id': fields.String( + required=True, + title='Identifier of Tenant', + description='Tenant this activity belongs to', + example=faker.uuid4(), + ), } -activity_response_fields.update(audit_fields) +activity_response_fields.update(common_fields) activity = ns.inherit( 'Activity', diff --git a/time_tracker_api/api.py b/time_tracker_api/api.py index 90dec35f..b8b026ce 100644 --- a/time_tracker_api/api.py +++ b/time_tracker_api/api.py @@ -1,10 +1,10 @@ -import pyodbc - -import sqlalchemy +from azure.cosmos.exceptions import CosmosResourceExistsError, CosmosResourceNotFoundError, CosmosHttpResponseError from faker import Faker from flask import current_app as app 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() @@ -16,13 +16,12 @@ ) # Common models structure -audit_fields = { +common_fields = { 'deleted': fields.String( readOnly=True, required=True, title='Last event Identifier', description='Last event over this resource', - example=faker.uuid4(), ), } @@ -52,28 +51,32 @@ """ -@api.errorhandler(sqlalchemy.exc.IntegrityError) -def handle_db_integrity_error(e): - """Handles errors related to data consistency""" - if e.code == 'gkpj': - return {'message': 'It already exists or references data that does not exist.'}, HTTPStatus.CONFLICT - else: - return {'message': 'Data integrity issues.'}, HTTPStatus.CONFLICT +@api.errorhandler(CosmosResourceExistsError) +def handle_cosmos_resource_exists_error(error): + return {'message': 'It already exists'}, HTTPStatus.CONFLICT + + +@api.errorhandler(CosmosResourceNotFoundError) +def handle_cosmos_resource_not_found_error(error): + return {'message': 'It was not found'}, HTTPStatus.NOT_FOUND + + +@api.errorhandler(CosmosHttpResponseError) +def handle_cosmos_http_response_error(error): + return {'message': 'Invalid request. Please verify your data.'}, HTTPStatus.BAD_REQUEST -@api.errorhandler(sqlalchemy.exc.DataError) -def handle_invalid_data_error(e): - """Return a 422 because the user entered data of an invalid type""" - return {'message': 'The processed data was invalid. Please correct it.'}, HTTPStatus.UNPROCESSABLE_ENTITY +@api.errorhandler(AttributeError) +def handle_attribute_error(error): + return {'message': "There are missing attributes"}, HTTPStatus.UNPROCESSABLE_ENTITY -@api.errorhandler(pyodbc.OperationalError) -def handle_connection_error(e): - """Return a 500 due to a issue in the connection to a 3rd party service""" - return {'message': 'Connection issues. Please try again in a few minutes.'}, HTTPStatus.SERVICE_UNAVAILABLE +@api.errorhandler(CustomError) +def handle_custom_error(error): + return {'message': error.description}, error.code @api.errorhandler -def generic_exception_handler(e): - app.logger.error(e) +def default_error_handler(error): + app.logger.error(error) return {'message': 'An unhandled exception occurred.'}, HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/time_tracker_api/customers/customers_model.py b/time_tracker_api/customers/customers_model.py index 2f4e568c..11e1af05 100644 --- a/time_tracker_api/customers/customers_model.py +++ b/time_tracker_api/customers/customers_model.py @@ -1,47 +1,50 @@ +from dataclasses import dataclass + from azure.cosmos import PartitionKey -from time_tracker_api.database import CRUDDao +from commons.data_access_layer.cosmos_db import CosmosDBModel, CosmosDBRepository, CosmosDBDao +from commons.data_access_layer.database import CRUDDao class CustomerDao(CRUDDao): pass -def create_dao() -> CustomerDao: - from commons.data_access_layer.sql import db - from time_tracker_api.database import COMMENTS_MAX_LENGTH - from commons.data_access_layer.sql import SQLCRUDDao - from sqlalchemy_utils import UUIDType - import uuid - - class CustomerSQLModel(db.Model): - __tablename__ = 'customer' - id = db.Column(UUIDType(binary=False), primary_key=True, default=uuid.uuid4) - name = db.Column(db.String(50), unique=True, nullable=False) - description = db.Column(db.String(COMMENTS_MAX_LENGTH), unique=False, nullable=False) - deleted = db.Column(UUIDType(binary=False), default=uuid.uuid4) - tenant_id = db.Column(UUIDType(binary=False), default=uuid.uuid4) - - def __repr__(self): - return '' % self.name - - def __str___(self): - return "the customer \"%s\"" % self.name - - class CustomerSQLDao(SQLCRUDDao): - def __init__(self): - SQLCRUDDao.__init__(self, CustomerSQLModel) - - return CustomerSQLDao() - - container_definition = { 'id': 'customer', 'partition_key': PartitionKey(path='/tenant_id'), 'unique_key_policy': { 'uniqueKeys': [ {'paths': ['/name']}, - {'paths': ['/deleted']}, ] } } + + +@dataclass() +class CustomerCosmosDBModel(CosmosDBModel): + id: str + name: str + description: str + deleted: str + tenant_id: str + + def __init__(self, data): + super(CustomerCosmosDBModel, self).__init__(data) # pragma: no cover + + def __repr__(self): + return '' % self.name # pragma: no cover + + def __str___(self): + return "the customer \"%s\"" % self.name # pragma: no cover + + +def create_dao() -> CustomerDao: + repository = CosmosDBRepository.from_definition(container_definition, + mapper=CustomerCosmosDBModel) + + class CustomerCosmosDBDao(CosmosDBDao, CustomerDao): + def __init__(self): + CosmosDBDao.__init__(self, repository) + + return CustomerCosmosDBDao() diff --git a/time_tracker_api/customers/customers_namespace.py b/time_tracker_api/customers/customers_namespace.py index 53dac000..f8064f95 100644 --- a/time_tracker_api/customers/customers_namespace.py +++ b/time_tracker_api/customers/customers_namespace.py @@ -2,7 +2,7 @@ from flask_restplus import Namespace, Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import audit_fields +from time_tracker_api.api import common_fields from time_tracker_api.customers.customers_model import create_dao faker = Faker() @@ -24,12 +24,6 @@ description='Description about the customer', example=faker.paragraph(), ), - 'tenant_id': fields.String( - required=True, - title='Identifier of Tenant', - description='Tenant this customer belongs to', - example=faker.uuid4(), - ), }) customer_response_fields = { @@ -39,9 +33,15 @@ title='Identifier', description='The unique identifier', example=faker.uuid4(), - ) + ), + 'tenant_id': fields.String( + required=True, + title='Identifier of Tenant', + description='Tenant this customer belongs to', + example=faker.uuid4(), + ), } -customer_response_fields.update(audit_fields) +customer_response_fields.update(common_fields) customer = ns.inherit( 'Customer', diff --git a/time_tracker_api/project_types/project_types_model.py b/time_tracker_api/project_types/project_types_model.py index 136be198..9ff226d8 100644 --- a/time_tracker_api/project_types/project_types_model.py +++ b/time_tracker_api/project_types/project_types_model.py @@ -1,49 +1,52 @@ +from dataclasses import dataclass + from azure.cosmos import PartitionKey -from time_tracker_api.database import CRUDDao +from commons.data_access_layer.cosmos_db import CosmosDBModel, CosmosDBDao, CosmosDBRepository +from commons.data_access_layer.database import CRUDDao class ProjectTypeDao(CRUDDao): pass -def create_dao() -> ProjectTypeDao: - from commons.data_access_layer.sql import db - from time_tracker_api.database import COMMENTS_MAX_LENGTH - from commons.data_access_layer.sql import SQLCRUDDao - from sqlalchemy_utils import UUIDType - import uuid - - class ProjectTypeSQLModel(db.Model): - __tablename__ = 'project_type' - id = db.Column(UUIDType(binary=False), primary_key=True, default=uuid.uuid4) - name = db.Column(db.String(50), unique=True, nullable=False) - description = db.Column(db.String(COMMENTS_MAX_LENGTH), unique=False, nullable=False) - parent_id = db.Column(UUIDType(binary=False), default=uuid.uuid4) - customer_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) - - def __repr__(self): - return '' % self.name - - def __str___(self): - return "the project type \"%s\"" % self.name - - class ProjectTypeSQLDao(SQLCRUDDao): - def __init__(self): - SQLCRUDDao.__init__(self, ProjectTypeSQLModel) - - return ProjectTypeSQLDao() - - container_definition = { 'id': 'project_type', - 'partition_key': PartitionKey(path='/customer_id'), + 'partition_key': PartitionKey(path='/tenant_id'), 'unique_key_policy': { 'uniqueKeys': [ - {'paths': ['/name']}, - {'paths': ['/deleted']}, + {'paths': ['/name', '/customer_id']}, ] } } + + +@dataclass() +class ProjectTypeCosmosDBModel(CosmosDBModel): + id: str + name: str + description: str + parent_id: str + customer_id: str + deleted: str + tenant_id: str + + def __init__(self, data): + super(ProjectTypeCosmosDBModel, self).__init__(data) # pragma: no cover + + def __repr__(self): + return '' % self.name # pragma: no cover + + def __str___(self): + return "the project type \"%s\"" % self.name # pragma: no cover + + +def create_dao() -> ProjectTypeDao: + repository = CosmosDBRepository.from_definition(container_definition, + mapper=ProjectTypeCosmosDBModel) + + class ProjectTypeCosmosDBDao(CosmosDBDao, ProjectTypeDao): + def __init__(self): + CosmosDBDao.__init__(self, repository) + + return ProjectTypeCosmosDBDao() diff --git a/time_tracker_api/project_types/project_types_namespace.py b/time_tracker_api/project_types/project_types_namespace.py index 397821e8..2ed93054 100644 --- a/time_tracker_api/project_types/project_types_namespace.py +++ b/time_tracker_api/project_types/project_types_namespace.py @@ -2,7 +2,7 @@ from flask_restplus import Namespace, Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import audit_fields +from time_tracker_api.api import common_fields from time_tracker_api.project_types.project_types_model import create_dao faker = Faker() @@ -16,7 +16,7 @@ title='Name', max_length=50, description='Name of the project type', - example=faker.company(), + example=faker.random_element(["Customer","Training","Internal"]), ), 'description': fields.String( title='Description', @@ -29,17 +29,11 @@ description='Customer this project type belongs to', example=faker.uuid4(), ), - 'tenant_id': fields.String( - required=True, - title='Identifier of Tenant', - description='Tenant this project type belongs to', - example=faker.uuid4(), - ), 'parent_id': fields.String( title='Identifier of Parent of the project type', description='Defines a self reference of the model ProjectType', example=faker.uuid4(), - ) + ), }) project_type_response_fields = { @@ -49,9 +43,15 @@ title='Identifier', description='The unique identifier', example=faker.uuid4(), - ) + ), + 'tenant_id': fields.String( + required=True, + title='Identifier of Tenant', + description='Tenant this project type belongs to', + example=faker.uuid4(), + ), } -project_type_response_fields.update(audit_fields) +project_type_response_fields.update(common_fields) project_type = ns.inherit( 'ProjectType', diff --git a/time_tracker_api/projects/projects_model.py b/time_tracker_api/projects/projects_model.py index 23c1c055..6700005e 100644 --- a/time_tracker_api/projects/projects_model.py +++ b/time_tracker_api/projects/projects_model.py @@ -1,60 +1,52 @@ -import enum +from dataclasses import dataclass from azure.cosmos import PartitionKey -from time_tracker_api.database import CRUDDao - - -class PROJECT_TYPE(enum.Enum): - CUSTOMER = 'CUSTOMER' - TRAINING = 'TRAINING' - - @classmethod - def valid_type_values(self): - return list(map(lambda x: x.value, PROJECT_TYPE._member_map_.values())) +from commons.data_access_layer.cosmos_db import CosmosDBModel, CosmosDBDao, CosmosDBRepository +from commons.data_access_layer.database import CRUDDao class ProjectDao(CRUDDao): pass -def create_dao() -> ProjectDao: - from commons.data_access_layer.sql import db - from time_tracker_api.database import COMMENTS_MAX_LENGTH - from sqlalchemy_utils import UUIDType - import uuid - from commons.data_access_layer.sql import SQLCRUDDao - - class ProjectSQLModel(db.Model): - __tablename__ = 'project' - id = db.Column(UUIDType(binary=False), primary_key=True, default=uuid.uuid4) - name = db.Column(db.String(50), unique=True, nullable=False) - description = db.Column(db.String(COMMENTS_MAX_LENGTH), unique=False, nullable=False) - project_type_id = db.Column(UUIDType(binary=False), default=uuid.uuid4) - customer_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) - - def __repr__(self): - return '' % self.name - - def __str___(self): - return "the project \"%s\"" % self.name - - class ProjectSQLDao(SQLCRUDDao): - def __init__(self): - SQLCRUDDao.__init__(self, ProjectSQLModel) - - return ProjectSQLDao() - - container_definition = { 'id': 'project', 'partition_key': PartitionKey(path='/tenant_id'), 'unique_key_policy': { 'uniqueKeys': [ - {'paths': ['/name']}, - {'paths': ['/deleted']}, + {'paths': ['/name', '/customer_id']}, ] } } + + +@dataclass() +class ProjectCosmosDBModel(CosmosDBModel): + id: str + name: str + description: str + project_type_id: int + customer_id: str + deleted: str + tenant_id: str + + def __init__(self, data): + super(ProjectCosmosDBModel, self).__init__(data) # pragma: no cover + + def __repr__(self): + return '' % self.name # pragma: no cover + + def __str___(self): + return "the project \"%s\"" % self.name # pragma: no cover + + +def create_dao() -> ProjectDao: + repository = CosmosDBRepository.from_definition(container_definition, + mapper=ProjectCosmosDBModel) + + class ProjectCosmosDBDao(CosmosDBDao, ProjectDao): + def __init__(self): + CosmosDBDao.__init__(self, repository) + + return ProjectCosmosDBDao() diff --git a/time_tracker_api/projects/projects_namespace.py b/time_tracker_api/projects/projects_namespace.py index 9c4cdd3d..82a0fb8e 100644 --- a/time_tracker_api/projects/projects_namespace.py +++ b/time_tracker_api/projects/projects_namespace.py @@ -2,7 +2,7 @@ from flask_restplus import Namespace, Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import audit_fields +from time_tracker_api.api import common_fields from time_tracker_api.projects.projects_model import create_dao faker = Faker() @@ -30,12 +30,6 @@ description='Customer this project belongs to', example=faker.uuid4(), ), - 'tenant_id': fields.String( - required=True, - title='Identifier of Tenant', - description='Tenant this project belongs to', - example=faker.uuid4(), - ), 'project_type_id': fields.String( title='Identifier of Project type', description='Type of the project. Used for grouping', @@ -50,9 +44,15 @@ title='Identifier', description='The unique identifier', example=faker.uuid4(), - ) + ), + 'tenant_id': fields.String( + required=False, + title='Identifier of Tenant', + description='Tenant this project belongs to', + example=faker.uuid4(), + ), } -project_response_fields.update(audit_fields) +project_response_fields.update(common_fields) project = ns.inherit( 'Project', diff --git a/time_tracker_api/security.py b/time_tracker_api/security.py index b8ae1124..684f1dfc 100644 --- a/time_tracker_api/security.py +++ b/time_tracker_api/security.py @@ -9,7 +9,7 @@ dev_secret_key: str = None -def current_user_id(): +def current_user_id() -> str: """ Returns the id of the authenticated user in Azure Active Directory @@ -17,6 +17,11 @@ def current_user_id(): return 'anonymous' +def current_user_tenant_id() -> str: + # TODO Get this from the JWT + return "ioet" + + def generate_dev_secret_key(): from time_tracker_api import flask_app as app """ @@ -25,7 +30,7 @@ def generate_dev_secret_key(): """ global dev_secret_key dev_secret_key = fake.password(length=16, special_chars=True, digits=True, upper_case=True, lower_case=True) - if app.config.get("FLASK_DEBUG", False): + if app.config.get("FLASK_DEBUG", False): # pragma: no cover print('*********************************************************') print("The generated secret is \"%s\"" % dev_secret_key) print('*********************************************************') diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index e876e884..daa780fe 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 time_tracker_api.database import CRUDDao +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 time_tracker_api.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 '