diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index 8082bd9a..6ec824e7 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -379,6 +379,11 @@ def __init__(self, status_code: int, description: str = None): self.description = description +def init_app(app: Flask) -> None: + global cosmos_helper + cosmos_helper = CosmosDBFacade.from_flask_config(app) + + def current_datetime() -> datetime: return datetime.utcnow() @@ -398,11 +403,6 @@ def generate_uuid4() -> str: return str(uuid.uuid4()) -def init_app(app: Flask) -> None: - global cosmos_helper - cosmos_helper = CosmosDBFacade.from_flask_config(app) - - def get_last_day_of_month(year: int, month: int) -> int: from calendar import monthrange return monthrange(year=year, month=month)[1] 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 35140fee..68d7c7c0 100644 --- a/tests/time_tracker_api/time_entries/time_entries_namespace_test.py +++ b/tests/time_tracker_api/time_entries/time_entries_namespace_test.py @@ -1,5 +1,5 @@ from datetime import timedelta -from unittest.mock import ANY +from unittest.mock import ANY, Mock from faker import Faker from flask import json @@ -7,11 +7,18 @@ from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture, pytest -from commons.data_access_layer.cosmos_db import current_datetime, \ - current_datetime_str, get_date_range_of_month, get_current_month, \ - get_current_year, datetime_str -from commons.data_access_layer.database import EventContext -from time_tracker_api.time_entries.time_entries_model import TimeEntriesCosmosDBDao +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, +) fake = Faker() @@ -23,417 +30,564 @@ "start_date": current_datetime_str(), } -fake_time_entry = ({ +fake_time_entry = { "id": fake.random_int(1, 9999), "running": True, "owner_id": fake.uuid4(), "tenant_id": fake.uuid4(), -}) +} fake_time_entry.update(valid_time_entry_input) -def test_create_time_entry_with_invalid_date_range_should_raise_bad_request_error(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao - repository_container_create_item_mock = mocker.patch.object(time_entries_dao.repository.container, - 'create_item', - return_value=fake_time_entry) +def test_create_time_entry_with_invalid_date_range_should_raise_bad_request_error( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + from time_tracker_api.time_entries.time_entries_namespace import ( + time_entries_dao, + ) + + repository_container_create_item_mock = mocker.patch.object( + time_entries_dao.repository.container, + 'create_item', + return_value=fake_time_entry, + ) invalid_time_entry_input = valid_time_entry_input.copy() - invalid_time_entry_input.update({ - "end_date": str(yesterday.isoformat()) - }) - response = client.post("/time-entries", - headers=valid_header, - json=invalid_time_entry_input, - follow_redirects=True) + invalid_time_entry_input.update({"end_date": str(yesterday.isoformat())}) + response = client.post( + "/time-entries", + headers=valid_header, + json=invalid_time_entry_input, + follow_redirects=True, + ) assert HTTPStatus.BAD_REQUEST == response.status_code repository_container_create_item_mock.assert_not_called() -def test_create_time_entry_with_end_date_in_future_should_raise_bad_request_error(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao - repository_container_create_item_mock = mocker.patch.object(time_entries_dao.repository.container, - 'create_item', - return_value=fake_time_entry) +def test_create_time_entry_with_end_date_in_future_should_raise_bad_request_error( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + from time_tracker_api.time_entries.time_entries_namespace import ( + time_entries_dao, + ) + + repository_container_create_item_mock = mocker.patch.object( + time_entries_dao.repository.container, + 'create_item', + return_value=fake_time_entry, + ) invalid_time_entry_input = valid_time_entry_input.copy() - invalid_time_entry_input.update({ - "end_date": str(fake.future_datetime().isoformat()) - }) - response = client.post("/time-entries", - headers=valid_header, - json=invalid_time_entry_input, - follow_redirects=True) + invalid_time_entry_input.update( + {"end_date": str(fake.future_datetime().isoformat())} + ) + response = client.post( + "/time-entries", + headers=valid_header, + json=invalid_time_entry_input, + follow_redirects=True, + ) assert HTTPStatus.BAD_REQUEST == response.status_code repository_container_create_item_mock.assert_not_called() -def test_create_time_entry_should_succeed_with_valid_request(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao - repository_create_mock = mocker.patch.object(time_entries_dao.repository, - 'create', - return_value=fake_time_entry) +def test_create_time_entry_should_succeed_with_valid_request( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + from time_tracker_api.time_entries.time_entries_namespace import ( + time_entries_dao, + ) + + repository_create_mock = mocker.patch.object( + time_entries_dao.repository, 'create', return_value=fake_time_entry + ) - response = client.post("/time-entries", - headers=valid_header, - json=valid_time_entry_input, - follow_redirects=True) + response = client.post( + "/time-entries", + headers=valid_header, + json=valid_time_entry_input, + follow_redirects=True, + ) assert HTTPStatus.CREATED == response.status_code repository_create_mock.assert_called_once() -def test_create_time_entry_with_missing_req_field_should_return_bad_request(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao - repository_create_mock = mocker.patch.object(time_entries_dao.repository, - 'create', - return_value=fake_time_entry) +def test_create_time_entry_with_missing_req_field_should_return_bad_request( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + from time_tracker_api.time_entries.time_entries_namespace import ( + time_entries_dao, + ) + + repository_create_mock = mocker.patch.object( + time_entries_dao.repository, 'create', return_value=fake_time_entry + ) - response = client.post("/time-entries", - headers=valid_header, - json={ - "activity_id": fake.uuid4(), - "start_date": current_datetime_str(), - }, - follow_redirects=True) + response = client.post( + "/time-entries", + headers=valid_header, + json={ + "activity_id": fake.uuid4(), + "start_date": current_datetime_str(), + }, + follow_redirects=True, + ) assert HTTPStatus.BAD_REQUEST == response.status_code repository_create_mock.assert_not_called() -def test_list_all_time_entries(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao - repository_find_all_mock = mocker.patch.object(time_entries_dao.repository, - 'find_all', - return_value=[]) +def test_list_all_time_entries( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + from time_tracker_api.time_entries.time_entries_namespace import ( + time_entries_dao, + ) - response = client.get("/time-entries", - headers=valid_header, - follow_redirects=True) + repository_find_all_mock = mocker.patch.object( + time_entries_dao.repository, 'find_all', return_value=[] + ) + + response = client.get( + "/time-entries", headers=valid_header, follow_redirects=True + ) assert HTTPStatus.OK == response.status_code assert [] == json.loads(response.data) repository_find_all_mock.assert_called_once() -def test_get_time_entry_should_succeed_with_valid_id(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao - repository_find_mock = mocker.patch.object(time_entries_dao.repository, - 'find', - return_value=fake_time_entry) +def test_get_time_entry_should_succeed_with_valid_id( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + from time_tracker_api.time_entries.time_entries_namespace import ( + time_entries_dao, + ) + + repository_find_mock = mocker.patch.object( + time_entries_dao.repository, 'find', return_value=fake_time_entry + ) valid_id = fake.random_int(1, 9999) - response = client.get("/time-entries/%s" % valid_id, - headers=valid_header, - follow_redirects=True) + response = client.get( + "/time-entries/%s" % valid_id, + headers=valid_header, + follow_redirects=True, + ) assert HTTPStatus.OK == response.status_code fake_time_entry == json.loads(response.data) - repository_find_mock.assert_called_once_with(str(valid_id), ANY, peeker=ANY) + repository_find_mock.assert_called_once_with( + str(valid_id), ANY, peeker=ANY + ) -def test_get_time_entry_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao +def test_get_time_entry_should_response_with_unprocessable_entity_for_invalid_id_format( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + 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) + repository_find_mock = mocker.patch.object( + time_entries_dao.repository, 'find', side_effect=UnprocessableEntity + ) - response = client.get("/time-entries/%s" % invalid_id, - headers=valid_header, - follow_redirects=True) + response = client.get( + "/time-entries/%s" % invalid_id, + headers=valid_header, + follow_redirects=True, + ) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_find_mock.assert_called_once_with(str(invalid_id), ANY, peeker=ANY) + repository_find_mock.assert_called_once_with( + str(invalid_id), ANY, peeker=ANY + ) -def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): - 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) +def test_update_time_entry_should_succeed_with_valid_data( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + 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) - response = client.put("/time-entries/%s" % valid_id, - headers=valid_header, - json=valid_time_entry_input, - follow_redirects=True) + response = client.put( + "/time-entries/%s" % 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) + repository_update_mock.assert_called_once_with( + str(valid_id), valid_time_entry_input, ANY, peeker=ANY + ) + +def test_update_time_entry_should_reject_bad_request( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + from time_tracker_api.time_entries.time_entries_namespace import ( + time_entries_dao, + ) -def test_update_time_entry_should_reject_bad_request(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao invalid_time_entry_data = valid_time_entry_input.copy() - invalid_time_entry_data.update({ - "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) + invalid_time_entry_data.update( + {"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 + ) valid_id = fake.random_int(1, 9999) - response = client.put("/time-entries/%s" % valid_id, - headers=valid_header, - json=invalid_time_entry_data, - follow_redirects=True) + response = client.put( + "/time-entries/%s" % valid_id, + headers=valid_header, + json=invalid_time_entry_data, + follow_redirects=True, + ) assert HTTPStatus.BAD_REQUEST == response.status_code 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): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao +def test_update_time_entry_should_return_not_found_with_invalid_id( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + 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) + + repository_update_mock = mocker.patch.object( + time_entries_dao.repository, 'partial_update', side_effect=NotFound + ) invalid_id = fake.random_int(1, 9999) - response = client.put("/time-entries/%s" % invalid_id, - headers=valid_header, - json=valid_time_entry_input, - follow_redirects=True) + response = client.put( + "/time-entries/%s" % invalid_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) + repository_update_mock.assert_called_once_with( + str(invalid_id), valid_time_entry_input, ANY, peeker=ANY + ) -def test_delete_time_entry_should_succeed_with_valid_id(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): - 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) +def test_delete_time_entry_should_succeed_with_valid_id( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + 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) - response = client.delete("/time-entries/%s" % valid_id, - headers=valid_header, - follow_redirects=True) + response = client.delete( + "/time-entries/%s" % valid_id, + headers=valid_header, + follow_redirects=True, + ) assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data - repository_remove_mock.assert_called_once_with(str(valid_id), ANY, peeker=ANY) + repository_remove_mock.assert_called_once_with( + str(valid_id), ANY, peeker=ANY + ) -def test_delete_time_entry_should_return_not_found_with_invalid_id(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao +def test_delete_time_entry_should_return_not_found_with_invalid_id( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + 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) + + repository_remove_mock = mocker.patch.object( + time_entries_dao.repository, 'delete', side_effect=NotFound + ) invalid_id = fake.random_int(1, 9999) - response = client.delete("/time-entries/%s" % invalid_id, - headers=valid_header, - follow_redirects=True) + response = client.delete( + "/time-entries/%s" % invalid_id, + headers=valid_header, + follow_redirects=True, + ) assert HTTPStatus.NOT_FOUND == response.status_code - repository_remove_mock.assert_called_once_with(str(invalid_id), ANY, peeker=ANY) + repository_remove_mock.assert_called_once_with( + str(invalid_id), ANY, peeker=ANY + ) -def test_delete_time_entry_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao +def test_delete_time_entry_should_return_unprocessable_entity_for_invalid_id_format( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + 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) + + 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) + 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 - repository_update_mock = mocker.patch.object(time_entries_dao.repository, - 'partial_update', - return_value=fake_time_entry) + 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, + ) + + repository_update_mock = mocker.patch.object( + time_entries_dao.repository, + 'partial_update', + return_value=fake_time_entry, + ) valid_id = fake.random_int(1, 9999) - response = client.post("/time-entries/%s/stop" % valid_id, - headers=valid_header, - follow_redirects=True) + response = client.post( + "/time-entries/%s/stop" % valid_id, + 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) - - -def test_stop_time_entry_with_id_with_invalid_format(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao + repository_update_mock.assert_called_once_with( + str(valid_id), + {"end_date": mocker.ANY}, + ANY, + peeker=TimeEntriesCosmosDBDao.checks_owner_and_is_not_stopped, + ) + + +def test_stop_time_entry_with_id_with_invalid_format( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + 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) + + repository_update_mock = mocker.patch.object( + time_entries_dao.repository, + 'partial_update', + side_effect=UnprocessableEntity, + ) invalid_id = fake.word() - response = client.post("/time-entries/%s/stop" % invalid_id, - headers=valid_header, - follow_redirects=True) + response = client.post( + "/time-entries/%s/stop" % invalid_id, + 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) - - -def test_restart_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 - repository_update_mock = mocker.patch.object(time_entries_dao.repository, - 'partial_update', - return_value=fake_time_entry) + repository_update_mock.assert_called_once_with( + invalid_id, + {"end_date": ANY}, + ANY, + peeker=TimeEntriesCosmosDBDao.checks_owner_and_is_not_stopped, + ) + + +def test_restart_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, + ) + + repository_update_mock = mocker.patch.object( + time_entries_dao.repository, + 'partial_update', + return_value=fake_time_entry, + ) valid_id = fake.random_int(1, 9999) - response = client.post("/time-entries/%s/restart" % valid_id, - headers=valid_header, - follow_redirects=True) + response = client.post( + "/time-entries/%s/restart" % valid_id, + 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) + repository_update_mock.assert_called_once_with( + str(valid_id), {"end_date": None}, ANY, peeker=ANY + ) -def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao +def test_restart_time_entry_with_id_with_invalid_format( + client: FlaskClient, mocker: MockFixture, valid_header: dict +): + 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) + + repository_update_mock = mocker.patch.object( + time_entries_dao.repository, + 'partial_update', + side_effect=UnprocessableEntity, + peeker=ANY, + ) invalid_id = fake.word() - response = client.post("/time-entries/%s/restart" % invalid_id, - headers=valid_header, - follow_redirects=True) + response = client.post( + "/time-entries/%s/restart" % invalid_id, + 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) - - -def test_get_running_should_call_find_running(client: FlaskClient, - mocker: MockFixture, - valid_header: dict, - tenant_id: str, - owner_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, - 'find_running', - return_value=fake_time_entry) - - response = client.get("/time-entries/running", - headers=valid_header, - follow_redirects=True) + repository_update_mock.assert_called_once_with( + invalid_id, {"end_date": None}, ANY, peeker=ANY + ) + + +def test_get_running_should_call_find_running( + client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str, + owner_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, + 'find_running', + return_value=fake_time_entry, + ) + + response = client.get( + "/time-entries/running", headers=valid_header, follow_redirects=True + ) assert HTTPStatus.OK == response.status_code assert json.loads(response.data) is not None repository_update_mock.assert_called_once_with(tenant_id, owner_id) -def test_get_running_should_return_not_found_if_find_running_throws_StopIteration(client: FlaskClient, - mocker: MockFixture, - valid_header: dict, - tenant_id: str, - owner_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, - 'find_running', - side_effect=StopIteration) +def test_get_running_should_return_not_found_if_find_running_throws_StopIteration( + client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str, + owner_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, 'find_running', side_effect=StopIteration + ) - response = client.get("/time-entries/running", - headers=valid_header, - follow_redirects=True) + response = client.get( + "/time-entries/running", headers=valid_header, follow_redirects=True + ) assert HTTPStatus.NOT_FOUND == response.status_code repository_update_mock.assert_called_once_with(tenant_id, owner_id) @pytest.mark.parametrize( - 'invalid_uuid', ["zxy", "zxy%s" % fake.uuid4(), "%szxy" % fake.uuid4(), " "] + 'invalid_uuid', + ["zxy", "zxy%s" % fake.uuid4(), "%szxy" % fake.uuid4(), " "], ) -def test_create_with_invalid_uuid_format_should_return_bad_request(client: FlaskClient, - mocker: MockFixture, - valid_header: dict, - invalid_uuid: str): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao - repository_container_create_item_mock = mocker.patch.object(time_entries_dao.repository.container, - 'create_item', - return_value=fake_time_entry) +def test_create_with_invalid_uuid_format_should_return_bad_request( + client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + invalid_uuid: str, +): + from time_tracker_api.time_entries.time_entries_namespace import ( + time_entries_dao, + ) + + repository_container_create_item_mock = mocker.patch.object( + time_entries_dao.repository.container, + 'create_item', + return_value=fake_time_entry, + ) invalid_time_entry_input = { "project_id": fake.uuid4(), "activity_id": invalid_uuid, } - response = client.post("/time-entries", - headers=valid_header, - json=invalid_time_entry_input, - follow_redirects=True) + response = client.post( + "/time-entries", + headers=valid_header, + json=invalid_time_entry_input, + follow_redirects=True, + ) assert HTTPStatus.BAD_REQUEST == response.status_code repository_container_create_item_mock.assert_not_called() -@pytest.mark.parametrize( - 'valid_uuid', ["", fake.uuid4()] -) -def test_create_with_valid_uuid_format_should_return_created(client: FlaskClient, - mocker: MockFixture, - valid_header: dict, - valid_uuid: str): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao - repository_container_create_item_mock = mocker.patch.object(time_entries_dao.repository.container, - 'create_item', - return_value=fake_time_entry) +@pytest.mark.parametrize('valid_uuid', ["", fake.uuid4()]) +def test_create_with_valid_uuid_format_should_return_created( + client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + valid_uuid: str, +): + from time_tracker_api.time_entries.time_entries_namespace import ( + time_entries_dao, + ) + + repository_container_create_item_mock = mocker.patch.object( + time_entries_dao.repository.container, + 'create_item', + return_value=fake_time_entry, + ) invalid_time_entry_input = { "project_id": fake.uuid4(), "activity_id": valid_uuid, } - response = client.post("/time-entries", - headers=valid_header, - json=invalid_time_entry_input, - follow_redirects=True) + response = client.post( + "/time-entries", + headers=valid_header, + json=invalid_time_entry_input, + follow_redirects=True, + ) assert HTTPStatus.CREATED == response.status_code repository_container_create_item_mock.assert_called() @@ -444,30 +598,62 @@ def test_create_with_valid_uuid_format_should_return_created(client: FlaskClient [ ('/time-entries?month=4&year=2020', 4, 2020), ('/time-entries?month=4', 4, get_current_year()), - ('/time-entries', get_current_month(), get_current_year()) - ] + ('/time-entries', get_current_month(), get_current_year()), + ], ) -def test_find_all_is_called_with_generated_dates(client: FlaskClient, - mocker: MockFixture, - valid_header: dict, - owner_id: str, - url: str, - month: int, - year: int): - from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao - repository_find_all_mock = mocker.patch.object(time_entries_dao.repository, - 'find_all', - return_value=fake_time_entry) +def test_find_all_is_called_with_generated_dates( + client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + owner_id: str, + url: str, + month: int, + year: int, +): + from time_tracker_api.time_entries.time_entries_namespace import ( + time_entries_dao, + ) + + repository_find_all_mock = mocker.patch.object( + time_entries_dao.repository, 'find_all', return_value=[] + ) response = client.get(url, headers=valid_header, follow_redirects=True) date_range = get_date_range_of_month(year, month) - conditions = { - 'owner_id': owner_id - } + conditions = {'owner_id': owner_id} + + assert HTTPStatus.OK == response.status_code + assert json.loads(response.data) is not None + repository_find_all_mock.assert_called_once_with( + ANY, conditions=conditions, date_range=date_range + ) + + +def test_summary_is_called_with_date_range_from_worked_time_module( + client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + owner_id: str, +): + from time_tracker_api.time_entries.time_entries_namespace import ( + time_entries_dao, + ) + + worked_time.date_range = Mock(return_value=worked_time.date_range()) + repository_find_all_mock = mocker.patch.object( + time_entries_dao.repository, 'find_all', return_value=[] + ) + + response = client.get( + '/time-entries/summary', headers=valid_header, follow_redirects=True + ) + + date_range = worked_time.date_range() + conditions = {'owner_id': owner_id} assert HTTPStatus.OK == response.status_code assert json.loads(response.data) is not None - repository_find_all_mock.assert_called_once_with(ANY, - conditions=conditions, - date_range=date_range) + repository_find_all_mock.assert_called_once_with( + ANY, conditions=conditions, date_range=date_range + ) diff --git a/time_tracker_api/time_entries/custom_modules/worked_time.py b/time_tracker_api/time_entries/custom_modules/worked_time.py new file mode 100644 index 00000000..e4c59eaf --- /dev/null +++ b/time_tracker_api/time_entries/custom_modules/worked_time.py @@ -0,0 +1,126 @@ +from datetime import datetime, timedelta +from commons.data_access_layer.cosmos_db import ( + current_datetime, + current_datetime_str, + datetime_str, + get_current_month, + get_current_year +) + + +def start_datetime_of_current_month() -> datetime: + return datetime(year=get_current_year(), month=get_current_month(), day=1,) + + +def start_datetime_of_current_week() -> datetime: + today = current_datetime() + monday = today - timedelta(days=today.weekday()) + monday = monday.replace(hour=0, minute=0, second=0, microsecond=000000) + return monday + + +def start_datetime_of_current_day() -> datetime: + today = current_datetime() + today = today.replace(hour=0, minute=0, second=0, microsecond=000000) + return today + + +def start_datetime_of_current_month_str() -> str: + return datetime_str(start_datetime_of_current_month()) + + +def str_to_datetime( + value: str, conversion_format: str = '%Y-%m-%dT%H:%M:%S.%f' +) -> datetime: + return datetime.strptime(value, conversion_format) + + +def date_range(): + return { + "start_date": start_datetime_of_current_month_str(), + "end_date": current_datetime_str(), + } + + +def filter_time_entries( + time_entries, start_date: datetime, end_date: datetime = current_datetime() +): + return [ + t + for t in time_entries + if start_date <= str_to_datetime(t.start_date) <= end_date + or start_date <= str_to_datetime(t.end_date) <= end_date + ] + + +def stop_running_time_entry(time_entries): + for t in time_entries: + if t.end_date is None: + t.end_date = current_datetime_str() + + +class WorkedTime: + def __init__(self, time_entries): + self.time_entries = time_entries + + def total_time_in_seconds(self): + times = [] + + for t in self.time_entries: + start_datetime = str_to_datetime(t.start_date) + end_datetime = str_to_datetime(t.end_date) + + elapsed_time = end_datetime - start_datetime + times.append(elapsed_time) + + total_time = timedelta() + for time in times: + total_time += time + + return total_time.total_seconds() + + def hours(self): + return self.total_time_in_seconds() // 3600 + + def minutes(self): + return (self.total_time_in_seconds() % 3600) // 60 + + def seconds(self): + return (self.total_time_in_seconds() % 3600) % 60 + + def summary(self): + return { + "hours": self.hours(), + "minutes": self.minutes(), + "seconds": round(self.seconds(), 2), + } + + +def worked_time_in_day(time_entries): + day_time_entries = filter_time_entries( + time_entries, start_date=start_datetime_of_current_day() + ) + return WorkedTime(day_time_entries).summary() + + +def worked_time_in_week(time_entries): + week_time_entries = filter_time_entries( + time_entries, start_date=start_datetime_of_current_week() + ) + return WorkedTime(week_time_entries).summary() + + +def worked_time_in_month(time_entries): + month_time_entries = filter_time_entries( + time_entries, start_date=start_datetime_of_current_month() + ) + return WorkedTime(month_time_entries).summary() + + +def summary(time_entries): + stop_running_time_entry(time_entries) + return { + 'day': worked_time_in_day(time_entries), + 'week': worked_time_in_week(time_entries), + 'month': worked_time_in_month(time_entries), + } diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 31044f55..955b5984 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -9,13 +9,15 @@ CosmosDBDao, CosmosDBRepository, CustomError, - current_datetime_str, CosmosDBModel, + current_datetime_str, get_date_range_of_month, get_current_year, get_current_month, ) from commons.data_access_layer.database import EventContext + +from time_tracker_api.time_entries.custom_modules import worked_time from time_tracker_api.database import CRUDDao, APICosmosDBDao from time_tracker_api.security import current_user_id @@ -105,7 +107,10 @@ def create_sql_date_range_filter(date_range: dict) -> str: return '' def find_all( - self, event_context: EventContext, conditions: dict, date_range: dict + self, + event_context: EventContext, + conditions: dict = {}, + date_range: dict = {}, ): custom_sql_conditions = [] custom_sql_conditions.append( @@ -332,6 +337,19 @@ def find_running(self): event_ctx.tenant_id, event_ctx.user_id ) + def get_worked_time(self, conditions: dict = {}): + event_ctx = self.create_event_context( + "read", "Summary of worked time in the current month" + ) + conditions.update({"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) + @staticmethod def handle_date_filter_args(args: dict) -> dict: if 'month' and 'year' in args: diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index 05c8e94a..df9f70c7 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -247,3 +247,15 @@ class ActiveTimeEntry(Resource): def get(self): """Find the time entry that is running""" return time_entries_dao.find_running() + + +@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.doc('summary_of_worked_time') + def get(self): + """Find the summary of worked time""" + return time_entries_dao.get_worked_time()