diff --git a/README.md b/README.md index 7f905643..7b9efd04 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ automatically [pip](https://pip.pypa.io/en/stable/) as well. python3 -m pip install -r requirements//.txt ``` - Where is one of the executable app namespace, e.g. `time_tracker_api` or `time_tracker_events`. + Where `` is one of the executable app namespace, e.g. `time_tracker_api` or `time_tracker_events`. The `stage` can be * `dev`: Used for working locally diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index 541a4786..9c08952c 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -4,6 +4,7 @@ import azure.cosmos.cosmos_client as cosmos_client import azure.cosmos.exceptions as exceptions +import flask from azure.cosmos import ContainerProxy, PartitionKey from flask import Flask from werkzeug.exceptions import HTTPException @@ -101,8 +102,8 @@ def __init__( raise ValueError("The cosmos_db module has not been initialized!") self.mapper = mapper self.order_fields = order_fields if order_fields else [] - self.container: ContainerProxy = self.cosmos_helper.db.get_container_client( - container_id + self.container: ContainerProxy = ( + self.cosmos_helper.db.get_container_client(container_id) ) self.partition_key_attribute = partition_key_attribute @@ -266,7 +267,6 @@ def find_all( ), order_clause=self.create_sql_order_clause(), ) - result = self.container.query_items( query=query_str, parameters=params, @@ -277,6 +277,50 @@ def find_all( function_mapper = self.get_mapper_or_dict(mapper) return list(map(function_mapper, result)) + def count( + self, + event_context: EventContext, + conditions: dict = None, + custom_sql_conditions: List[str] = None, + custom_params: dict = None, + visible_only=True, + ): + conditions = conditions if conditions else {} + custom_sql_conditions = ( + custom_sql_conditions if custom_sql_conditions else [] + ) + custom_params = custom_params if custom_params else {} + partition_key_value = self.find_partition_key_value(event_context) + params = [ + {"name": "@partition_key_value", "value": partition_key_value}, + ] + params.extend(self.generate_params(conditions)) + params.extend(custom_params) + query_str = """ + SELECT VALUE COUNT(1) FROM c + WHERE c.{partition_key_attribute}=@partition_key_value + {conditions_clause} + {visibility_condition} + {custom_sql_conditions_clause} + """.format( + partition_key_attribute=self.partition_key_attribute, + visibility_condition=self.create_sql_condition_for_visibility( + visible_only + ), + conditions_clause=self.create_sql_where_conditions(conditions), + custom_sql_conditions_clause=self.create_custom_sql_conditions( + custom_sql_conditions + ), + ) + + flask.current_app.logger.debug(query_str) + result = self.container.query_items( + query=query_str, + parameters=params, + partition_key=partition_key_value, + ) + return result.next() + def partial_update( self, id: str, @@ -286,7 +330,10 @@ def partial_update( mapper: Callable = None, ): item_data = self.find( - id, event_context, visible_only=visible_only, mapper=dict, + id, + event_context, + visible_only=visible_only, + mapper=dict, ) item_data.update(changes) return self.update(id, item_data, event_context, mapper=mapper) @@ -304,7 +351,10 @@ def update( return function_mapper(self.container.replace_item(id, body=item_data)) def delete( - self, id: str, event_context: EventContext, mapper: Callable = None, + self, + id: str, + event_context: EventContext, + mapper: Callable = None, ): return self.partial_update( id, @@ -327,7 +377,7 @@ def get_mapper_or_dict(self, alternative_mapper: Callable) -> Callable: def get_page_size_or(self, custom_page_size: int) -> int: # TODO The default value should be taken from the Azure Feature Manager # or any other repository for the settings - return custom_page_size or 100 + return custom_page_size or 9999 def on_update(self, update_item_data: dict, event_context: EventContext): pass diff --git a/tests/commons/data_access_layer/cosmos_db_test.py b/tests/commons/data_access_layer/cosmos_db_test.py index ad3bd7da..06e79f85 100644 --- a/tests/commons/data_access_layer/cosmos_db_test.py +++ b/tests/commons/data_access_layer/cosmos_db_test.py @@ -82,7 +82,10 @@ def test_create_with_diff_unique_data_but_same_tenant_should_succeed( ): new_data = sample_item.copy() new_data.update( - {'id': fake.uuid4(), 'email': fake.safe_email(),} + { + 'id': fake.uuid4(), + 'email': fake.safe_email(), + } ) result = cosmos_db_repository.create(new_data, event_context) @@ -97,7 +100,9 @@ def test_create_with_same_id_should_fail( try: new_data = sample_item.copy() new_data.update( - {'email': fake.safe_email(),} + { + 'email': fake.safe_email(), + } ) cosmos_db_repository.create(new_data, event_context) @@ -134,7 +139,9 @@ def test_create_with_same_id_but_diff_partition_key_attrib_should_succeed( new_data = sample_item.copy() new_data.update( - {'tenant_id': another_tenant_id,} + { + 'tenant_id': another_tenant_id, + } ) result = cosmos_db_repository.create(new_data, another_event_context) @@ -310,6 +317,15 @@ def test_find_all_with_offset( assert result_after_the_second_item == result_all_items[2:] +def test_count( + cosmos_db_repository: CosmosDBRepository, event_context: EventContext +): + counter = cosmos_db_repository.count(event_context) + print('test counter: ', counter) + + assert counter == 10 + + @pytest.mark.parametrize( 'mapper,expected_type', [(None, dict), (dict, dict), (Person, Person)] ) @@ -405,7 +421,10 @@ def test_update_with_mapper( ): changed_item = sample_item.copy() changed_item.update( - {'name': fake.name(), 'email': fake.safe_email(),} + { + 'name': fake.name(), + 'email': fake.safe_email(), + } ) updated_item = cosmos_db_repository.update( 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 e4984d8b..30cd2fce 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 @@ -730,26 +730,31 @@ def test_summary_is_called_with_date_range_from_worked_time_module( def test_paginated_fails_with_no_params( - client: FlaskClient, valid_header: dict, + client: FlaskClient, + valid_header: dict, ): response = client.get('/time-entries/paginated', headers=valid_header) assert HTTPStatus.BAD_REQUEST == response.status_code def test_paginated_succeeds_with_valid_params( - client: FlaskClient, valid_header: dict, + client: FlaskClient, + valid_header: dict, ): response = client.get( - '/time-entries/paginated?start=10&length=10', headers=valid_header + '/time-entries/paginated?start_date=2020-09-10T00:00:00-05:00&end_date=2020-09-10T23:59:59-05:00&timezone_offset=300&start=0&length=5', + headers=valid_header, ) assert HTTPStatus.OK == response.status_code def test_paginated_response_contains_expected_props( - client: FlaskClient, valid_header: dict, + client: FlaskClient, + valid_header: dict, ): response = client.get( - '/time-entries/paginated?start=10&length=10', headers=valid_header + '/time-entries/paginated?start_date=2020-09-10T00:00:00-05:00&end_date=2020-09-10T23:59:59-05:00&timezone_offset=300&start=0&length=5', + headers=valid_header, ) assert 'data' in json.loads(response.data) assert 'records_total' in json.loads(response.data) @@ -761,7 +766,8 @@ def test_paginated_sends_max_count_and_offset_on_call_to_repository( time_entries_dao.repository.find_all = Mock(return_value=[]) response = client.get( - '/time-entries/paginated?start=10&length=10', headers=valid_header + '/time-entries/paginated?start_date=2020-09-10T00:00:00-05:00&end_date=2020-09-10T23:59:59-05:00&timezone_offset=300&start=0&length=5', + headers=valid_header, ) time_entries_dao.repository.find_all.assert_called_once() diff --git a/time_tracker_api/__init__.py b/time_tracker_api/__init__.py index c7b48da7..2a5dbce5 100644 --- a/time_tracker_api/__init__.py +++ b/time_tracker_api/__init__.py @@ -6,8 +6,9 @@ flask_app: Flask = None -def create_app(config_path='time_tracker_api.config.DefaultConfig', - config_data=None): +def create_app( + config_path='time_tracker_api.config.DefaultConfig', config_data=None +): global flask_app flask_app = Flask(__name__) @@ -36,13 +37,15 @@ def init_app_config(app: Flask, config_path: str, config_data: dict = None): def init_app(app: Flask): from time_tracker_api.database import init_app as init_database + init_database(app) from time_tracker_api.api import init_app + init_app(app) if app.config.get('DEBUG'): - app.logger.setLevel(logging.INFO) + app.logger.setLevel(logging.DEBUG) add_debug_toolbar(app) add_werkzeug_proxy_fix(app) @@ -62,16 +65,18 @@ def add_debug_toolbar(app: Flask): 'flask_debugtoolbar.panels.template.TemplateDebugPanel', 'flask_debugtoolbar.panels.logger.LoggingPanel', 'flask_debugtoolbar.panels.route_list.RouteListDebugPanel', - 'flask_debugtoolbar.panels.profiler.ProfilerDebugPanel' + 'flask_debugtoolbar.panels.profiler.ProfilerDebugPanel', ) from flask_debugtoolbar import DebugToolbarExtension + toolbar = DebugToolbarExtension() toolbar.init_app(app) def enable_cors(app: Flask, cors_origins: str): from flask_cors import CORS + cors_origins_list = cors_origins.split(",") CORS(app, resources={r"/*": {"origins": cors_origins_list}}) app.logger.info("Set CORS access to [%s]" % cors_origins) @@ -79,5 +84,6 @@ def enable_cors(app: Flask, cors_origins: str): def add_werkzeug_proxy_fix(app: Flask): from werkzeug.middleware.proxy_fix import ProxyFix + app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) app.logger.info("Add ProxyFix to serve swagger.json over https.") diff --git a/time_tracker_api/api.py b/time_tracker_api/api.py index 4910e8d6..e09f798d 100644 --- a/time_tracker_api/api.py +++ b/time_tracker_api/api.py @@ -126,17 +126,20 @@ def init_app(app: Flask): @api.errorhandler(CosmosResourceExistsError) def handle_cosmos_resource_exists_error(error): + app.logger.error(error) return {'message': 'It already exists'}, HTTPStatus.CONFLICT @api.errorhandler(CosmosResourceNotFoundError) @api.errorhandler(StopIteration) def handle_not_found_errors(error): + app.logger.error(error) return {'message': 'It was not found'}, HTTPStatus.NOT_FOUND @api.errorhandler(CosmosHttpResponseError) def handle_cosmos_http_response_error(error): + app.logger.error(error) return ( {'message': 'Invalid request. Please verify your data.'}, HTTPStatus.BAD_REQUEST, @@ -145,6 +148,7 @@ def handle_cosmos_http_response_error(error): @api.errorhandler(AttributeError) def handle_attribute_error(error): + app.logger.error(error) return ( {'message': "There are missing attributes"}, HTTPStatus.UNPROCESSABLE_ENTITY, @@ -153,6 +157,7 @@ def handle_attribute_error(error): @api.errorhandler(CustomError) def handle_custom_error(error): + app.logger.error(error) return {'message': error.description}, error.code diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 885495c3..2a8d47be 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -185,6 +185,33 @@ def find_all_entries( ) return time_entries + def count( + self, + event_context: EventContext, + conditions: dict = None, + custom_sql_conditions: List[str] = None, + date_range: dict = None, + ): + conditions = conditions if conditions else {} + custom_sql_conditions = ( + custom_sql_conditions if custom_sql_conditions else [] + ) + date_range = date_range if date_range else {} + + custom_sql_conditions.append( + self.create_sql_date_range_filter(date_range) + ) + + custom_params = self.generate_params(date_range) + counter = CosmosDBRepository.count( + self, + event_context=event_context, + conditions=conditions, + custom_sql_conditions=custom_sql_conditions, + custom_params=custom_params, + ) + return counter + def find_all( self, event_context: EventContext, @@ -428,9 +455,9 @@ def get_all(self, conditions: dict = None, **kwargs) -> list: conditions.update({"owner_id": event_ctx.user_id}) custom_query = self.build_custom_query( - is_admin=event_ctx.is_admin, conditions=conditions, + is_admin=event_ctx.is_admin, + conditions=conditions, ) - date_range = self.handle_date_filter_args(args=conditions) limit = conditions.get("limit", None) conditions.pop("limit", None) @@ -443,18 +470,30 @@ def get_all(self, conditions: dict = None, **kwargs) -> list: ) def get_all_paginated(self, conditions: dict = None, **kwargs) -> list: + get_all_conditions = dict(conditions) + get_all_conditions.pop("length") + get_all_conditions.pop("start") event_ctx = self.create_event_context("read-many") + get_all_conditions.update({"owner_id": event_ctx.user_id}) + custom_query = self.build_custom_query( + is_admin=event_ctx.is_admin, + conditions=get_all_conditions, + ) + date_range = self.handle_date_filter_args(args=get_all_conditions) + records_total = self.repository.count( + event_ctx, + conditions=get_all_conditions, + custom_sql_conditions=custom_query, + date_range=date_range, + ) conditions.update({"owner_id": event_ctx.user_id}) - custom_query = self.build_custom_query( - is_admin=event_ctx.is_admin, conditions=conditions, + is_admin=event_ctx.is_admin, + conditions=conditions, ) - date_range = self.handle_date_filter_args(args=conditions) - length = conditions.get("length", None) conditions.pop("length", None) - start = conditions.get("start", None) conditions.pop("start", None) @@ -468,7 +507,7 @@ def get_all_paginated(self, conditions: dict = None, **kwargs) -> list: ) return { - 'records_total': len(time_entries), + 'records_total': records_total, 'data': time_entries, } @@ -494,7 +533,11 @@ def update(self, id, data: dict, description=None): time_entry = self.repository.find(id, event_ctx) self.check_whether_current_user_owns_item(time_entry) - return self.repository.partial_update(id, data, event_ctx,) + return self.repository.partial_update( + id, + data, + event_ctx, + ) def stop(self, id): event_ctx = self.create_event_context("update", "Stop time entry") @@ -504,7 +547,9 @@ def stop(self, id): self.check_time_entry_is_not_stopped(time_entry) return self.repository.partial_update( - id, {'end_date': current_datetime_str()}, event_ctx, + id, + {'end_date': current_datetime_str()}, + event_ctx, ) def restart(self, id): @@ -515,7 +560,9 @@ def restart(self, id): self.check_time_entry_is_not_started(time_entry) return self.repository.partial_update( - id, {'end_date': None}, event_ctx, + id, + {'end_date': None}, + event_ctx, ) def delete(self, id): @@ -523,7 +570,8 @@ def delete(self, id): time_entry = self.repository.find(id, event_ctx) self.check_whether_current_user_owns_item(time_entry) self.repository.delete( - id, event_ctx, + id, + event_ctx, ) def find_running(self): @@ -573,6 +621,10 @@ def handle_date_filter_args(args: dict) -> dict: 'end_date': datetime_str(end_date), } + @staticmethod + def current_user_id(): + return super().current_user_id() + def create_dao() -> TimeEntriesDao: repository = TimeEntryCosmosDBRepository() diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index 840aa869..da614e03 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -156,7 +156,9 @@ time_entry_response_fields.update(common_fields) time_entry = ns.inherit( - 'TimeEntry', time_entry_input, time_entry_response_fields, + 'TimeEntry', + time_entry_input, + time_entry_response_fields, ) time_entries_dao = create_dao() @@ -359,7 +361,8 @@ def get(self): 'TimeEntryPaginated', { 'records_total': fields.Integer( - title='Records total', description='Total number of entries.', + title='Records total', + description='Total number of entries.', ), 'data': fields.List(fields.Nested(time_entry)), }, @@ -408,7 +411,7 @@ def get(self): paginated_attribs_parser.add_argument( 'start_date', - required=False, + required=True, store_missing=False, help="(Filter) Start to filter by", location='args', @@ -416,7 +419,7 @@ def get(self): paginated_attribs_parser.add_argument( 'end_date', - required=False, + required=True, store_missing=False, help="(Filter) End time to filter by", location='args',