From 5e9c3234c1913bcd1b7117d16783c8ff9baa9e56 Mon Sep 17 00:00:00 2001 From: roberto Date: Tue, 19 May 2020 17:00:08 -0500 Subject: [PATCH 001/287] feat(projects): :sparkles: add customer name to projects --- time_tracker_api/projects/custom_modules/utils.py | 9 +++++++++ time_tracker_api/projects/projects_model.py | 11 +++++++++-- time_tracker_api/projects/projects_namespace.py | 10 +++++++++- time_tracker_api/time_entries/custom_modules/utils.py | 4 ++++ 4 files changed, 31 insertions(+), 3 deletions(-) create mode 100644 time_tracker_api/projects/custom_modules/utils.py diff --git a/time_tracker_api/projects/custom_modules/utils.py b/time_tracker_api/projects/custom_modules/utils.py new file mode 100644 index 00000000..1d4ba1bb --- /dev/null +++ b/time_tracker_api/projects/custom_modules/utils.py @@ -0,0 +1,9 @@ +# TODO this must be refactored to be used from the utils module ↓ +# Also check if we can change this using the overwritten __add__ method + + +def add_customer_name_to_projects(projects, customers): + for project in projects: + for customer in customers: + if project.customer_id == customer.id: + setattr(project, 'customer_name', customer.name) diff --git a/time_tracker_api/projects/projects_model.py b/time_tracker_api/projects/projects_model.py index 3c4a3aac..54d7c0ba 100644 --- a/time_tracker_api/projects/projects_model.py +++ b/time_tracker_api/projects/projects_model.py @@ -5,6 +5,9 @@ from time_tracker_api.customers.customers_model import create_dao as customers_create_dao from time_tracker_api.customers.customers_model import CustomerCosmosDBModel +from time_tracker_api.projects.custom_modules.utils import ( + add_customer_name_to_projects +) class ProjectDao(CRUDDao): pass @@ -73,11 +76,15 @@ def get_all(self, conditions: dict = None, **kwargs) -> list: customers_id = [customer.id for customer in customers] conditions = conditions if conditions else {} custom_condition = "c.customer_id IN {}".format(str(tuple(customers_id))) + # TODO this must be refactored to be used from the utils module ↑ if "custom_sql_conditions" in kwargs: kwargs["custom_sql_conditions"].append(custom_condition) else: - kwargs["custom_sql_conditions"] = [custom_condition] - return self.repository.find_all(event_ctx, conditions, **kwargs) + kwargs["custom_sql_conditions"] = [custom_condition] + projects = self.repository.find_all(event_ctx, conditions, **kwargs) + + add_customer_name_to_projects(projects, customers) + return projects def create_dao() -> ProjectDao: diff --git a/time_tracker_api/projects/projects_namespace.py b/time_tracker_api/projects/projects_namespace.py index d14b3923..c64039c8 100644 --- a/time_tracker_api/projects/projects_namespace.py +++ b/time_tracker_api/projects/projects_namespace.py @@ -48,7 +48,15 @@ ) }) -project_response_fields = {} +project_response_fields = { + 'customer_name': fields.String( + required=True, + title='Customer Name', + max_length=50, + description='Name of the customer of the project', + example=faker.company(), + ), +} project_response_fields.update(common_fields) project = ns.inherit( diff --git a/time_tracker_api/time_entries/custom_modules/utils.py b/time_tracker_api/time_entries/custom_modules/utils.py index 6ae79c1c..e2ed09d5 100644 --- a/time_tracker_api/time_entries/custom_modules/utils.py +++ b/time_tracker_api/time_entries/custom_modules/utils.py @@ -1,3 +1,7 @@ +# TODO this must be refactored to be used from the utils module ↓ +# Also check if we can improve this by using the overwritten __add__ method + + def add_project_name_to_time_entries(time_entries, projects): for time_entry in time_entries: for project in projects: From e43e0abba48760de4fde14cdec40e60320ea966e Mon Sep 17 00:00:00 2001 From: roberto Date: Tue, 19 May 2020 17:33:05 -0500 Subject: [PATCH 002/287] chore(hooks): :mage: add code formatter in commit hook --- .pre-commit-config.yaml | 9 +++++++++ README.md | 5 ++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 797fb4b0..77e4a0c2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,5 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks repos: - repo: local hooks: @@ -6,3 +8,10 @@ repos: entry: python ./commons/git_hooks/enforce_semantic_commit_msg.py language: python stages : [commit-msg] +- repo: https://github.com/psf/black + rev: stable + hooks: + - id: black + args: [--line-length=79, --skip-string-normalization] + name: Check formatted code + stages: [commit] \ No newline at end of file diff --git a/README.md b/README.md index 01861dea..7f905643 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Remember to do it with Python 3. Bear in mind that the requirements for `time_tracker_events`, must be located on its local requirements.txt, by [convention](https://docs.microsoft.com/en-us/azure/azure-functions/functions-reference-python#folder-structure). -- Run `pre-commit install`. For more details, check out Development > Git hooks. +- Run `pre-commit install -t pre-commit -t commit-msg`. For more details, see section Development > Git hooks. ### How to use it - Set the env var `FLASK_APP` to `time_tracker_api` and start the app: @@ -153,11 +153,10 @@ func azure functionapp publish time-tracker-events --build local We use [pre-commit](https://github.com/pre-commit/pre-commit) library to manage local git hooks, as developers we just need to run in our virtual environment: ``` -pre-commit install +pre-commit install -t pre-commit -t commit-msg ``` With this command the library will take configuration from `.pre-commit-config.yaml` and will set up the hooks by us. -Currently, we only have a hook to enforce semantic commit message. ### Test We are using [Pytest](https://docs.pytest.org/en/latest/index.html) for tests. The tests are located in the package From 90a39fa9ad60bbb261e2b1ec86316d31cd57e080 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Tue, 19 May 2020 23:08:34 +0000 Subject: [PATCH 003/287] 0.14.0 Automatically generated by python-semantic-release --- time_tracker_api/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time_tracker_api/version.py b/time_tracker_api/version.py index 2d7893e3..ef919940 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.13.0' +__version__ = '0.14.0' From b0ac67ff57ceedac9aa789b6701fcec46680e6ed Mon Sep 17 00:00:00 2001 From: Roberto Mena <17350786+Angeluz-07@users.noreply.github.com> Date: Thu, 21 May 2020 11:28:50 -0500 Subject: [PATCH 004/287] Ref/move custom modules to utils folder#147 (#149) * refactor: :truck: move custom modules to utils folder * refactor: :recycle: replace peeker logic * refactor: :recycle: make validation functions of repository as instance methods * refactor: :recycle: remove log statement * refactor: :recycle: rename long named tests * refactor: :ok_hand: add docs to utils.extend_model.py --- commons/data_access_layer/cosmos_db.py | 51 +- .../data_access_layer/cosmos_db_test.py | 533 +++++++++++------- tests/conftest.py | 121 ++-- .../time_entries_namespace_test.py | 267 +++++---- .../projects/custom_modules/utils.py | 9 - time_tracker_api/projects/projects_model.py | 38 +- .../time_entries/custom_modules/utils.py | 9 - .../time_entries/time_entries_model.py | 89 +-- utils/extend_model.py | 28 + .../custom_modules => utils}/worked_time.py | 13 +- 10 files changed, 652 insertions(+), 506 deletions(-) delete mode 100644 time_tracker_api/projects/custom_modules/utils.py delete mode 100644 time_tracker_api/time_entries/custom_modules/utils.py create mode 100644 utils/extend_model.py rename {time_tracker_api/time_entries/custom_modules => utils}/worked_time.py (94%) diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index 8ac412c6..b8af2cbd 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -210,20 +210,25 @@ def find( self, id: str, event_context: EventContext, - peeker: Callable = None, visible_only=True, mapper: Callable = None, ): partition_key_value = self.find_partition_key_value(event_context) found_item = self.container.read_item(id, partition_key_value) - if peeker: - peeker(found_item) - function_mapper = self.get_mapper_or_dict(mapper) return function_mapper(self.check_visibility(found_item, visible_only)) - def find_all(self, event_context: EventContext, conditions: dict = {}, custom_sql_conditions: List[str] = [], - custom_params: dict = {}, max_count=None, offset=0, visible_only=True, mapper: Callable = None): + def find_all( + self, + event_context: EventContext, + conditions: dict = {}, + custom_sql_conditions: List[str] = [], + custom_params: dict = {}, + max_count=None, + offset=0, + visible_only=True, + mapper: Callable = None, + ): partition_key_value = self.find_partition_key_value(event_context) max_count = self.get_page_size_or(max_count) params = [ @@ -242,16 +247,16 @@ def find_all(self, event_context: EventContext, conditions: dict = {}, custom_sq {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 - ), - conditions_clause=self.create_sql_where_conditions(conditions), - custom_sql_conditions_clause=self.create_custom_sql_conditions( - custom_sql_conditions - ), - order_clause=self.create_sql_order_clause(), - ) + partition_key_attribute=self.partition_key_attribute, + visibility_condition=self.create_sql_condition_for_visibility( + visible_only + ), + conditions_clause=self.create_sql_where_conditions(conditions), + custom_sql_conditions_clause=self.create_custom_sql_conditions( + custom_sql_conditions + ), + order_clause=self.create_sql_order_clause(), + ) result = self.container.query_items( query=query_str, @@ -268,16 +273,11 @@ def partial_update( id: str, changes: dict, event_context: EventContext, - peeker: Callable = None, visible_only=True, mapper: Callable = None, ): item_data = self.find( - id, - event_context, - peeker=peeker, - visible_only=visible_only, - mapper=dict, + id, event_context, visible_only=visible_only, mapper=dict, ) item_data.update(changes) return self.update(id, item_data, event_context, mapper=mapper) @@ -295,17 +295,12 @@ def update( return function_mapper(self.container.replace_item(id, body=item_data)) def delete( - self, - id: str, - event_context: EventContext, - peeker: Callable = None, - mapper: Callable = None, + self, id: str, event_context: EventContext, mapper: Callable = None, ): return self.partial_update( id, {'deleted': generate_uuid4()}, event_context, - peeker=peeker, visible_only=True, mapper=mapper, ) diff --git a/tests/commons/data_access_layer/cosmos_db_test.py b/tests/commons/data_access_layer/cosmos_db_test.py index dd54038a..6cb6deb7 100644 --- a/tests/commons/data_access_layer/cosmos_db_test.py +++ b/tests/commons/data_access_layer/cosmos_db_test.py @@ -3,13 +3,21 @@ from typing import Callable import pytest -from azure.cosmos.exceptions import CosmosResourceExistsError, CosmosResourceNotFoundError +from azure.cosmos.exceptions import ( + CosmosResourceExistsError, + CosmosResourceNotFoundError, +) from faker import Faker from flask_restplus._http import HTTPStatus from pytest import fail -from commons.data_access_layer.cosmos_db import CosmosDBRepository, CosmosDBModel, CustomError, current_datetime, \ - datetime_str +from commons.data_access_layer.cosmos_db import ( + CosmosDBRepository, + CosmosDBModel, + CustomError, + current_datetime, + datetime_str, +) from commons.data_access_layer.database import EventContext fake = Faker() @@ -35,14 +43,18 @@ def test_repository_exists(cosmos_db_repository): assert cosmos_db_repository is not None -def test_create_should_succeed(cosmos_db_repository: CosmosDBRepository, - tenant_id: str, - event_context: EventContext): - sample_item = dict(id=fake.uuid4(), - name=fake.name(), - email=fake.safe_email(), - age=fake.pyint(min_value=10, max_value=80), - tenant_id=tenant_id) +def test_create_should_succeed( + cosmos_db_repository: CosmosDBRepository, + tenant_id: str, + event_context: EventContext, +): + sample_item = dict( + id=fake.uuid4(), + name=fake.name(), + email=fake.safe_email(), + age=fake.pyint(min_value=10, max_value=80), + tenant_id=tenant_id, + ) created_item = cosmos_db_repository.create(sample_item, event_context) @@ -50,9 +62,11 @@ def test_create_should_succeed(cosmos_db_repository: CosmosDBRepository, assert all(item in created_item.items() for item in sample_item.items()) -def test_create_should_fail_if_user_is_same(cosmos_db_repository: CosmosDBRepository, - sample_item: dict, - event_context: EventContext): +def test_create_should_fail_if_user_is_same( + cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + event_context: EventContext, +): try: cosmos_db_repository.create(sample_item, event_context) @@ -62,27 +76,30 @@ def test_create_should_fail_if_user_is_same(cosmos_db_repository: CosmosDBReposi assert e.status_code == 409 -def test_create_with_diff_unique_data_but_same_tenant_should_succeed(cosmos_db_repository: CosmosDBRepository, - sample_item: dict, - event_context: EventContext): +def test_create_with_diff_unique_data_but_same_tenant_should_succeed( + cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + event_context: EventContext, +): new_data = sample_item.copy() - new_data.update({ - 'id': fake.uuid4(), - 'email': fake.safe_email(), - }) + new_data.update( + {'id': fake.uuid4(), 'email': fake.safe_email(),} + ) result = cosmos_db_repository.create(new_data, event_context) assert result["id"] != sample_item["id"], 'It should be a new element' -def test_create_with_same_id_should_fail(cosmos_db_repository: CosmosDBRepository, - sample_item: dict, - event_context: EventContext): +def test_create_with_same_id_should_fail( + cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + event_context: EventContext, +): try: new_data = sample_item.copy() - new_data.update({ - 'email': fake.safe_email(), - }) + new_data.update( + {'email': fake.safe_email(),} + ) cosmos_db_repository.create(new_data, event_context) @@ -92,14 +109,14 @@ def test_create_with_same_id_should_fail(cosmos_db_repository: CosmosDBRepositor assert e.status_code == 409 -def test_create_with_diff_id_but_same_unique_field_should_fail(cosmos_db_repository: CosmosDBRepository, - sample_item: dict, - event_context: EventContext): +def test_create_with_diff_id_but_same_unique_field_should_fail( + cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + event_context: EventContext, +): try: new_data = sample_item.copy() - new_data.update({ - 'id': fake.uuid4() - }) + new_data.update({'id': fake.uuid4()}) cosmos_db_repository.create(new_data, event_context) @@ -109,47 +126,62 @@ def test_create_with_diff_id_but_same_unique_field_should_fail(cosmos_db_reposit assert e.status_code == 409 -def test_create_with_same_id_but_diff_partition_key_attrib_should_succeed(cosmos_db_repository: CosmosDBRepository, - another_event_context: EventContext, - sample_item: dict, - another_tenant_id: str): +def test_create_with_same_id_but_diff_partition_key_attrib_should_succeed( + cosmos_db_repository: CosmosDBRepository, + another_event_context: EventContext, + sample_item: dict, + another_tenant_id: str, +): new_data = sample_item.copy() - new_data.update({ - 'tenant_id': another_tenant_id, - }) + new_data.update( + {'tenant_id': another_tenant_id,} + ) result = cosmos_db_repository.create(new_data, another_event_context) assert result["id"] == sample_item["id"], "Should have allowed same id" -def test_create_with_mapper_should_provide_calculated_fields(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext, - tenant_id: str): - new_item = dict(id=fake.uuid4(), - name=fake.name(), - email=fake.safe_email(), - age=fake.pyint(min_value=10, max_value=80), - tenant_id=tenant_id) +def test_create_with_mapper_should_provide_calculated_fields( + cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + tenant_id: str, +): + new_item = dict( + id=fake.uuid4(), + name=fake.name(), + email=fake.safe_email(), + age=fake.pyint(min_value=10, max_value=80), + tenant_id=tenant_id, + ) - created_item: Person = cosmos_db_repository.create(new_item, event_context, mapper=Person) + created_item: Person = cosmos_db_repository.create( + new_item, event_context, mapper=Person + ) assert created_item is not None - assert all(item in created_item.__dict__.items() for item in new_item.items()) - assert type(created_item) is Person, "The result should be wrapped with a class" + assert all( + item in created_item.__dict__.items() for item in new_item.items() + ) + assert ( + type(created_item) is Person + ), "The result should be wrapped with a class" assert created_item.is_adult() is (new_item["age"] >= 18) -def test_find_by_valid_id_should_succeed(cosmos_db_repository: CosmosDBRepository, - sample_item: dict, - event_context: EventContext): +def test_find_by_valid_id_should_succeed( + cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + event_context: EventContext, +): found_item = cosmos_db_repository.find(sample_item["id"], event_context) assert all(item in found_item.items() for item in sample_item.items()) -def test_find_by_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext): +def test_find_by_invalid_id_should_fail( + cosmos_db_repository: CosmosDBRepository, event_context: EventContext +): try: cosmos_db_repository.find(fake.uuid4(), event_context) @@ -159,8 +191,9 @@ def test_find_by_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository assert e.status_code == 404 -def test_find_by_invalid_partition_key_value_should_fail(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext): +def test_find_by_invalid_partition_key_value_should_fail( + cosmos_db_repository: CosmosDBRepository, event_context: EventContext +): try: cosmos_db_repository.find(fake.uuid4(), event_context) @@ -170,51 +203,75 @@ def test_find_by_invalid_partition_key_value_should_fail(cosmos_db_repository: C assert e.status_code == 404 -def test_find_by_valid_id_and_mapper_should_succeed(cosmos_db_repository: CosmosDBRepository, - sample_item: dict, - event_context: EventContext): - found_item: Person = cosmos_db_repository.find(sample_item["id"], - event_context, - mapper=Person) +def test_find_by_valid_id_and_mapper_should_succeed( + cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + event_context: EventContext, +): + found_item: Person = cosmos_db_repository.find( + sample_item["id"], event_context, mapper=Person + ) found_item_dict = found_item.__dict__ - assert all(attrib in sample_item.items() for attrib in found_item_dict.items()) - assert type(found_item) is Person, "The result should be wrapped with a class" + assert all( + attrib in sample_item.items() for attrib in found_item_dict.items() + ) + assert ( + type(found_item) is Person + ), "The result should be wrapped with a class" assert found_item.is_adult() is (sample_item["age"] >= 18) @pytest.mark.parametrize( 'mapper,expected_type', [(None, dict), (dict, dict), (Person, Person)] ) -def test_find_all_with_mapper(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext, - mapper: Callable, - expected_type: Callable): +def test_find_all_with_mapper( + cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + mapper: Callable, + expected_type: Callable, +): result = cosmos_db_repository.find_all(event_context, mapper=mapper) assert result is not None assert len(result) > 0 - assert type(result[0]) is expected_type, "The result type is not the expected" + assert ( + type(result[0]) is expected_type + ), "The result type is not the expected" -def test_find_all_should_return_items_from_specified_partition_key_value(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext, - another_event_context: EventContext): +def test_find_all_should_return_items_from_specified_partition_key_value( + cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + another_event_context: EventContext, +): result_tenant_id = cosmos_db_repository.find_all(event_context) assert len(result_tenant_id) > 1 - assert all((i["tenant_id"] == event_context.tenant_id for i in result_tenant_id)) + assert all( + (i["tenant_id"] == event_context.tenant_id for i in result_tenant_id) + ) - result_another_tenant_id = cosmos_db_repository.find_all(another_event_context) + result_another_tenant_id = cosmos_db_repository.find_all( + another_event_context + ) assert len(result_another_tenant_id) > 0 - assert all((i["tenant_id"] == another_event_context.tenant_id for i in result_another_tenant_id)) + assert all( + ( + i["tenant_id"] == another_event_context.tenant_id + for i in result_another_tenant_id + ) + ) - assert not any(item in result_another_tenant_id for item in result_tenant_id), \ - "There should be no interceptions" + assert not any( + item in result_another_tenant_id for item in result_tenant_id + ), "There should be no interceptions" -def test_find_all_should_succeed_with_partition_key_value_with_no_items(cosmos_db_repository: CosmosDBRepository): +def test_find_all_should_succeed_with_partition_key_value_with_no_items( + cosmos_db_repository: CosmosDBRepository, +): invalid_event_context = EventContext("test", "any", tenant_id=fake.uuid4()) no_items = cosmos_db_repository.find_all(invalid_event_context) @@ -223,8 +280,9 @@ def test_find_all_should_succeed_with_partition_key_value_with_no_items(cosmos_d assert len(no_items) == 0, "No items are expected" -def test_find_all_with_max_count(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext): +def test_find_all_with_max_count( + cosmos_db_repository: CosmosDBRepository, event_context: EventContext +): all_items = cosmos_db_repository.find_all(event_context) assert len(all_items) > 2 @@ -233,17 +291,22 @@ def test_find_all_with_max_count(cosmos_db_repository: CosmosDBRepository, assert len(first_two_items) == 2, "The result should be limited to 2" -def test_find_all_with_offset(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext): +def test_find_all_with_offset( + cosmos_db_repository: CosmosDBRepository, event_context: EventContext +): result_all_items = cosmos_db_repository.find_all(event_context) assert len(result_all_items) >= 3 - result_after_the_first_item = cosmos_db_repository.find_all(event_context, offset=1) + result_after_the_first_item = cosmos_db_repository.find_all( + event_context, offset=1 + ) assert result_after_the_first_item == result_all_items[1:] - result_after_the_second_item = cosmos_db_repository.find_all(event_context, offset=2) + result_after_the_second_item = cosmos_db_repository.find_all( + event_context, offset=2 + ) assert result_after_the_second_item == result_all_items[2:] @@ -251,49 +314,58 @@ def test_find_all_with_offset(cosmos_db_repository: CosmosDBRepository, @pytest.mark.parametrize( 'mapper,expected_type', [(None, dict), (dict, dict), (Person, Person)] ) -def test_partial_update_with_mapper(cosmos_db_repository: CosmosDBRepository, - mapper: Callable, - sample_item: dict, - event_context: EventContext, - expected_type: Callable): +def test_partial_update_with_mapper( + cosmos_db_repository: CosmosDBRepository, + mapper: Callable, + sample_item: dict, + event_context: EventContext, + expected_type: Callable, +): changes = { 'name': fake.name(), 'email': fake.safe_email(), } - updated_item = cosmos_db_repository.partial_update(sample_item['id'], changes, - event_context, mapper=mapper) + updated_item = cosmos_db_repository.partial_update( + sample_item['id'], changes, event_context, mapper=mapper + ) assert updated_item is not None assert type(updated_item) is expected_type def test_partial_update_with_new_partition_key_value_should_fail( - cosmos_db_repository: CosmosDBRepository, - another_event_context: EventContext, - sample_item: dict): + cosmos_db_repository: CosmosDBRepository, + another_event_context: EventContext, + sample_item: dict, +): changes = { 'name': fake.name(), 'email': fake.safe_email(), } try: - cosmos_db_repository.partial_update(sample_item['id'], changes, another_event_context) + cosmos_db_repository.partial_update( + sample_item['id'], changes, another_event_context + ) fail('It should have failed') except Exception as e: assert type(e) is CosmosResourceNotFoundError assert e.status_code == 404 -def test_partial_update_with_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext): +def test_partial_update_with_invalid_id_should_fail( + cosmos_db_repository: CosmosDBRepository, event_context: EventContext +): changes = { 'name': fake.name(), 'email': fake.safe_email(), } try: - cosmos_db_repository.partial_update(fake.uuid4(), changes, event_context) + cosmos_db_repository.partial_update( + fake.uuid4(), changes, event_context + ) fail('It should have failed') except Exception as e: assert type(e) is CosmosResourceNotFoundError @@ -301,17 +373,18 @@ def test_partial_update_with_invalid_id_should_fail(cosmos_db_repository: Cosmos def test_partial_update_should_only_update_fields_in_changes( - cosmos_db_repository: CosmosDBRepository, - sample_item: dict, - event_context: EventContext): + cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + event_context: EventContext, +): changes = { 'name': fake.name(), 'email': fake.safe_email(), } - updated_item = cosmos_db_repository.partial_update(sample_item['id'], - changes, - event_context) + updated_item = cosmos_db_repository.partial_update( + sample_item['id'], changes, event_context + ) assert updated_item is not None assert updated_item['name'] == changes["name"] != sample_item["name"] @@ -324,28 +397,29 @@ def test_partial_update_should_only_update_fields_in_changes( @pytest.mark.parametrize( 'mapper,expected_type', [(None, dict), (dict, dict), (Person, Person)] ) -def test_update_with_mapper(cosmos_db_repository: CosmosDBRepository, - mapper: Callable, - sample_item: dict, - event_context: EventContext, - expected_type: Callable): +def test_update_with_mapper( + cosmos_db_repository: CosmosDBRepository, + mapper: Callable, + sample_item: dict, + event_context: EventContext, + expected_type: Callable, +): changed_item = sample_item.copy() - changed_item.update({ - 'name': fake.name(), - 'email': fake.safe_email(), - }) + changed_item.update( + {'name': fake.name(), 'email': fake.safe_email(),} + ) - updated_item = cosmos_db_repository.update(sample_item['id'], - changed_item, - event_context, - mapper=mapper) + updated_item = cosmos_db_repository.update( + sample_item['id'], changed_item, event_context, mapper=mapper + ) assert updated_item is not None assert type(updated_item) is expected_type -def test_update_with_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext): +def test_update_with_invalid_id_should_fail( + cosmos_db_repository: CosmosDBRepository, event_context: EventContext +): changes = { 'name': fake.name(), 'email': fake.safe_email(), @@ -359,9 +433,11 @@ def test_update_with_invalid_id_should_fail(cosmos_db_repository: CosmosDBReposi assert e.status_code == 404 -def test_update_with_partial_changes_without_required_fields_it_should_fail(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext, - sample_item: dict): +def test_update_with_partial_changes_without_required_fields_it_should_fail( + cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + sample_item: dict, +): changes = { 'id': sample_item['id'], 'email': fake.safe_email(), @@ -377,16 +453,19 @@ def test_update_with_partial_changes_without_required_fields_it_should_fail(cosm def test_update_with_partial_changes_with_required_fields_should_delete_the_missing_ones( - cosmos_db_repository: CosmosDBRepository, - event_context: EventContext, - sample_item: dict): + cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + sample_item: dict, +): changes = { 'id': fake.uuid4(), 'email': fake.safe_email(), 'tenant_id': event_context.tenant_id, } - updated_item = cosmos_db_repository.update(sample_item['id'], changes, event_context) + updated_item = cosmos_db_repository.update( + sample_item['id'], changes, event_context + ) assert updated_item is not None assert updated_item['id'] == changes["id"] != sample_item["id"] @@ -403,9 +482,11 @@ def test_update_with_partial_changes_with_required_fields_should_delete_the_miss assert e.status_code == 404 -def test_delete_with_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext, - tenant_id: str): +def test_delete_with_invalid_id_should_fail( + cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + tenant_id: str, +): try: cosmos_db_repository.delete(fake.uuid4(), event_context) except Exception as e: @@ -416,28 +497,38 @@ def test_delete_with_invalid_id_should_fail(cosmos_db_repository: CosmosDBReposi @pytest.mark.parametrize( 'mapper,expected_type', [(None, dict), (dict, dict), (Person, Person)] ) -def test_delete_with_mapper(cosmos_db_repository: CosmosDBRepository, - sample_item: dict, - event_context: EventContext, - mapper: Callable, - expected_type: Callable): - deleted_item = cosmos_db_repository.delete(sample_item['id'], event_context, mapper=mapper) +def test_delete_with_mapper( + cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + event_context: EventContext, + mapper: Callable, + expected_type: Callable, +): + deleted_item = cosmos_db_repository.delete( + sample_item['id'], event_context, mapper=mapper + ) assert deleted_item is not None assert type(deleted_item) is expected_type try: - cosmos_db_repository.find(sample_item['id'], event_context, mapper=mapper) + cosmos_db_repository.find( + sample_item['id'], event_context, mapper=mapper + ) fail('It should have not found the deleted item') except Exception as e: assert type(e) is CosmosResourceNotFoundError assert e.status_code == 404 -def test_find_can_find_deleted_item_only_if_visibile_only_is_true(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext, - sample_item: dict): - deleted_item = cosmos_db_repository.delete(sample_item['id'], event_context) +def test_find_can_find_deleted_item_only_if_visibile_only_is_true( + cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + sample_item: dict, +): + deleted_item = cosmos_db_repository.delete( + sample_item['id'], event_context + ) assert deleted_item is not None assert deleted_item['deleted'] is not None @@ -448,34 +539,48 @@ def test_find_can_find_deleted_item_only_if_visibile_only_is_true(cosmos_db_repo assert type(e) is CosmosResourceNotFoundError assert e.status_code == 404 - found_deleted_item = cosmos_db_repository.find(sample_item['id'], event_context, visible_only=False) + found_deleted_item = cosmos_db_repository.find( + sample_item['id'], event_context, visible_only=False + ) assert found_deleted_item is not None -def test_find_all_can_find_deleted_items_only_if_visibile_only_is_true(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext, - sample_item: dict): - deleted_item = cosmos_db_repository.delete(sample_item['id'], event_context) +def test_find_all_can_find_deleted_items_only_if_visibile_only_is_true( + cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + sample_item: dict, +): + deleted_item = cosmos_db_repository.delete( + sample_item['id'], event_context + ) assert deleted_item is not None assert deleted_item['deleted'] is not None visible_items = cosmos_db_repository.find_all(event_context) assert visible_items is not None - assert any(item['id'] == sample_item['id'] for item in visible_items) == False, \ - 'The deleted item should not be visible' + assert ( + any(item['id'] == sample_item['id'] for item in visible_items) == False + ), 'The deleted item should not be visible' - all_items = cosmos_db_repository.find_all(event_context, visible_only=False) + all_items = cosmos_db_repository.find_all( + event_context, visible_only=False + ) assert all_items is not None - assert any(item['id'] == sample_item['id'] for item in all_items), \ - 'Deleted item should be visible' + assert any( + item['id'] == sample_item['id'] for item in all_items + ), 'Deleted item should be visible' -def test_delete_should_not_find_element_that_is_already_deleted(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext, - sample_item: dict): - deleted_item = cosmos_db_repository.delete(sample_item['id'], event_context) +def test_delete_should_not_find_element_that_is_already_deleted( + cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + sample_item: dict, +): + deleted_item = cosmos_db_repository.delete( + sample_item['id'], event_context + ) assert deleted_item is not None @@ -487,10 +592,14 @@ def test_delete_should_not_find_element_that_is_already_deleted(cosmos_db_reposi assert e.status_code == 404 -def test_partial_update_should_not_find_element_that_is_already_deleted(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext, - sample_item: dict): - deleted_item = cosmos_db_repository.delete(sample_item['id'], event_context) +def test_partial_update_should_not_find_element_that_is_already_deleted( + cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + sample_item: dict, +): + deleted_item = cosmos_db_repository.delete( + sample_item['id'], event_context + ) assert deleted_item is not None @@ -499,9 +608,9 @@ def test_partial_update_should_not_find_element_that_is_already_deleted(cosmos_d 'name': fake.name(), 'email': fake.safe_email(), } - cosmos_db_repository.partial_update(deleted_item['id'], - changes, - event_context) + cosmos_db_repository.partial_update( + deleted_item['id'], changes, event_context + ) fail('It should have not found the deleted item') except Exception as e: @@ -509,9 +618,11 @@ def test_partial_update_should_not_find_element_that_is_already_deleted(cosmos_d assert e.status_code == 404 -def test_delete_permanently_with_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext, - tenant_id: str): +def test_delete_permanently_with_invalid_id_should_fail( + cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + tenant_id: str, +): try: cosmos_db_repository.delete_permanently(fake.uuid4(), event_context) fail('It should have not found the deleted item') @@ -520,9 +631,11 @@ def test_delete_permanently_with_invalid_id_should_fail(cosmos_db_repository: Co assert e.status_code == 404 -def test_delete_permanently_with_valid_id_should_succeed(cosmos_db_repository: CosmosDBRepository, - event_context: EventContext, - sample_item: dict): +def test_delete_permanently_with_valid_id_should_succeed( + cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + sample_item: dict, +): found_item = cosmos_db_repository.find(sample_item['id'], event_context) assert found_item is not None @@ -538,52 +651,40 @@ def test_delete_permanently_with_valid_id_should_succeed(cosmos_db_repository: C assert e.status_code == 404 -def test_repository_create_sql_where_conditions_with_multiple_values(cosmos_db_repository: CosmosDBRepository): - result = cosmos_db_repository.create_sql_where_conditions({ - 'owner_id': 'mark', - 'customer_id': 'me' - }, "c") +def test_repository_create_sql_where_conditions_with_multiple_values( + cosmos_db_repository: CosmosDBRepository, +): + result = cosmos_db_repository.create_sql_where_conditions( + {'owner_id': 'mark', 'customer_id': 'me'}, "c" + ) assert result is not None - assert result == "AND c.owner_id = @owner_id AND c.customer_id = @customer_id" + assert ( + result == "AND c.owner_id = @owner_id AND c.customer_id = @customer_id" + ) -def test_repository_create_sql_where_conditions_with_no_values(cosmos_db_repository: CosmosDBRepository): +def test_repository_create_sql_where_conditions_with_no_values( + cosmos_db_repository: CosmosDBRepository, +): result = cosmos_db_repository.create_sql_where_conditions({}, "c") assert result is not None assert result == "" -def test_repository_append_conditions_values(cosmos_db_repository: CosmosDBRepository): - result = cosmos_db_repository.generate_params({'owner_id': 'mark', 'customer_id': 'ioet'}) +def test_repository_append_conditions_values( + cosmos_db_repository: CosmosDBRepository, +): + result = cosmos_db_repository.generate_params( + {'owner_id': 'mark', 'customer_id': 'ioet'} + ) assert result is not None - assert result == [{'name': '@owner_id', 'value': 'mark'}, - {'name': '@customer_id', 'value': 'ioet'}] - - -def test_find_should_call_picker_if_it_was_specified(cosmos_db_repository: CosmosDBRepository, - sample_item: dict, - event_context: EventContext, - another_item: dict): - def raise_bad_request_if_name_diff_the_one_from_sample_item(data: dict): - if sample_item['name'] != data['name']: - raise CustomError(HTTPStatus.BAD_REQUEST, "Anything") - - found_item = cosmos_db_repository.find(sample_item['id'], event_context) - - assert found_item is not None - assert found_item['id'] == sample_item['id'] - - try: - cosmos_db_repository.find(another_item['id'], event_context, - peeker=raise_bad_request_if_name_diff_the_one_from_sample_item) - - fail('It should have not found any item because of condition') - except Exception as e: - assert e.code == HTTPStatus.BAD_REQUEST - assert e.description == "Anything" + assert result == [ + {'name': '@owner_id', 'value': 'mark'}, + {'name': '@customer_id', 'value': 'ioet'}, + ] def test_datetime_str_comparison(): @@ -601,15 +702,17 @@ def test_datetime_str_comparison(): def test_replace_empty_value_per_none(tenant_id: str): - initial_value = dict(id=fake.uuid4(), - name=fake.name(), - empty_str_attrib="", - array_attrib=[1, 2, 3], - empty_array_attrib=[], - description=" ", - age=fake.pyint(min_value=10, max_value=80), - size=0, - tenant_id=tenant_id) + initial_value = dict( + id=fake.uuid4(), + name=fake.name(), + empty_str_attrib="", + array_attrib=[1, 2, 3], + empty_array_attrib=[], + description=" ", + age=fake.pyint(min_value=10, max_value=80), + size=0, + tenant_id=tenant_id, + ) input = initial_value.copy() @@ -625,9 +728,9 @@ def test_replace_empty_value_per_none(tenant_id: str): assert input["tenant_id"] == initial_value["tenant_id"] -def test_attach_context_should_create_last_event_context_attrib(owner_id: str, - tenant_id: str, - event_context: EventContext): +def test_attach_context_should_create_last_event_context_attrib( + owner_id: str, tenant_id: str, event_context: EventContext +): data = dict() CosmosDBRepository.real_attach_context(data, event_context) diff --git a/tests/conftest.py b/tests/conftest.py index 362df371..1f8afe71 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,9 @@ from time_tracker_api import create_app from time_tracker_api.database import init_sql from time_tracker_api.security import get_or_generate_dev_secret_key -from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository +from time_tracker_api.time_entries.time_entries_model import ( + TimeEntryCosmosDBRepository, +) fake = Faker() Faker.seed() @@ -37,6 +39,7 @@ def sql_model_class(app: Flask): init_sql(app) from commons.data_access_layer.sql import db + class PersonSQLModel(db.Model): __tablename__ = 'test' id = db.Column(db.Integer, primary_key=True) @@ -59,27 +62,29 @@ def sql_repository(app: Flask, sql_model_class): init_app(app) from commons.data_access_layer.sql import db - db.metadata.create_all(bind=db.engine, tables=[sql_model_class.__table__]) + db.metadata.create_all( + bind=db.engine, tables=[sql_model_class.__table__] + ) app.logger.info("SQl test models created!") from commons.data_access_layer.sql import SQLRepository + yield SQLRepository(sql_model_class) - db.metadata.drop_all(bind=db.engine, tables=[sql_model_class.__table__]) + db.metadata.drop_all( + bind=db.engine, tables=[sql_model_class.__table__] + ) app.logger.info("SQL test models removed!") @pytest.fixture(scope="module") def cosmos_db_model(): from azure.cosmos import PartitionKey + return { 'id': 'test', 'partition_key': PartitionKey(path='/tenant_id'), - 'unique_key_policy': { - 'uniqueKeys': [ - {'paths': ['/email']}, - ] - } + 'unique_key_policy': {'uniqueKeys': [{'paths': ['/email']},]}, } @@ -104,11 +109,18 @@ def cosmos_db_repository(app: Flask, cosmos_db_model) -> CosmosDBRepository: @pytest.fixture(scope="module") -def cosmos_db_dao(app: Flask, cosmos_db_repository: CosmosDBRepository) -> CosmosDBDao: +def cosmos_db_dao( + app: Flask, cosmos_db_repository: CosmosDBRepository +) -> CosmosDBDao: with app.app_context(): return CosmosDBDao(cosmos_db_repository) +@pytest.fixture +def valid_id() -> str: + return fake.uuid4() + + @pytest.fixture(scope="session") def tenant_id() -> str: return fake.uuid4() @@ -125,27 +137,35 @@ def owner_id() -> str: @pytest.fixture(scope="function") -def sample_item(cosmos_db_repository: CosmosDBRepository, - tenant_id: str, - event_context: EventContext) -> dict: - sample_item_data = dict(id=fake.uuid4(), - name=fake.name(), - email=fake.safe_email(), - age=fake.pyint(min_value=10, max_value=80), - tenant_id=tenant_id) +def sample_item( + cosmos_db_repository: CosmosDBRepository, + tenant_id: str, + event_context: EventContext, +) -> dict: + sample_item_data = dict( + id=fake.uuid4(), + name=fake.name(), + email=fake.safe_email(), + age=fake.pyint(min_value=10, max_value=80), + tenant_id=tenant_id, + ) return cosmos_db_repository.create(sample_item_data, event_context) @pytest.fixture(scope="function") -def another_item(cosmos_db_repository: CosmosDBRepository, - tenant_id: str, - event_context: EventContext) -> dict: - sample_item_data = dict(id=fake.uuid4(), - name=fake.name(), - email=fake.safe_email(), - age=fake.pyint(min_value=10, max_value=80), - tenant_id=tenant_id) +def another_item( + cosmos_db_repository: CosmosDBRepository, + tenant_id: str, + event_context: EventContext, +) -> dict: + sample_item_data = dict( + id=fake.uuid4(), + name=fake.name(), + email=fake.safe_email(), + age=fake.pyint(min_value=10, max_value=80), + tenant_id=tenant_id, + ) return cosmos_db_repository.create(sample_item_data, event_context) @@ -162,31 +182,40 @@ def time_entry_repository(app: Flask) -> TimeEntryCosmosDBRepository: @pytest.yield_fixture(scope="module") -def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository, - owner_id: str, - tenant_id: str, - event_context: EventContext): - created_time_entry = time_entry_repository.create({ - "project_id": fake.uuid4(), - "owner_id": owner_id, - "tenant_id": tenant_id - }, event_context) +def running_time_entry( + time_entry_repository: TimeEntryCosmosDBRepository, + owner_id: str, + tenant_id: str, + event_context: EventContext, +): + created_time_entry = time_entry_repository.create( + { + "project_id": fake.uuid4(), + "owner_id": owner_id, + "tenant_id": tenant_id, + }, + event_context, + ) yield created_time_entry - time_entry_repository.delete_permanently(id=created_time_entry.id, - event_context=event_context) + time_entry_repository.delete_permanently( + id=created_time_entry.id, event_context=event_context + ) @pytest.fixture(scope="session") def valid_jwt(app: Flask, tenant_id: str, owner_id: str) -> str: with app.app_context(): expiration_time = datetime.utcnow() + timedelta(seconds=3600) - return jwt.encode({ - "iss": "https://ioetec.b2clogin.com/%s/v2.0/" % tenant_id, - "oid": owner_id, - 'exp': expiration_time - }, key=get_or_generate_dev_secret_key()).decode("UTF-8") + return jwt.encode( + { + "iss": "https://ioetec.b2clogin.com/%s/v2.0/" % tenant_id, + "oid": owner_id, + 'exp': expiration_time, + }, + key=get_or_generate_dev_secret_key(), + ).decode("UTF-8") @pytest.fixture(scope="session") @@ -196,13 +225,11 @@ def valid_header(valid_jwt: str) -> dict: @pytest.fixture(scope="session") def event_context(owner_id: str, tenant_id: str) -> EventContext: - return EventContext("test", "any", - user_id=owner_id, - tenant_id=tenant_id) + return EventContext("test", "any", user_id=owner_id, tenant_id=tenant_id) @pytest.fixture(scope="session") def another_event_context(another_tenant_id: str) -> EventContext: - return EventContext("test", "any", - user_id=fake.uuid4(), - tenant_id=another_tenant_id) + return EventContext( + "test", "any", user_id=fake.uuid4(), tenant_id=another_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 a29dbc98..13fd9268 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 @@ -10,15 +10,13 @@ from commons.data_access_layer.cosmos_db import ( current_datetime, current_datetime_str, - get_date_range_of_month, get_current_month, get_current_year, ) -from time_tracker_api.time_entries.custom_modules import worked_time -from time_tracker_api.time_entries.time_entries_model import ( - TimeEntriesCosmosDBDao, -) +from utils import worked_time + +from werkzeug.exceptions import NotFound, UnprocessableEntity, HTTPException fake = Faker() @@ -39,7 +37,7 @@ fake_time_entry.update(valid_time_entry_input) -def test_create_time_entry_with_invalid_date_range_should_raise_bad_request_error( +def test_create_time_entry_with_invalid_date_range_should_raise_bad_request( client: FlaskClient, mocker: MockFixture, valid_header: dict ): from time_tracker_api.time_entries.time_entries_namespace import ( @@ -65,7 +63,7 @@ def test_create_time_entry_with_invalid_date_range_should_raise_bad_request_erro repository_container_create_item_mock.assert_not_called() -def test_create_time_entry_with_end_date_in_future_should_raise_bad_request_error( +def test_create_time_entry_with_end_date_in_future_should_raise_bad_request( client: FlaskClient, mocker: MockFixture, valid_header: dict ): from time_tracker_api.time_entries.time_entries_namespace import ( @@ -182,59 +180,64 @@ def test_get_time_entry_should_succeed_with_valid_id( dao_get_mock.assert_called_once_with(str(valid_id)) -def test_get_time_entry_should_response_with_unprocessable_entity_for_invalid_id_format( - client: FlaskClient, mocker: MockFixture, valid_header: dict +@pytest.mark.parametrize( + 'http_exception,http_status', + [ + (NotFound, HTTPStatus.NOT_FOUND), + (UnprocessableEntity, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_time_entry_raise_http_exception( + client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + valid_id: str, + http_exception: HTTPException, + http_status: tuple, ): from time_tracker_api.time_entries.time_entries_namespace import ( time_entries_dao, ) - from werkzeug.exceptions import UnprocessableEntity - invalid_id = fake.word() - - repository_find_mock = mocker.patch.object( - time_entries_dao.repository, 'find', side_effect=UnprocessableEntity - ) + time_entries_dao.repository.find = Mock(side_effect=http_exception) response = client.get( - "/time-entries/%s" % invalid_id, + f"/time-entries/{valid_id}", headers=valid_header, follow_redirects=True, ) - assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_find_mock.assert_called_once_with( - str(invalid_id), ANY, peeker=ANY - ) + assert http_status == response.status_code + time_entries_dao.repository.find.assert_called_once_with(valid_id, ANY) -def test_update_time_entry_should_succeed_with_valid_data( - client: FlaskClient, mocker: MockFixture, valid_header: dict +def test_update_time_entry_calls_partial_update_with_incoming_payload( + client: FlaskClient, mocker: MockFixture, valid_header: dict, valid_id: str ): from time_tracker_api.time_entries.time_entries_namespace import ( time_entries_dao, ) - repository_update_mock = mocker.patch.object( - time_entries_dao.repository, - 'partial_update', - return_value=fake_time_entry, - ) + time_entries_dao.repository.partial_update = Mock(return_value={}) + + time_entries_dao.repository.find = Mock(return_value={}) + time_entries_dao.check_whether_current_user_owns_item = Mock() - valid_id = fake.random_int(1, 9999) response = client.put( - "/time-entries/%s" % valid_id, + f'/time-entries/{valid_id}', headers=valid_header, json=valid_time_entry_input, follow_redirects=True, ) 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, ANY, peeker=ANY + time_entries_dao.repository.partial_update.assert_called_once_with( + valid_id, valid_time_entry_input, ANY ) + time_entries_dao.repository.find.assert_called_once() + time_entries_dao.check_whether_current_user_owns_item.assert_called_once() + def test_update_time_entry_should_reject_bad_request( client: FlaskClient, mocker: MockFixture, valid_header: dict @@ -245,7 +248,7 @@ def test_update_time_entry_should_reject_bad_request( invalid_time_entry_data = valid_time_entry_input.copy() invalid_time_entry_data.update( - {"project_id": fake.pyint(min_value=1, max_value=100),} + {"project_id": fake.pyint(min_value=1, max_value=100)} ) repository_update_mock = mocker.patch.object( time_entries_dao.repository, 'update', return_value=fake_time_entry @@ -263,218 +266,204 @@ def test_update_time_entry_should_reject_bad_request( repository_update_mock.assert_not_called() -def test_update_time_entry_should_return_not_found_with_invalid_id( - client: FlaskClient, mocker: MockFixture, valid_header: dict +def test_update_time_entry_raise_not_found( + client: FlaskClient, mocker: MockFixture, valid_header: dict, valid_id: str ): 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, 'partial_update', side_effect=NotFound - ) - invalid_id = fake.random_int(1, 9999) + time_entries_dao.repository.partial_update = Mock(side_effect=NotFound) + + time_entries_dao.repository.find = Mock(return_value={}) + time_entries_dao.check_whether_current_user_owns_item = Mock() response = client.put( - "/time-entries/%s" % invalid_id, + f'/time-entries/{valid_id}', headers=valid_header, json=valid_time_entry_input, follow_redirects=True, ) assert HTTPStatus.NOT_FOUND == response.status_code - repository_update_mock.assert_called_once_with( - str(invalid_id), valid_time_entry_input, ANY, peeker=ANY + time_entries_dao.repository.partial_update.assert_called_once_with( + valid_id, valid_time_entry_input, ANY ) + time_entries_dao.repository.find.assert_called_once() + time_entries_dao.check_whether_current_user_owns_item.assert_called_once() -def test_delete_time_entry_should_succeed_with_valid_id( - client: FlaskClient, mocker: MockFixture, valid_header: dict + +def test_delete_time_entry_calls_delete( + client: FlaskClient, mocker: MockFixture, valid_header: dict, valid_id: str ): from time_tracker_api.time_entries.time_entries_namespace import ( time_entries_dao, ) - repository_remove_mock = mocker.patch.object( - time_entries_dao.repository, 'delete', return_value=None - ) - valid_id = fake.random_int(1, 9999) - + time_entries_dao.repository.delete = Mock(return_value=None) + time_entries_dao.repository.find = Mock() + time_entries_dao.check_whether_current_user_owns_item = Mock() response = client.delete( - "/time-entries/%s" % valid_id, + f'/time-entries/{valid_id}', headers=valid_header, follow_redirects=True, ) assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data - repository_remove_mock.assert_called_once_with( - str(valid_id), ANY, peeker=ANY - ) + time_entries_dao.repository.delete.assert_called_once_with(valid_id, ANY) + time_entries_dao.repository.find.assert_called_once() + time_entries_dao.check_whether_current_user_owns_item.assert_called_once() -def test_delete_time_entry_should_return_not_found_with_invalid_id( - client: FlaskClient, mocker: MockFixture, valid_header: dict +@pytest.mark.parametrize( + 'http_exception,http_status', + [ + (NotFound, HTTPStatus.NOT_FOUND), + (UnprocessableEntity, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_delete_time_entry_raise_http_exception( + client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + valid_id: str, + http_exception: HTTPException, + http_status: tuple, ): 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, 'delete', side_effect=NotFound - ) - invalid_id = fake.random_int(1, 9999) + time_entries_dao.repository.delete = Mock(side_effect=http_exception) + time_entries_dao.repository.find = Mock() + time_entries_dao.check_whether_current_user_owns_item = Mock() response = client.delete( - "/time-entries/%s" % invalid_id, + f"/time-entries/{valid_id}", headers=valid_header, follow_redirects=True, ) - assert HTTPStatus.NOT_FOUND == response.status_code - repository_remove_mock.assert_called_once_with( - str(invalid_id), ANY, peeker=ANY - ) + assert http_status == response.status_code + time_entries_dao.repository.delete.assert_called_once_with(valid_id, ANY) + time_entries_dao.repository.find.assert_called_once() + time_entries_dao.check_whether_current_user_owns_item.assert_called_once() -def test_delete_time_entry_should_return_unprocessable_entity_for_invalid_id_format( - client: FlaskClient, mocker: MockFixture, valid_header: dict +def test_stop_time_entry_calls_partial_update( + client: FlaskClient, mocker: MockFixture, valid_header: dict, valid_id: str ): 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, 'delete', side_effect=UnprocessableEntity - ) - invalid_id = fake.word() - response = client.delete( - "/time-entries/%s" % invalid_id, - headers=valid_header, - follow_redirects=True, - ) - - assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_remove_mock.assert_called_once_with( - str(invalid_id), ANY, peeker=ANY - ) - - -def test_stop_time_entry_with_valid_id( - client: FlaskClient, mocker: MockFixture, valid_header: dict -): - from time_tracker_api.time_entries.time_entries_namespace import ( - time_entries_dao, - ) + time_entries_dao.repository.partial_update = Mock(return_value={}) - repository_update_mock = mocker.patch.object( - time_entries_dao.repository, - 'partial_update', - return_value=fake_time_entry, - ) - valid_id = fake.random_int(1, 9999) + time_entries_dao.repository.find = Mock(return_value={}) + time_entries_dao.check_time_entry_is_not_stopped = Mock() + time_entries_dao.check_whether_current_user_owns_item = Mock() response = client.post( - "/time-entries/%s/stop" % valid_id, + f'/time-entries/{valid_id}/stop', headers=valid_header, follow_redirects=True, ) assert HTTPStatus.OK == response.status_code - repository_update_mock.assert_called_once_with( - str(valid_id), - {"end_date": mocker.ANY}, - ANY, - peeker=TimeEntriesCosmosDBDao.checks_owner_and_is_not_stopped, + time_entries_dao.repository.partial_update.assert_called_once_with( + valid_id, {"end_date": ANY}, ANY ) + time_entries_dao.check_time_entry_is_not_stopped.assert_called_once() + time_entries_dao.check_whether_current_user_owns_item.assert_called_once() -def test_stop_time_entry_with_id_with_invalid_format( - client: FlaskClient, mocker: MockFixture, valid_header: dict +def test_stop_time_entry_raise_unprocessable_entity( + client: FlaskClient, mocker: MockFixture, valid_header: dict, valid_id: str ): 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, - 'partial_update', - side_effect=UnprocessableEntity, + time_entries_dao.repository.partial_update = Mock( + side_effect=UnprocessableEntity ) - invalid_id = fake.word() + time_entries_dao.repository.find = Mock(return_value={}) + time_entries_dao.check_time_entry_is_not_stopped = Mock() + time_entries_dao.check_whether_current_user_owns_item = Mock() response = client.post( - "/time-entries/%s/stop" % invalid_id, + f'/time-entries/{valid_id}/stop', headers=valid_header, follow_redirects=True, ) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_update_mock.assert_called_once_with( - invalid_id, - {"end_date": ANY}, - ANY, - peeker=TimeEntriesCosmosDBDao.checks_owner_and_is_not_stopped, + time_entries_dao.repository.partial_update.assert_called_once_with( + valid_id, {"end_date": ANY}, ANY ) + time_entries_dao.check_whether_current_user_owns_item.assert_called_once() + time_entries_dao.check_time_entry_is_not_stopped.assert_called_once() -def test_restart_time_entry_with_valid_id( - client: FlaskClient, mocker: MockFixture, valid_header: dict +def test_restart_time_entry_calls_partial_update( + client: FlaskClient, mocker: MockFixture, valid_header: dict, valid_id: str ): from time_tracker_api.time_entries.time_entries_namespace import ( time_entries_dao, ) - repository_update_mock = mocker.patch.object( - time_entries_dao.repository, - 'partial_update', - return_value=fake_time_entry, - ) - valid_id = fake.random_int(1, 9999) + time_entries_dao.repository.partial_update = Mock(return_value={}) + + time_entries_dao.repository.find = Mock(return_value={}) + time_entries_dao.check_time_entry_is_not_started = Mock() + time_entries_dao.check_whether_current_user_owns_item = Mock() response = client.post( - "/time-entries/%s/restart" % valid_id, + f'/time-entries/{valid_id}/restart', headers=valid_header, follow_redirects=True, ) assert HTTPStatus.OK == response.status_code - repository_update_mock.assert_called_once_with( - str(valid_id), {"end_date": None}, ANY, peeker=ANY + time_entries_dao.repository.partial_update.assert_called_once_with( + valid_id, {"end_date": None}, ANY ) + time_entries_dao.check_time_entry_is_not_started.assert_called_once() + time_entries_dao.check_whether_current_user_owns_item.assert_called_once() -def test_restart_time_entry_with_id_with_invalid_format( - client: FlaskClient, mocker: MockFixture, valid_header: dict +def test_restart_time_entry_raise_unprocessable_entity( + client: FlaskClient, mocker: MockFixture, valid_header: dict, valid_id: str ): 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, - 'partial_update', - side_effect=UnprocessableEntity, - peeker=ANY, + time_entries_dao.repository.partial_update = Mock( + side_effect=UnprocessableEntity ) - invalid_id = fake.word() + + time_entries_dao.repository.find = Mock(return_value={}) + time_entries_dao.check_time_entry_is_not_started = Mock() + time_entries_dao.check_whether_current_user_owns_item = Mock() response = client.post( - "/time-entries/%s/restart" % invalid_id, + f'/time-entries/{valid_id}/restart', headers=valid_header, follow_redirects=True, ) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_update_mock.assert_called_once_with( - invalid_id, {"end_date": None}, ANY, peeker=ANY + time_entries_dao.repository.partial_update.assert_called_once_with( + valid_id, {"end_date": None}, ANY ) + time_entries_dao.check_time_entry_is_not_started.assert_called_once() + time_entries_dao.check_whether_current_user_owns_item.assert_called_once() def test_get_running_should_call_find_running( @@ -503,7 +492,7 @@ def test_get_running_should_call_find_running( repository_update_mock.assert_called_once_with(tenant_id, owner_id) -def test_get_running_should_return_not_found_if_find_running_throws_StopIteration( +def test_get_running_should_return_not_found_if_StopIteration( client: FlaskClient, mocker: MockFixture, valid_header: dict, diff --git a/time_tracker_api/projects/custom_modules/utils.py b/time_tracker_api/projects/custom_modules/utils.py deleted file mode 100644 index 1d4ba1bb..00000000 --- a/time_tracker_api/projects/custom_modules/utils.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO this must be refactored to be used from the utils module ↓ -# Also check if we can change this using the overwritten __add__ method - - -def add_customer_name_to_projects(projects, customers): - for project in projects: - for customer in customers: - if project.customer_id == customer.id: - setattr(project, 'customer_name', customer.name) diff --git a/time_tracker_api/projects/projects_model.py b/time_tracker_api/projects/projects_model.py index 54d7c0ba..62cba129 100644 --- a/time_tracker_api/projects/projects_model.py +++ b/time_tracker_api/projects/projects_model.py @@ -1,13 +1,18 @@ from dataclasses import dataclass from azure.cosmos import PartitionKey -from commons.data_access_layer.cosmos_db import CosmosDBModel, CosmosDBDao, CosmosDBRepository +from commons.data_access_layer.cosmos_db import ( + CosmosDBModel, + CosmosDBDao, + CosmosDBRepository, +) from time_tracker_api.database import CRUDDao, APICosmosDBDao -from time_tracker_api.customers.customers_model import create_dao as customers_create_dao +from time_tracker_api.customers.customers_model import ( + create_dao as customers_create_dao, +) from time_tracker_api.customers.customers_model import CustomerCosmosDBModel -from time_tracker_api.projects.custom_modules.utils import ( - add_customer_name_to_projects -) +from utils.extend_model import add_customer_name_to_projects + class ProjectDao(CRUDDao): pass @@ -17,10 +22,8 @@ class ProjectDao(CRUDDao): 'id': 'project', 'partition_key': PartitionKey(path='/tenant_id'), 'unique_key_policy': { - 'uniqueKeys': [ - {'paths': ['/name', '/customer_id', '/deleted']}, - ] - } + 'uniqueKeys': [{'paths': ['/name', '/customer_id', '/deleted']},] + }, } @@ -36,7 +39,7 @@ class ProjectCosmosDBModel(CosmosDBModel): technologies: list def __init__(self, data): - super(ProjectCosmosDBModel, self).__init__(data) # pragma: no cover + super(ProjectCosmosDBModel, self).__init__(data) # pragma: no cover def __contains__(self, item): if type(item) is CustomerCosmosDBModel: @@ -53,9 +56,12 @@ def __str___(self): class ProjectCosmosDBRepository(CosmosDBRepository): def __init__(self): - CosmosDBRepository.__init__(self, container_id=container_definition['id'], - partition_key_attribute='tenant_id', - mapper=ProjectCosmosDBModel) + CosmosDBRepository.__init__( + self, + container_id=container_definition['id'], + partition_key_attribute='tenant_id', + mapper=ProjectCosmosDBModel, + ) class ProjectCosmosDBDao(APICosmosDBDao, ProjectDao): @@ -75,12 +81,14 @@ def get_all(self, conditions: dict = None, **kwargs) -> list: customers_id = [customer.id for customer in customers] conditions = conditions if conditions else {} - custom_condition = "c.customer_id IN {}".format(str(tuple(customers_id))) + custom_condition = "c.customer_id IN {}".format( + str(tuple(customers_id)) + ) # TODO this must be refactored to be used from the utils module ↑ if "custom_sql_conditions" in kwargs: kwargs["custom_sql_conditions"].append(custom_condition) else: - kwargs["custom_sql_conditions"] = [custom_condition] + kwargs["custom_sql_conditions"] = [custom_condition] projects = self.repository.find_all(event_ctx, conditions, **kwargs) add_customer_name_to_projects(projects, customers) diff --git a/time_tracker_api/time_entries/custom_modules/utils.py b/time_tracker_api/time_entries/custom_modules/utils.py deleted file mode 100644 index e2ed09d5..00000000 --- a/time_tracker_api/time_entries/custom_modules/utils.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO this must be refactored to be used from the utils module ↓ -# Also check if we can improve this by using the overwritten __add__ method - - -def add_project_name_to_time_entries(time_entries, projects): - for time_entry in time_entries: - for project in projects: - if time_entry.project_id == project.id: - setattr(time_entry, 'project_name', project.name) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index e2bcdc19..644e3834 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -17,11 +17,10 @@ ) from commons.data_access_layer.database import EventContext -from time_tracker_api.time_entries.custom_modules import worked_time -from time_tracker_api.time_entries.custom_modules.utils import ( - add_project_name_to_time_entries, -) -from time_tracker_api.projects.projects_model import ProjectCosmosDBModel, create_dao as project_create_dao +from utils.extend_model import add_project_name_to_time_entries +from utils import worked_time + +from time_tracker_api.projects.projects_model import ProjectCosmosDBModel from time_tracker_api.projects import projects_model from time_tracker_api.database import CRUDDao, APICosmosDBDao from time_tracker_api.security import current_user_id @@ -142,11 +141,17 @@ def find_all( if time_entries: projects_id = [project.project_id for project in time_entries] - p_ids = str(tuple(projects_id)).replace(",", "") if len(projects_id) == 1 else str(tuple(projects_id)) + p_ids = ( + str(tuple(projects_id)).replace(",", "") + if len(projects_id) == 1 + else str(tuple(projects_id)) + ) custom_conditions = "c.id IN {}".format(p_ids) # TODO this must be refactored to be used from the utils module ↑ project_dao = projects_model.create_dao() - projects = project_dao.get_all(custom_sql_conditions=[custom_conditions]) + projects = project_dao.get_all( + custom_sql_conditions=[custom_conditions] + ) add_project_name_to_time_entries(time_entries, projects) return time_entries @@ -270,32 +275,25 @@ class TimeEntriesCosmosDBDao(APICosmosDBDao, TimeEntriesDao): def __init__(self, repository): CosmosDBDao.__init__(self, repository) - @classmethod - def check_whether_current_user_owns_item(cls, data: dict): + def check_whether_current_user_owns_item(self, data): if ( - data.get('owner_id') is not None - and data.get('owner_id') != cls.current_user_id() + data.owner_id is not None + and data.owner_id != self.current_user_id() ): raise CustomError( HTTPStatus.FORBIDDEN, "The current user is not the owner of this time entry", ) - @classmethod - def checks_owner_and_is_not_stopped(cls, data: dict): - cls.check_whether_current_user_owns_item(data) - - if data.get('end_date') is not None: + def check_time_entry_is_not_stopped(self, data): + if data.end_date is not None: raise CustomError( HTTPStatus.UNPROCESSABLE_ENTITY, "The specified time entry is already stopped", ) - @classmethod - def checks_owner_and_is_not_started(cls, data: dict): - cls.check_whether_current_user_owns_item(data) - - if data.get('end_date') is None: + def check_time_entry_is_not_started(self, data): + if data.end_date is None: raise CustomError( HTTPStatus.UNPROCESSABLE_ENTITY, "The specified time entry is already running", @@ -306,13 +304,15 @@ def get_all(self, conditions: dict = None, **kwargs) -> list: conditions.update({"owner_id": event_ctx.user_id}) date_range = self.handle_date_filter_args(args=conditions) - return self.repository.find_all(event_ctx, conditions=conditions, date_range=date_range) + return self.repository.find_all( + event_ctx, conditions=conditions, date_range=date_range + ) def get(self, id): event_ctx = self.create_event_context("read") - time_entry = self.repository.find( - id, event_ctx, peeker=self.check_whether_current_user_owns_item - ) + + time_entry = self.repository.find(id, event_ctx) + self.check_whether_current_user_owns_item(time_entry) project_dao = projects_model.create_dao() project = project_dao.get(time_entry.project_id) @@ -326,35 +326,40 @@ def create(self, data: dict): def update(self, id, data: dict, description=None): event_ctx = self.create_event_context("update", description) - return self.repository.partial_update( - id, - data, - event_ctx, - peeker=self.check_whether_current_user_owns_item, - ) + + time_entry = self.repository.find(id, event_ctx) + self.check_whether_current_user_owns_item(time_entry) + + return self.repository.partial_update(id, data, event_ctx,) def stop(self, id): event_ctx = self.create_event_context("update", "Stop time entry") + + time_entry = self.repository.find(id, event_ctx) + self.check_whether_current_user_owns_item(time_entry) + self.check_time_entry_is_not_stopped(time_entry) + return self.repository.partial_update( - id, - {'end_date': current_datetime_str()}, - event_ctx, - peeker=self.checks_owner_and_is_not_stopped, + id, {'end_date': current_datetime_str()}, event_ctx, ) def restart(self, id): event_ctx = self.create_event_context("update", "Restart time entry") + + time_entry = self.repository.find(id, event_ctx) + self.check_whether_current_user_owns_item(time_entry) + self.check_time_entry_is_not_started(time_entry) + return self.repository.partial_update( - id, - {'end_date': None}, - event_ctx, - peeker=self.checks_owner_and_is_not_started, + id, {'end_date': None}, event_ctx, ) def delete(self, id): event_ctx = self.create_event_context("delete") + time_entry = self.repository.find(id, event_ctx) + self.check_whether_current_user_owns_item(time_entry) self.repository.delete( - id, event_ctx, peeker=self.check_whether_current_user_owns_item + id, event_ctx, ) def find_running(self): @@ -397,7 +402,9 @@ def handle_date_filter_args(args: dict) -> dict: else: month = get_current_month() year = get_current_year() - return date_range if date_range else get_date_range_of_month(year, month) + return ( + date_range if date_range else get_date_range_of_month(year, month) + ) def create_dao() -> TimeEntriesDao: diff --git a/utils/extend_model.py b/utils/extend_model.py new file mode 100644 index 00000000..cd33cc5b --- /dev/null +++ b/utils/extend_model.py @@ -0,0 +1,28 @@ +def add_customer_name_to_projects(projects, customers): + """ + Add attribute customer_name in project model, based on customer_id of the + project + :param (list) projects: projects retrieved from project repository + :param (list) customers: customers retrieved from customer repository + + TODO : check if we can improve this by using the overwritten __add__ method + """ + for project in projects: + for customer in customers: + if project.customer_id == customer.id: + setattr(project, 'customer_name', customer.name) + + +def add_project_name_to_time_entries(time_entries, projects): + """ + Add attribute project_name in time-entry model, based on project_id of the + time_entry + :param (list) time_entries: time_entries retrieved from time-entry repository + :param (list) projects: projects retrieved from project repository + + TODO : check if we can improve this by using the overwritten __add__ method + """ + for time_entry in time_entries: + for project in projects: + if time_entry.project_id == project.id: + setattr(time_entry, 'project_name', project.name) diff --git a/time_tracker_api/time_entries/custom_modules/worked_time.py b/utils/worked_time.py similarity index 94% rename from time_tracker_api/time_entries/custom_modules/worked_time.py rename to utils/worked_time.py index 29475b63..2b7e0862 100644 --- a/time_tracker_api/time_entries/custom_modules/worked_time.py +++ b/utils/worked_time.py @@ -4,12 +4,17 @@ current_datetime_str, datetime_str, get_current_month, - get_current_year + get_current_year, ) def start_datetime_of_current_month() -> datetime: - return datetime(year=get_current_year(), month=get_current_month(), day=1, tzinfo=timezone.utc) + return datetime( + year=get_current_year(), + month=get_current_month(), + day=1, + tzinfo=timezone.utc, + ) def start_datetime_of_current_week() -> datetime: @@ -33,7 +38,9 @@ def str_to_datetime( value: str, conversion_format: str = '%Y-%m-%dT%H:%M:%S.%fZ' ) -> datetime: if 'Z' in value: - return datetime.strptime(value, conversion_format).astimezone(timezone.utc) + return datetime.strptime(value, conversion_format).astimezone( + timezone.utc + ) else: return datetime.fromisoformat(value).astimezone(timezone.utc) From d3bc0152b53bd331e4d2d0f6d071ffceb8a4bcc9 Mon Sep 17 00:00:00 2001 From: Dickson Armijos Date: Thu, 21 May 2020 18:28:42 -0500 Subject: [PATCH 005/287] fix: Get all time-entries as admin user #122 --- time_tracker_api/time_entries/time_entries_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 644e3834..ff7c8280 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -140,7 +140,7 @@ def find_all( ) if time_entries: - projects_id = [project.project_id for project in time_entries] + projects_id = [str(project.project_id) for project in time_entries] p_ids = ( str(tuple(projects_id)).replace(",", "") if len(projects_id) == 1 From 58dc55ee633fec743d1bdee4e18f00f492db1c6b Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 22 May 2020 01:21:07 +0000 Subject: [PATCH 006/287] 0.14.1 Automatically generated by python-semantic-release --- time_tracker_api/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time_tracker_api/version.py b/time_tracker_api/version.py index ef919940..092052c1 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.14.0' +__version__ = '0.14.1' From a2758de96c1d9a2246346faa9b336c74df8abdae Mon Sep 17 00:00:00 2001 From: roberto Date: Wed, 27 May 2020 12:41:31 -0500 Subject: [PATCH 007/287] fix(time-entries): :goal_net: stop time-entry if was left running --- commons/data_access_layer/cosmos_db.py | 4 ++ .../time_entries_namespace_test.py | 1 + .../time_entries/time_entries_model.py | 40 ++++++++++++++++++- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index b8af2cbd..859df9b6 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -408,6 +408,10 @@ def get_current_month() -> int: return datetime.now().month +def get_current_day() -> int: + return datetime.now().day + + def get_date_range_of_month(year: int, month: int) -> Dict[str, str]: first_day_of_month = 1 start_date = datetime( 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 13fd9268..f79ca63b 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 @@ -482,6 +482,7 @@ def test_get_running_should_call_find_running( 'find_running', return_value=fake_time_entry, ) + time_entries_dao.stop_time_entry_if_was_left_running = Mock() response = client.get( "/time-entries/running", headers=valid_header, follow_redirects=True diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index ff7c8280..97132aff 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -3,6 +3,8 @@ from typing import List, Callable from azure.cosmos import PartitionKey +from azure.cosmos.exceptions import CosmosResourceNotFoundError + from flask_restplus._http import HTTPStatus from commons.data_access_layer.cosmos_db import ( @@ -11,14 +13,17 @@ CustomError, CosmosDBModel, current_datetime_str, + datetime_str, get_date_range_of_month, get_current_year, get_current_month, + get_current_day, ) from commons.data_access_layer.database import EventContext from utils.extend_model import add_project_name_to_time_entries from utils import worked_time +from utils.worked_time import str_to_datetime from time_tracker_api.projects.projects_model import ProjectCosmosDBModel from time_tracker_api.projects import projects_model @@ -74,6 +79,22 @@ def __init__(self, data): # pragma: no cover def running(self): return self.end_date is None + @property + def was_left_running(self) -> bool: + start_date = str_to_datetime(self.start_date) + return ( + get_current_day() > start_date.day + or get_current_month() > start_date.month + or get_current_year() > start_date.year + ) + + @property + def start_date_at_midnight(self) -> str: + start_date = str_to_datetime(self.start_date) + return datetime_str( + start_date.replace(hour=23, minute=59, second=59, microsecond=0) + ) + def __add__(self, other): if type(other) is ProjectCosmosDBModel: time_entry = self.__class__ @@ -299,6 +320,21 @@ def check_time_entry_is_not_started(self, data): "The specified time entry is already running", ) + def stop_time_entry_if_was_left_running( + self, time_entry: TimeEntryCosmosDBModel + ): + + if time_entry.was_left_running: + end_date = time_entry.start_date_at_midnight + event_ctx = self.create_event_context( + "update", "Stop time-entry that was left running" + ) + + self.repository.partial_update( + time_entry.id, {'end_date': end_date}, event_ctx + ) + raise CosmosResourceNotFoundError() + def get_all(self, conditions: dict = None, **kwargs) -> list: event_ctx = self.create_event_context("read-many") conditions.update({"owner_id": event_ctx.user_id}) @@ -364,9 +400,11 @@ def delete(self, id): def find_running(self): event_ctx = self.create_event_context("find_running") - return self.repository.find_running( + time_entry = self.repository.find_running( event_ctx.tenant_id, event_ctx.user_id ) + self.stop_time_entry_if_was_left_running(time_entry) + return time_entry def get_worked_time(self, conditions: dict = {}): event_ctx = self.create_event_context( From f7a612ac601b6764628e746232b18c9759152d5f Mon Sep 17 00:00:00 2001 From: semantic-release Date: Thu, 28 May 2020 15:13:54 +0000 Subject: [PATCH 008/287] 0.14.2 Automatically generated by python-semantic-release --- time_tracker_api/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time_tracker_api/version.py b/time_tracker_api/version.py index 092052c1..c41af0b6 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.14.1' +__version__ = '0.14.2' From f87c7977d55b88199cc71c9cfaf20344c163ed24 Mon Sep 17 00:00:00 2001 From: Dickson Armijos Date: Thu, 28 May 2020 11:30:29 -0500 Subject: [PATCH 009/287] feat: Add user_id attribute --- .../time_entries/time_entries_namespace.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index 53aea216..f1feccf4 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -136,6 +136,14 @@ ) # custom attributes filter +attributes_filter.add_argument( + 'user_id', + required=False, + store_missing=False, + help="(Filter) User to filter by", + location='args', +) + attributes_filter.add_argument( 'month', required=False, @@ -143,6 +151,7 @@ help="(Filter) Month to filter by", location='args', ) + attributes_filter.add_argument( 'year', required=False, @@ -158,6 +167,7 @@ help="(Filter) Start to filter by", location='args', ) + attributes_filter.add_argument( 'end_date', required=False, From 83350eb5e0cdb669d5532b9b4faff6753bf5d6de Mon Sep 17 00:00:00 2001 From: Dickson Armijos Date: Fri, 29 May 2020 14:24:45 -0500 Subject: [PATCH 010/287] feat: Refactor function --- .../time_entries/time_entries_model.py | 13 +++++-------- utils/extend_model.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index ff7c8280..baa226e0 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -19,6 +19,7 @@ from utils.extend_model import add_project_name_to_time_entries from utils import worked_time +from utils.extend_model import create_in_condition from time_tracker_api.projects.projects_model import ProjectCosmosDBModel from time_tracker_api.projects import projects_model @@ -140,14 +141,8 @@ def find_all( ) if time_entries: - projects_id = [str(project.project_id) for project in time_entries] - p_ids = ( - str(tuple(projects_id)).replace(",", "") - if len(projects_id) == 1 - else str(tuple(projects_id)) - ) - custom_conditions = "c.id IN {}".format(p_ids) - # TODO this must be refactored to be used from the utils module ↑ + custom_conditions = create_in_condition(time_entries, "project_id") + project_dao = projects_model.create_dao() projects = project_dao.get_all( custom_sql_conditions=[custom_conditions] @@ -384,6 +379,8 @@ def get_worked_time(self, conditions: dict = {}): @staticmethod def handle_date_filter_args(args: dict) -> dict: date_range = None + year = None + month = None if 'month' and 'year' in args: month = int(args.get("month")) year = int(args.get("year")) diff --git a/utils/extend_model.py b/utils/extend_model.py index cd33cc5b..f6224172 100644 --- a/utils/extend_model.py +++ b/utils/extend_model.py @@ -1,3 +1,6 @@ +import re + + def add_customer_name_to_projects(projects, customers): """ Add attribute customer_name in project model, based on customer_id of the @@ -26,3 +29,14 @@ def add_project_name_to_time_entries(time_entries, projects): for project in projects: if time_entry.project_id == project.id: setattr(time_entry, 'project_name', project.name) + + +def create_in_condition(data_object, attr_to_filter, first_attr="c.id"): + attr_filter = re.sub('[^a-zA-Z_$0-9]', '', attr_to_filter) + object_id = [str(eval(f"object.{attr_filter}")) for object in data_object] + ids = ( + str(tuple(object_id)).replace(",", "") + if len(object_id) == 1 + else str(tuple(object_id)) + ) + return "{} IN {}".format(first_attr, ids) From 4dc956eeeed46d44321f68e002d6ed51eb689577 Mon Sep 17 00:00:00 2001 From: Rene Enriquez Date: Mon, 1 Jun 2020 10:29:11 -0500 Subject: [PATCH 011/287] fix: #156 including activity name as part of the entries model --- time_tracker_api/time_entries/time_entries_model.py | 7 ++++++- time_tracker_api/time_entries/time_entries_namespace.py | 7 +++++++ utils/extend_model.py | 7 +++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 97132aff..6617fdc7 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -20,8 +20,9 @@ get_current_day, ) from commons.data_access_layer.database import EventContext +from time_tracker_api.activities import activities_model -from utils.extend_model import add_project_name_to_time_entries +from utils.extend_model import add_project_name_to_time_entries, add_activity_name_to_time_entries from utils import worked_time from utils.worked_time import str_to_datetime @@ -174,6 +175,10 @@ def find_all( custom_sql_conditions=[custom_conditions] ) add_project_name_to_time_entries(time_entries, projects) + + activity_dao = activities_model.create_dao() + activities = activity_dao.get_all() + add_activity_name_to_time_entries(time_entries, activities) return time_entries def on_create(self, new_item_data: dict, event_context: EventContext): diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index 53aea216..8275e41d 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -122,6 +122,13 @@ description='Name of the project where time-entry was registered', example=faker.word(['mobile app', 'web app']), ), + 'activity_name': fields.String( + required=False, + title='Activity Name', + max_length=50, + description='Name of the activity associated with the time-entry', + example=faker.word(['development', 'QA']), + ), } time_entry_response_fields.update(common_fields) diff --git a/utils/extend_model.py b/utils/extend_model.py index cd33cc5b..25a79b2a 100644 --- a/utils/extend_model.py +++ b/utils/extend_model.py @@ -26,3 +26,10 @@ def add_project_name_to_time_entries(time_entries, projects): for project in projects: if time_entry.project_id == project.id: setattr(time_entry, 'project_name', project.name) + + +def add_activity_name_to_time_entries(time_entries, activities): + for time_entry in time_entries: + for activity in activities: + if time_entry.activity_id == activity.id: + setattr(time_entry, 'activity_name', activity.name) \ No newline at end of file From 3de88072dfb5be8725ae679cd97b1fb3e3967e2f Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 1 Jun 2020 16:30:13 +0000 Subject: [PATCH 012/287] 0.14.3 Automatically generated by python-semantic-release --- time_tracker_api/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time_tracker_api/version.py b/time_tracker_api/version.py index c41af0b6..62ea085b 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.14.2' +__version__ = '0.14.3' From 64dacf0a96698da4dcbca7aeda6e1cd3f7d396f7 Mon Sep 17 00:00:00 2001 From: Rene Enriquez Date: Mon, 1 Jun 2020 11:39:06 -0500 Subject: [PATCH 013/287] fix: #160 disabling automatic clock-outs --- time_tracker_api/time_entries/time_entries_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 6617fdc7..06fb4578 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -408,7 +408,7 @@ def find_running(self): time_entry = self.repository.find_running( event_ctx.tenant_id, event_ctx.user_id ) - self.stop_time_entry_if_was_left_running(time_entry) + # self.stop_time_entry_if_was_left_running(time_entry) return time_entry def get_worked_time(self, conditions: dict = {}): From e386e3790bbb0ec50fee5212b9ba1fc9a35076db Mon Sep 17 00:00:00 2001 From: Rene Enriquez Date: Mon, 1 Jun 2020 12:13:06 -0500 Subject: [PATCH 014/287] fix: #160 disabling automatic clock-outs --- time_tracker_api/time_entries/time_entries_model.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 06fb4578..5e917405 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -408,6 +408,11 @@ def find_running(self): time_entry = self.repository.find_running( event_ctx.tenant_id, event_ctx.user_id ) + # TODO: we need to make this work using the users time zone + # This is disabled as part of https://github.com/ioet/time-tracker-backend/issues/160 + # Remove all these comments after implementing + # https://github.com/ioet/time-tracker-backend/issues/159 + # https://github.com/ioet/time-tracker-backend/issues/162 # self.stop_time_entry_if_was_left_running(time_entry) return time_entry From 65055600238b07b17b20d297b2277a5f294c2282 Mon Sep 17 00:00:00 2001 From: Dickson Armijos Date: Tue, 2 Jun 2020 09:35:43 -0500 Subject: [PATCH 015/287] feat: Add new utils --- utils/extend_model.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/utils/extend_model.py b/utils/extend_model.py index f6224172..ecffc3bc 100644 --- a/utils/extend_model.py +++ b/utils/extend_model.py @@ -31,12 +31,27 @@ def add_project_name_to_time_entries(time_entries, projects): setattr(time_entry, 'project_name', project.name) -def create_in_condition(data_object, attr_to_filter, first_attr="c.id"): +def create_in_condition(data_object, attr_to_filter="", first_attr="c.id"): attr_filter = re.sub('[^a-zA-Z_$0-9]', '', attr_to_filter) - object_id = [str(eval(f"object.{attr_filter}")) for object in data_object] + object_id = ( + [str(i) for i in data_object] + if type(data_object[0]) == str + else [str(eval(f"object.{attr_filter}")) for object in data_object] + ) ids = ( str(tuple(object_id)).replace(",", "") if len(object_id) == 1 else str(tuple(object_id)) ) return "{} IN {}".format(first_attr, ids) + + +def create_custom_query_from_str( + data: str, first_attr, delimiter: str = "," +) -> str: + data = data.split(delimiter) + if len(data) > 1: + query_str = create_in_condition(data, first_attr=first_attr) + else: + query_str = "{} = '{}'".format(first_attr, data[0]) + return query_str From c80d2a46835caef04a13706abbead833eb7226bd Mon Sep 17 00:00:00 2001 From: Dickson Armijos Date: Tue, 2 Jun 2020 10:11:24 -0500 Subject: [PATCH 016/287] feat: Add documentation to util functions --- utils/extend_model.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/utils/extend_model.py b/utils/extend_model.py index ecffc3bc..450c1517 100644 --- a/utils/extend_model.py +++ b/utils/extend_model.py @@ -31,7 +31,16 @@ def add_project_name_to_time_entries(time_entries, projects): setattr(time_entry, 'project_name', project.name) -def create_in_condition(data_object, attr_to_filter="", first_attr="c.id"): +def create_in_condition( + data_object: list, attr_to_filter: str = "", first_attr: str = "c.id" +): + """ + Function to create a custom query string from a list of objects or a list of strings. + :param data_object: List of objects or a list of strings + :param attr_to_filter: Attribute to retrieve the value of the objects (Only in case it is a list of objects) + :param first_attr: First attribute to build the condition + :return: Custom condition string + """ attr_filter = re.sub('[^a-zA-Z_$0-9]', '', attr_to_filter) object_id = ( [str(i) for i in data_object] @@ -49,6 +58,13 @@ def create_in_condition(data_object, attr_to_filter="", first_attr="c.id"): def create_custom_query_from_str( data: str, first_attr, delimiter: str = "," ) -> str: + """ + Function to create a string condition for url parameters (Example: data?values=value1,value2 or data?values=*) + :param data: String to build the query + :param first_attr: First attribute to build the condition + :param delimiter: String delimiter + :return: Custom condition string + """ data = data.split(delimiter) if len(data) > 1: query_str = create_in_condition(data, first_attr=first_attr) From 5780b986a1d56f0471018993404b1bc18c0c3e13 Mon Sep 17 00:00:00 2001 From: Dickson Armijos Date: Tue, 2 Jun 2020 10:41:26 -0500 Subject: [PATCH 017/287] feat: Implement filters on time-entries by uuid #154 --- .../time_entries/time_entries_model.py | 40 ++++++++++++++----- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index baa226e0..2c1375c2 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -1,8 +1,8 @@ import abc from dataclasses import dataclass, field from typing import List, Callable - from azure.cosmos import PartitionKey +from flask_restplus import abort from flask_restplus._http import HTTPStatus from commons.data_access_layer.cosmos_db import ( @@ -19,7 +19,10 @@ from utils.extend_model import add_project_name_to_time_entries from utils import worked_time -from utils.extend_model import create_in_condition +from utils.extend_model import ( + create_in_condition, + create_custom_query_from_str, +) from time_tracker_api.projects.projects_model import ProjectCosmosDBModel from time_tracker_api.projects import projects_model @@ -123,13 +126,12 @@ def find_all( self, event_context: EventContext, conditions: dict = {}, + custom_sql_conditions: List[str] = [], date_range: dict = {}, ): - custom_sql_conditions = [self.create_sql_date_range_filter(date_range)] - - if event_context.is_admin: - conditions.pop("owner_id") - # TODO should be removed when implementing a role-based permission module ↑ + custom_sql_conditions.append( + self.create_sql_date_range_filter(date_range) + ) custom_params = self.generate_params(date_range) time_entries = CosmosDBRepository.find_all( @@ -297,10 +299,30 @@ def check_time_entry_is_not_started(self, data): def get_all(self, conditions: dict = None, **kwargs) -> list: event_ctx = self.create_event_context("read-many") conditions.update({"owner_id": event_ctx.user_id}) - + custom_query = [] + if "user_id" in conditions: + if event_ctx.is_admin: + conditions.pop("owner_id") + custom_query = ( + [] + if conditions.get("user_id") == "*" + else [ + create_custom_query_from_str( + conditions.get("user_id"), "c.owner_id" + ) + ] + ) + conditions.pop("user_id") + else: + abort( + HTTPStatus.FORBIDDEN, "You don't have enough permissions." + ) date_range = self.handle_date_filter_args(args=conditions) return self.repository.find_all( - event_ctx, conditions=conditions, date_range=date_range + event_ctx, + conditions=conditions, + custom_sql_conditions=custom_query, + date_range=date_range, ) def get(self, id): From 54a242fd36375775b1bcb70ed1dc57211c45d817 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Tue, 2 Jun 2020 16:19:16 +0000 Subject: [PATCH 018/287] 0.15.0 Automatically generated by python-semantic-release --- time_tracker_api/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time_tracker_api/version.py b/time_tracker_api/version.py index 62ea085b..a842d05a 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.14.3' +__version__ = '0.15.0' From 6e938ab6046a5df2057303bfb430b52b995b1aa8 Mon Sep 17 00:00:00 2001 From: roberto Date: Thu, 28 May 2020 15:50:45 -0500 Subject: [PATCH 019/287] feat: add msal to requirements --- requirements/time_tracker_api/prod.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/requirements/time_tracker_api/prod.txt b/requirements/time_tracker_api/prod.txt index 344bc4bb..ea5818a4 100644 --- a/requirements/time_tracker_api/prod.txt +++ b/requirements/time_tracker_api/prod.txt @@ -34,3 +34,6 @@ flask-cors==3.0.8 #JWT PyJWT==1.7.1 + +#Azure +msal==1.3.0 From cd66aeb57590883cf09aee32be00a621a083588b Mon Sep 17 00:00:00 2001 From: roberto Date: Thu, 28 May 2020 17:51:59 -0500 Subject: [PATCH 020/287] feat: add baseline class to get users' information from azure --- .env.template | 7 ++++++ utils/azure_users.py | 53 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 utils/azure_users.py diff --git a/.env.template b/.env.template index 9d76e4e6..9d230a6e 100644 --- a/.env.template +++ b/.env.template @@ -13,3 +13,10 @@ export DATABASE_MASTER_KEY= # export COSMOS_DATABASE_URI=AccountEndpoint=;AccountKey= ## Also specify the database name export DATABASE_NAME= + +## For Azure Users interaction +export MSAL_AUTHORITY= +export MSAL_CLIENT_ID= +export MSAL_SCOPE= +export MSAL_SECRET= +export MSAL_ENDPOINT= diff --git a/utils/azure_users.py b/utils/azure_users.py new file mode 100644 index 00000000..2b0792fe --- /dev/null +++ b/utils/azure_users.py @@ -0,0 +1,53 @@ +import msal +import os +import requests + + +class MSALConfig: + MSAL_CLIENT_ID = os.environ.get('MSAL_CLIENT_ID') + MSAL_AUTHORITY = os.environ.get('MSAL_AUTHORITY') + MSAL_SECRET = os.environ.get('MSAL_SECRET') + MSAL_SCOPE = os.environ.get('MSAL_SCOPE') + MSAL_ENDPOINT = os.environ.get('MSAL_ENDPOINT') + """ + TODO : Add validation to ensure variables are set + """ + + +class AzureUsers: + def __init__(self, config=MSALConfig): + self.client = msal.ConfidentialClientApplication( + config.MSAL_CLIENT_ID, + authority=config.MSAL_AUTHORITY, + client_credential=config.MSAL_SECRET, + ) + self.config = config + self.set_token() + + def set_token(self): + response = self.client.acquire_token_for_client( + scopes=self.config.MSAL_SCOPE + ) + if "access_token" in response: + # Call a protected API with the access token. + # print(response["access_token"]) + self.access_token = response['access_token'] + else: + print(response.get("error")) + print(response.get("error_description")) + print( + response.get("correlation_id") + ) # You might need this when reporting a bug + + def get_user_info_by_id(self, id): + endpoint = f"{self.config.MSAL_ENDPOINT}/users/{id}?api-version=1.6&$select=displayName,otherMails" + print(endpoint) + http_headers = { + 'Authorization': f'Bearer {self.access_token}', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + data = requests.get( + endpoint, headers=http_headers, stream=False + ).json() + return data From f8a7e88874693100c04aa5e74b7bc162ab4dd949 Mon Sep 17 00:00:00 2001 From: roberto Date: Mon, 1 Jun 2020 18:13:04 -0500 Subject: [PATCH 021/287] feat: add owner email to time-entries payload --- .../time_entries/time_entries_model.py | 11 +++-- .../time_entries/time_entries_namespace.py | 7 +++ utils/azure_users.py | 43 ++++++++++++++++++- utils/extend_model.py | 7 +++ 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 20cb6f5b..82ebdbc8 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -24,13 +24,14 @@ from utils.extend_model import ( add_project_name_to_time_entries, add_activity_name_to_time_entries, -) -from utils import worked_time -from utils.worked_time import str_to_datetime -from utils.extend_model import ( create_in_condition, create_custom_query_from_str, + add_user_email_to_time_entries, ) +from utils import worked_time +from utils.worked_time import str_to_datetime + +from utils.azure_users import AzureUsers from time_tracker_api.projects.projects_model import ProjectCosmosDBModel from time_tracker_api.projects import projects_model from time_tracker_api.database import CRUDDao, APICosmosDBDao @@ -177,6 +178,8 @@ def find_all( activity_dao = activities_model.create_dao() activities = activity_dao.get_all() add_activity_name_to_time_entries(time_entries, activities) + + add_user_email_to_time_entries(time_entries, AzureUsers().users()) return time_entries def on_create(self, new_item_data: dict, event_context: EventContext): diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index aae7e6ff..7227fea3 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -129,6 +129,13 @@ description='Name of the activity associated with the time-entry', example=faker.word(['development', 'QA']), ), + 'owner_email': fields.String( + required=True, + title="Owner's Email", + max_length=50, + description='Email of the user that owns the time-entry', + example=faker.email(), + ), } time_entry_response_fields.update(common_fields) diff --git a/utils/azure_users.py b/utils/azure_users.py index 2b0792fe..d57d56d0 100644 --- a/utils/azure_users.py +++ b/utils/azure_users.py @@ -14,6 +14,13 @@ class MSALConfig: """ +class AzureUser: + def __init__(self, id, display_name, email): + self.id = id + self.display_name = display_name + self.email = email + + class AzureUsers: def __init__(self, config=MSALConfig): self.client = msal.ConfidentialClientApplication( @@ -41,7 +48,19 @@ def set_token(self): def get_user_info_by_id(self, id): endpoint = f"{self.config.MSAL_ENDPOINT}/users/{id}?api-version=1.6&$select=displayName,otherMails" - print(endpoint) + # print(endpoint) + http_headers = { + 'Authorization': f'Bearer {self.access_token}', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + data = requests.get( + endpoint, headers=http_headers, stream=False + ).json() + return data + + def get_users_info(self): + endpoint = f"{self.config.MSAL_ENDPOINT}/users?api-version=1.6&$select=displayName,otherMails,objectId" http_headers = { 'Authorization': f'Bearer {self.access_token}', 'Accept': 'application/json', @@ -51,3 +70,25 @@ def get_user_info_by_id(self, id): endpoint, headers=http_headers, stream=False ).json() return data + + def users(self): + endpoint = f"{self.config.MSAL_ENDPOINT}/users?api-version=1.6&$select=displayName,otherMails,objectId" + http_headers = { + 'Authorization': f'Bearer {self.access_token}', + #'Accept': 'application/json', + #'Content-Type': 'application/json', + } + data = requests.get( + endpoint, headers=http_headers, stream=False + ).json() + # print(data) + + users = [] + for value in data['value']: + user = AzureUser( + id=value['objectId'], + display_name=value['displayName'], + email=value['otherMails'][0], + ) + users.append(user) + return users diff --git a/utils/extend_model.py b/utils/extend_model.py index 356f133c..5c72e26f 100644 --- a/utils/extend_model.py +++ b/utils/extend_model.py @@ -38,6 +38,13 @@ def add_activity_name_to_time_entries(time_entries, activities): setattr(time_entry, 'activity_name', activity.name) +def add_user_email_to_time_entries(time_entries, users): + for time_entry in time_entries: + for user in users: + if time_entry.owner_id == user.id: + setattr(time_entry, 'owner_email', user.email) + + def create_in_condition( data_object: list, attr_to_filter: str = "", first_attr: str = "c.id" ): From aa1edf9c8847e5e43714fe0624731d9b9149dc2e Mon Sep 17 00:00:00 2001 From: roberto Date: Wed, 3 Jun 2020 15:42:55 -0500 Subject: [PATCH 022/287] feat: make a function to get user's info --- .env.template | 10 +- .../time_entries/time_entries_model.py | 5 +- utils/azure_users.py | 127 ++++++++---------- 3 files changed, 64 insertions(+), 78 deletions(-) diff --git a/.env.template b/.env.template index 9d230a6e..d5bfb21c 100644 --- a/.env.template +++ b/.env.template @@ -15,8 +15,8 @@ export DATABASE_MASTER_KEY= export DATABASE_NAME= ## For Azure Users interaction -export MSAL_AUTHORITY= -export MSAL_CLIENT_ID= -export MSAL_SCOPE= -export MSAL_SECRET= -export MSAL_ENDPOINT= +export MS_AUTHORITY= +export MS_CLIENT_ID= +export MS_SCOPE= +export MS_SECRET= +export MS_ENDPOINT= diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 82ebdbc8..1d4c0eac 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -31,7 +31,7 @@ from utils import worked_time from utils.worked_time import str_to_datetime -from utils.azure_users import AzureUsers +from utils.azure_users import AzureConnection from time_tracker_api.projects.projects_model import ProjectCosmosDBModel from time_tracker_api.projects import projects_model from time_tracker_api.database import CRUDDao, APICosmosDBDao @@ -179,7 +179,8 @@ def find_all( activities = activity_dao.get_all() add_activity_name_to_time_entries(time_entries, activities) - add_user_email_to_time_entries(time_entries, AzureUsers().users()) + users = AzureConnection().users() + add_user_email_to_time_entries(time_entries, users) return time_entries def on_create(self, new_item_data: dict, event_context: EventContext): diff --git a/utils/azure_users.py b/utils/azure_users.py index d57d56d0..b31f0f0e 100644 --- a/utils/azure_users.py +++ b/utils/azure_users.py @@ -1,94 +1,79 @@ import msal import os import requests +from typing import List -class MSALConfig: - MSAL_CLIENT_ID = os.environ.get('MSAL_CLIENT_ID') - MSAL_AUTHORITY = os.environ.get('MSAL_AUTHORITY') - MSAL_SECRET = os.environ.get('MSAL_SECRET') - MSAL_SCOPE = os.environ.get('MSAL_SCOPE') - MSAL_ENDPOINT = os.environ.get('MSAL_ENDPOINT') - """ - TODO : Add validation to ensure variables are set - """ +class MSConfig: + def check_variables_are_defined(): + auth_variables = [ + 'MS_CLIENT_ID', + 'MS_AUTHORITY', + 'MS_SECRET', + 'MS_SCOPE', + 'MS_ENDPOINT', + ] + for var in auth_variables: + if var not in os.environ: + raise EnvironmentError( + "{} is not defined in the environment".format(var) + ) + + check_variables_are_defined() + CLIENT_ID = os.environ.get('MS_CLIENT_ID') + AUTHORITY = os.environ.get('MS_AUTHORITY') + SECRET = os.environ.get('MS_SECRET') + SCOPE = os.environ.get('MS_SCOPE') + ENDPOINT = os.environ.get('MS_ENDPOINT') + + +class BearerAuth(requests.auth.AuthBase): + def __init__(self, access_token): + self.access_token = access_token + + def __call__(self, r): + r.headers["Authorization"] = f'Bearer {self.access_token}' + return r class AzureUser: - def __init__(self, id, display_name, email): + def __init__(self, id, name, email): self.id = id - self.display_name = display_name + self.name = name self.email = email -class AzureUsers: - def __init__(self, config=MSALConfig): +class AzureConnection: + def __init__(self, config=MSConfig): self.client = msal.ConfidentialClientApplication( - config.MSAL_CLIENT_ID, - authority=config.MSAL_AUTHORITY, - client_credential=config.MSAL_SECRET, + config.CLIENT_ID, + authority=config.AUTHORITY, + client_credential=config.SECRET, ) self.config = config - self.set_token() + self.access_token = self.get_token() - def set_token(self): + def get_token(self): response = self.client.acquire_token_for_client( - scopes=self.config.MSAL_SCOPE + scopes=self.config.SCOPE ) if "access_token" in response: - # Call a protected API with the access token. - # print(response["access_token"]) - self.access_token = response['access_token'] + return response['access_token'] else: - print(response.get("error")) - print(response.get("error_description")) - print( - response.get("correlation_id") - ) # You might need this when reporting a bug - - def get_user_info_by_id(self, id): - endpoint = f"{self.config.MSAL_ENDPOINT}/users/{id}?api-version=1.6&$select=displayName,otherMails" - # print(endpoint) - http_headers = { - 'Authorization': f'Bearer {self.access_token}', - 'Accept': 'application/json', - 'Content-Type': 'application/json', - } - data = requests.get( - endpoint, headers=http_headers, stream=False - ).json() - return data + error_info = f"{response['error']} {response['error_description']}" + raise ValueError(error_info) - def get_users_info(self): - endpoint = f"{self.config.MSAL_ENDPOINT}/users?api-version=1.6&$select=displayName,otherMails,objectId" - http_headers = { - 'Authorization': f'Bearer {self.access_token}', - 'Accept': 'application/json', - 'Content-Type': 'application/json', - } - data = requests.get( - endpoint, headers=http_headers, stream=False - ).json() - return data + def users(self) -> List[AzureUser]: + def to_azure_user(item) -> AzureUser: + there_is_email = len(item['otherMails']) > 0 + id = item['objectId'] + name = item['displayName'] + email = item['otherMails'][0] if there_is_email else '' + return AzureUser(id, name, email) - def users(self): - endpoint = f"{self.config.MSAL_ENDPOINT}/users?api-version=1.6&$select=displayName,otherMails,objectId" - http_headers = { - 'Authorization': f'Bearer {self.access_token}', - #'Accept': 'application/json', - #'Content-Type': 'application/json', - } - data = requests.get( - endpoint, headers=http_headers, stream=False - ).json() - # print(data) + endpoint = f"{self.config.ENDPOINT}/users?api-version=1.6&$select=displayName,otherMails,objectId" + response = requests.get(endpoint, auth=BearerAuth(self.access_token)) - users = [] - for value in data['value']: - user = AzureUser( - id=value['objectId'], - display_name=value['displayName'], - email=value['otherMails'][0], - ) - users.append(user) - return users + assert 200 == response.status_code + assert 'value' in response.json() + return [to_azure_user(item) for item in response.json()['value']] From a3fed866f58948b654b229b9217492bfb9bbd3ac Mon Sep 17 00:00:00 2001 From: semantic-release Date: Thu, 4 Jun 2020 15:22:05 +0000 Subject: [PATCH 023/287] 0.16.0 Automatically generated by python-semantic-release --- time_tracker_api/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time_tracker_api/version.py b/time_tracker_api/version.py index a842d05a..8911e95c 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.15.0' +__version__ = '0.16.0' From 4d235e70278c4a17eb0c97d81640a8a827210ae0 Mon Sep 17 00:00:00 2001 From: Rene Enriquez Date: Tue, 9 Jun 2020 09:41:20 -0500 Subject: [PATCH 024/287] fix: #170 inscrease description size --- commons/data_access_layer/database.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commons/data_access_layer/database.py b/commons/data_access_layer/database.py index aa895339..52f379b8 100644 --- a/commons/data_access_layer/database.py +++ b/commons/data_access_layer/database.py @@ -7,7 +7,7 @@ """ import abc -COMMENTS_MAX_LENGTH = 500 +COMMENTS_MAX_LENGTH = 1500 ID_MAX_LENGTH = 64 From 1652cb32032bd4edc10ac29d1e435970da066e0e Mon Sep 17 00:00:00 2001 From: semantic-release Date: Tue, 9 Jun 2020 15:36:21 +0000 Subject: [PATCH 025/287] 0.16.1 Automatically generated by python-semantic-release --- time_tracker_api/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time_tracker_api/version.py b/time_tracker_api/version.py index 8911e95c..9513287c 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.16.0' +__version__ = '0.16.1' From 80fa31e9b96a09f39f82fbd95049d79f4fc76ed4 Mon Sep 17 00:00:00 2001 From: Dickson Armijos Date: Thu, 11 Jun 2020 09:28:54 -0500 Subject: [PATCH 026/287] Add is_deleted function to CosmosDBModel class --- commons/data_access_layer/cosmos_db.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index 859df9b6..9508715f 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -78,6 +78,11 @@ def __init__(self, data): if k in names: setattr(self, k, v) + def is_deleted(self): + if "deleted" in self.__dict__.keys(): + return True if self.deleted else False + return False + def partition_key_attribute(pk: PartitionKey) -> str: return pk.path.strip('/') From 323c713b2d61fe8e024317259ea4dfed93cfb5cd Mon Sep 17 00:00:00 2001 From: Dickson Armijos Date: Thu, 11 Jun 2020 09:31:28 -0500 Subject: [PATCH 027/287] feature: Change visible_only to False --- time_tracker_api/time_entries/time_entries_model.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 1d4c0eac..aa0954c4 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -168,15 +168,17 @@ def find_all( if time_entries: custom_conditions = create_in_condition(time_entries, "project_id") + custom_conditions_activity = create_in_condition(time_entries, "activity_id") project_dao = projects_model.create_dao() projects = project_dao.get_all( - custom_sql_conditions=[custom_conditions] + custom_sql_conditions=[custom_conditions], + visible_only=False ) add_project_name_to_time_entries(time_entries, projects) activity_dao = activities_model.create_dao() - activities = activity_dao.get_all() + activities = activity_dao.get_all(custom_sql_conditions=[custom_conditions_activity], visible_only=False) add_activity_name_to_time_entries(time_entries, activities) users = AzureConnection().users() From 5aa6b619b125619d5f7c0f7375c3b92c3a789774 Mon Sep 17 00:00:00 2001 From: Dickson Armijos Date: Thu, 11 Jun 2020 09:33:52 -0500 Subject: [PATCH 028/287] feature: Detect if an activity was deleted --- utils/extend_model.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/utils/extend_model.py b/utils/extend_model.py index 5c72e26f..3b15633c 100644 --- a/utils/extend_model.py +++ b/utils/extend_model.py @@ -35,7 +35,8 @@ def add_activity_name_to_time_entries(time_entries, activities): for time_entry in time_entries: for activity in activities: if time_entry.activity_id == activity.id: - setattr(time_entry, 'activity_name', activity.name) + name = activity.name + " (archived)" if activity.is_deleted() else activity.name + setattr(time_entry, 'activity_name', name) def add_user_email_to_time_entries(time_entries, users): From 12cb1290b6ea1154c56d7cdf1ba61443d08af241 Mon Sep 17 00:00:00 2001 From: Dickson Armijos Date: Thu, 11 Jun 2020 09:47:14 -0500 Subject: [PATCH 029/287] feature: Detect if an project was deleted --- utils/extend_model.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/utils/extend_model.py b/utils/extend_model.py index 3b15633c..54d25975 100644 --- a/utils/extend_model.py +++ b/utils/extend_model.py @@ -28,7 +28,8 @@ def add_project_name_to_time_entries(time_entries, projects): for time_entry in time_entries: for project in projects: if time_entry.project_id == project.id: - setattr(time_entry, 'project_name', project.name) + name = project.name + " (archived)" if project.is_deleted() else project.name + setattr(time_entry, 'project_name', name) def add_activity_name_to_time_entries(time_entries, activities): From d1413eeb5cbe3bb2d554c1c2dec0c5ba2a1b5ae6 Mon Sep 17 00:00:00 2001 From: roberto Date: Tue, 9 Jun 2020 13:06:46 -0500 Subject: [PATCH 030/287] feat: :construction: add query param time-offset to summary endpoint --- commons/data_access_layer/cosmos_db.py | 63 +-------- .../data_access_layer/cosmos_db_test.py | 13 +- .../time_entries/time_entries_model_test.py | 122 ++++++++++++------ .../time_entries_namespace_test.py | 7 +- .../time_entries/time_entries_model.py | 25 ++-- .../time_entries/time_entries_namespace.py | 21 ++- utils/time.py | 60 +++++++++ utils/worked_time.py | 9 +- 8 files changed, 189 insertions(+), 131 deletions(-) create mode 100644 utils/time.py diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index 9508715f..2b1dee9d 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -1,8 +1,6 @@ import dataclasses import logging -import uuid -from datetime import datetime, timezone -from typing import Callable, List, Dict +from typing import Callable, List import azure.cosmos.cosmos_client as cosmos_client import azure.cosmos.exceptions as exceptions @@ -380,62 +378,7 @@ def init_app(app: Flask) -> None: cosmos_helper = CosmosDBFacade.from_flask_config(app) -def current_datetime() -> datetime: - return datetime.now(timezone.utc) - - -def datetime_str(value: datetime) -> str: - if value is not None: - return value.isoformat() - else: - return None - - -def current_datetime_str() -> str: - return datetime_str(current_datetime()) - - def generate_uuid4() -> str: - return str(uuid.uuid4()) - - -def get_last_day_of_month(year: int, month: int) -> int: - from calendar import monthrange - - return monthrange(year=year, month=month)[1] - - -def get_current_year() -> int: - return datetime.now().year - - -def get_current_month() -> int: - return datetime.now().month - - -def get_current_day() -> int: - return datetime.now().day - - -def get_date_range_of_month(year: int, month: int) -> Dict[str, str]: - first_day_of_month = 1 - start_date = datetime( - year=year, month=month, day=first_day_of_month, tzinfo=timezone.utc - ) - - last_day_of_month = get_last_day_of_month(year=year, month=month) - end_date = datetime( - year=year, - month=month, - day=last_day_of_month, - hour=23, - minute=59, - second=59, - microsecond=999999, - tzinfo=timezone.utc, - ) + from uuid import uuid4 - return { - 'start_date': datetime_str(start_date), - 'end_date': datetime_str(end_date), - } + return str(uuid4()) diff --git a/tests/commons/data_access_layer/cosmos_db_test.py b/tests/commons/data_access_layer/cosmos_db_test.py index 6cb6deb7..ad3bd7da 100644 --- a/tests/commons/data_access_layer/cosmos_db_test.py +++ b/tests/commons/data_access_layer/cosmos_db_test.py @@ -1,24 +1,23 @@ from dataclasses import dataclass from datetime import timedelta from typing import Callable +from faker import Faker import pytest +from pytest import fail + from azure.cosmos.exceptions import ( CosmosResourceExistsError, CosmosResourceNotFoundError, ) -from faker import Faker -from flask_restplus._http import HTTPStatus -from pytest import fail +from commons.data_access_layer.database import EventContext from commons.data_access_layer.cosmos_db import ( CosmosDBRepository, CosmosDBModel, - CustomError, - current_datetime, - datetime_str, ) -from commons.data_access_layer.database import EventContext + +from utils.time import datetime_str, current_datetime fake = Faker() Faker.seed() 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 index 78a463f9..f8a403f1 100644 --- a/tests/time_tracker_api/time_entries/time_entries_model_test.py +++ b/tests/time_tracker_api/time_entries/time_entries_model_test.py @@ -3,10 +3,13 @@ import pytest from faker import Faker -from commons.data_access_layer.cosmos_db import current_datetime, datetime_str +from utils.time import datetime_str, current_datetime from commons.data_access_layer.database import EventContext -from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository, TimeEntryCosmosDBModel, \ - container_definition +from time_tracker_api.time_entries.time_entries_model import ( + TimeEntryCosmosDBRepository, + TimeEntryCosmosDBModel, + container_definition, +) fake = Faker() @@ -15,9 +18,14 @@ two_days_ago = current_datetime() - timedelta(days=2) -def create_time_entry(start_date: datetime, end_date: datetime, - owner_id: str, tenant_id: str, event_context: EventContext, - time_entry_repository: TimeEntryCosmosDBRepository) -> TimeEntryCosmosDBModel: +def create_time_entry( + start_date: datetime, + end_date: datetime, + owner_id: str, + tenant_id: str, + event_context: EventContext, + time_entry_repository: TimeEntryCosmosDBRepository, +) -> TimeEntryCosmosDBModel: data = { "project_id": fake.uuid4(), "activity_id": fake.uuid4(), @@ -25,32 +33,45 @@ def create_time_entry(start_date: datetime, end_date: datetime, "start_date": datetime_str(start_date), "end_date": datetime_str(end_date), "owner_id": owner_id, - "tenant_id": tenant_id + "tenant_id": tenant_id, } - created_item = time_entry_repository.create(data, event_context, - mapper=TimeEntryCosmosDBModel) + created_item = time_entry_repository.create( + data, event_context, 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): - event_ctx = EventContext(container_definition["id"], "create", - tenant_id=tenant_id, user_id=owner_id) - - existing_item = create_time_entry(start_date, end_date, owner_id, tenant_id, - event_ctx, time_entry_repository) +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, +): + event_ctx = EventContext( + container_definition["id"], + "create", + tenant_id=tenant_id, + user_id=owner_id, + ) + + existing_item = create_time_entry( + start_date, + end_date, + owner_id, + tenant_id, + event_ctx, + time_entry_repository, + ) try: - result = time_entry_repository.find_interception_with_date_range(datetime_str(yesterday), - datetime_str(now), - owner_id, tenant_id) + result = time_entry_repository.find_interception_with_date_range( + datetime_str(yesterday), datetime_str(now), owner_id, tenant_id + ) assert result is not None assert len(result) > 0 @@ -59,42 +80,63 @@ def test_find_interception_with_date_range_should_find(start_date: datetime, time_entry_repository.delete_permanently(existing_item.id, event_ctx) -def test_find_interception_should_ignore_id_of_existing_item(owner_id: str, tenant_id: str, - time_entry_repository: TimeEntryCosmosDBRepository): - event_ctx = EventContext(container_definition["id"], "create", tenant_id=tenant_id, user_id=owner_id) +def test_find_interception_should_ignore_id_of_existing_item( + owner_id: str, + tenant_id: str, + time_entry_repository: TimeEntryCosmosDBRepository, +): + event_ctx = EventContext( + container_definition["id"], + "create", + tenant_id=tenant_id, + user_id=owner_id, + ) start_date = datetime_str(yesterday) end_date = datetime_str(now) - existing_item = create_time_entry(yesterday, now, owner_id, tenant_id, event_ctx, time_entry_repository) + existing_item = create_time_entry( + yesterday, now, owner_id, tenant_id, event_ctx, time_entry_repository + ) try: - colliding_result = time_entry_repository.find_interception_with_date_range(start_date, end_date, - owner_id, tenant_id) - - non_colliding_result = time_entry_repository.find_interception_with_date_range(start_date, end_date, - owner_id, tenant_id, - ignore_id=existing_item.id) + colliding_result = time_entry_repository.find_interception_with_date_range( + start_date, end_date, owner_id, tenant_id + ) + + non_colliding_result = time_entry_repository.find_interception_with_date_range( + start_date, + end_date, + owner_id, + 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]) + assert not any( + [existing_item.id == item.id for item in non_colliding_result] + ) finally: time_entry_repository.delete_permanently(existing_item.id, event_ctx) -def test_find_running_should_return_running_time_entry(running_time_entry, - time_entry_repository: TimeEntryCosmosDBRepository): - found_time_entry = time_entry_repository.find_running(running_time_entry.tenant_id, - running_time_entry.owner_id) +def test_find_running_should_return_running_time_entry( + running_time_entry, time_entry_repository: TimeEntryCosmosDBRepository +): + found_time_entry = time_entry_repository.find_running( + running_time_entry.tenant_id, running_time_entry.owner_id + ) assert found_time_entry is not None assert found_time_entry.id == running_time_entry.id assert found_time_entry.owner_id == running_time_entry.owner_id -def test_find_running_should_not_find_any_item(tenant_id: str, - owner_id: str, - time_entry_repository: TimeEntryCosmosDBRepository): +def test_find_running_should_not_find_any_item( + tenant_id: str, + owner_id: str, + time_entry_repository: TimeEntryCosmosDBRepository, +): try: time_entry_repository.find_running(tenant_id, owner_id) except Exception as e: 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 f79ca63b..40e3f4ea 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 @@ -7,13 +7,12 @@ from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture, pytest -from commons.data_access_layer.cosmos_db import ( +from utils.time import ( + get_current_year, + get_current_month, current_datetime, current_datetime_str, - get_current_month, - get_current_year, ) - from utils import worked_time from werkzeug.exceptions import NotFound, UnprocessableEntity, HTTPException diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index aa0954c4..d4ddfdec 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -11,13 +11,8 @@ CosmosDBRepository, CustomError, CosmosDBModel, - current_datetime_str, - datetime_str, - get_date_range_of_month, - get_current_year, - get_current_month, - get_current_day, ) + from commons.data_access_layer.database import EventContext from time_tracker_api.activities import activities_model @@ -28,10 +23,18 @@ create_custom_query_from_str, add_user_email_to_time_entries, ) +from utils.time import ( + datetime_str, + get_current_year, + get_current_month, + get_current_day, + get_date_range_of_month, + current_datetime_str, +) from utils import worked_time from utils.worked_time import str_to_datetime - from utils.azure_users import AzureConnection + from time_tracker_api.projects.projects_model import ProjectCosmosDBModel from time_tracker_api.projects import projects_model from time_tracker_api.database import CRUDDao, APICosmosDBDao @@ -440,18 +443,20 @@ def find_running(self): # self.stop_time_entry_if_was_left_running(time_entry) return time_entry - def get_worked_time(self, conditions: dict = {}): + def get_worked_time(self, args: dict = {}): event_ctx = self.create_event_context( "read", "Summary of worked time in the current month" ) - conditions.update({"owner_id": event_ctx.user_id}) + conditions = {"owner_id": event_ctx.user_id} time_entries = self.repository.find_all( event_ctx, conditions=conditions, date_range=worked_time.date_range(), ) - return worked_time.summary(time_entries) + return worked_time.summary( + time_entries, time_offset=args.get('time_offset') + ) @staticmethod def handle_date_filter_args(args: dict) -> dict: diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index 7227fea3..b926525a 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -4,11 +4,8 @@ from flask_restplus import fields, Resource from flask_restplus._http import HTTPStatus -from commons.data_access_layer.cosmos_db import ( - current_datetime, - datetime_str, - current_datetime_str, -) +from utils.time import datetime_str, current_datetime, current_datetime_str + from commons.data_access_layer.database import COMMENTS_MAX_LENGTH from time_tracker_api.api import ( common_fields, @@ -295,13 +292,25 @@ def get(self): return time_entries_dao.find_running() +summary_attribs_parser = ns.parser() +summary_attribs_parser.add_argument( + 'time_offset', + required=False, + store_missing=False, + help="(Filter) Time zone difference, in minutes, from current locale (host system settings) to UTC.", + location='args', +) + + @ns.route('/summary') @ns.response(HTTPStatus.OK, 'Summary of worked time in the current month') @ns.response( HTTPStatus.NOT_FOUND, 'There is no time entry in the current month' ) class WorkedTimeSummary(Resource): + @ns.expect(summary_attribs_parser) @ns.doc('summary_of_worked_time') def get(self): """Find the summary of worked time""" - return time_entries_dao.get_worked_time() + conditions = summary_attribs_parser.parse_args() + return time_entries_dao.get_worked_time(conditions) diff --git a/utils/time.py b/utils/time.py new file mode 100644 index 00000000..915f4589 --- /dev/null +++ b/utils/time.py @@ -0,0 +1,60 @@ +from typing import Dict +from datetime import datetime, timezone + + +def current_datetime_str() -> str: + from utils.time import datetime_str + + return datetime_str(current_datetime()) + + +def current_datetime() -> datetime: + return datetime.now(timezone.utc) + + +def get_current_year() -> int: + return datetime.now().year + + +def get_current_month() -> int: + return datetime.now().month + + +def get_current_day() -> int: + return datetime.now().day + + +def datetime_str(value: datetime) -> str: + if value is not None: + return value.isoformat() + else: + return None + + +def get_date_range_of_month(year: int, month: int) -> Dict[str, str]: + def get_last_day_of_month(year: int, month: int) -> int: + from calendar import monthrange + + return monthrange(year=year, month=month)[1] + + first_day_of_month = 1 + start_date = datetime( + year=year, month=month, day=first_day_of_month, tzinfo=timezone.utc + ) + + last_day_of_month = get_last_day_of_month(year=year, month=month) + end_date = datetime( + year=year, + month=month, + day=last_day_of_month, + hour=23, + minute=59, + second=59, + microsecond=999999, + tzinfo=timezone.utc, + ) + + return { + 'start_date': datetime_str(start_date), + 'end_date': datetime_str(end_date), + } diff --git a/utils/worked_time.py b/utils/worked_time.py index 2b7e0862..f50133c2 100644 --- a/utils/worked_time.py +++ b/utils/worked_time.py @@ -1,10 +1,10 @@ from datetime import datetime, timedelta, timezone -from commons.data_access_layer.cosmos_db import ( - current_datetime, - current_datetime_str, +from utils.time import ( datetime_str, get_current_month, get_current_year, + current_datetime, + current_datetime_str, ) @@ -127,7 +127,8 @@ def worked_time_in_month(time_entries): return WorkedTime(month_time_entries).summary() -def summary(time_entries): +def summary(time_entries, time_offset): + print(time_offset) stop_running_time_entry(time_entries) return { 'day': worked_time_in_day(time_entries), From d1008632e8a389a913151b61ccd0ffba2bb45cfb Mon Sep 17 00:00:00 2001 From: roberto Date: Wed, 10 Jun 2020 11:46:49 -0500 Subject: [PATCH 031/287] fix: :recycle: make method elapsed time of class TimeEntryModel --- requirements/time_tracker_api/prod.txt | 4 + .../time_entries/time_entries_model.py | 30 ++++- .../time_entries/time_entries_namespace.py | 16 ++- utils/time.py | 39 +++++- utils/worked_time.py | 111 ++++++------------ 5 files changed, 105 insertions(+), 95 deletions(-) diff --git a/requirements/time_tracker_api/prod.txt b/requirements/time_tracker_api/prod.txt index ea5818a4..86b5c632 100644 --- a/requirements/time_tracker_api/prod.txt +++ b/requirements/time_tracker_api/prod.txt @@ -37,3 +37,7 @@ PyJWT==1.7.1 #Azure msal==1.3.0 + +# Time utils +pytz==2019.3 +python-dateutil==2.8.1 \ No newline at end of file diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index d4ddfdec..5da07486 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -6,6 +6,8 @@ from flask_restplus import abort from flask_restplus._http import HTTPStatus +from datetime import datetime, timedelta + from commons.data_access_layer.cosmos_db import ( CosmosDBDao, CosmosDBRepository, @@ -25,6 +27,7 @@ ) from utils.time import ( datetime_str, + str_to_datetime, get_current_year, get_current_month, get_current_day, @@ -32,7 +35,6 @@ current_datetime_str, ) from utils import worked_time -from utils.worked_time import str_to_datetime from utils.azure_users import AzureConnection from time_tracker_api.projects.projects_model import ProjectCosmosDBModel @@ -105,6 +107,18 @@ def start_date_at_midnight(self) -> str: start_date.replace(hour=23, minute=59, second=59, microsecond=0) ) + @property + def elapsed_time(self) -> timedelta: + start_datetime = str_to_datetime(self.start_date) + end_datetime = str_to_datetime(self.end_date) + return end_datetime - start_datetime + + def in_range(self, start_date: datetime, end_date: datetime) -> bool: + return ( + start_date <= str_to_datetime(self.start_date) <= end_date + and start_date <= str_to_datetime(self.end_date) <= end_date + ) + def __add__(self, other): if type(other) is ProjectCosmosDBModel: time_entry = self.__class__ @@ -114,7 +128,7 @@ def __add__(self, other): raise NotImplementedError def __repr__(self): - return '