From 80fa31e9b96a09f39f82fbd95049d79f4fc76ed4 Mon Sep 17 00:00:00 2001 From: Dickson Armijos Date: Thu, 11 Jun 2020 09:28:54 -0500 Subject: [PATCH 001/262] 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 002/262] 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 003/262] 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 004/262] 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 005/262] 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 006/262] 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 '