From a2758de96c1d9a2246346faa9b336c74df8abdae Mon Sep 17 00:00:00 2001 From: roberto Date: Wed, 27 May 2020 12:41:31 -0500 Subject: [PATCH 001/281] 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 002/281] 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 003/281] 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 004/281] 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 005/281] 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 006/281] 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 007/281] 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 008/281] 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 009/281] 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 010/281] 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 011/281] 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 012/281] 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 013/281] 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 014/281] 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 015/281] 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 016/281] 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 017/281] 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 018/281] 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 019/281] 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 020/281] 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 021/281] 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 022/281] 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 023/281] 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 024/281] 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 025/281] 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 '