diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index f064b175..e098ce9c 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -219,8 +219,9 @@ class CosmosDBDao(CRUDDao): def __init__(self, repository: CosmosDBRepository): self.repository = repository - def get_all(self) -> list: - return self.repository.find_all(partition_key_value=self.partition_key_value) + def get_all(self, conditions: dict = {}) -> list: + return self.repository.find_all(partition_key_value=self.partition_key_value, + conditions=conditions) def get(self, id): return self.repository.find(id, partition_key_value=self.partition_key_value) diff --git a/commons/data_access_layer/database.py b/commons/data_access_layer/database.py index 27bee0ca..b30afe2c 100644 --- a/commons/data_access_layer/database.py +++ b/commons/data_access_layer/database.py @@ -15,7 +15,7 @@ class CRUDDao(abc.ABC): @abc.abstractmethod - def get_all(self): + def get_all(self, conditions: dict): raise NotImplementedError # pragma: no cover @abc.abstractmethod diff --git a/tests/time_tracker_api/activities/activities_namespace_test.py b/tests/time_tracker_api/activities/activities_namespace_test.py index 508e7f88..b2519bae 100644 --- a/tests/time_tracker_api/activities/activities_namespace_test.py +++ b/tests/time_tracker_api/activities/activities_namespace_test.py @@ -67,7 +67,8 @@ def test_list_all_activities(client: FlaskClient, assert HTTPStatus.OK == response.status_code json_data = json.loads(response.data) assert [] == json_data - repository_find_all_mock.assert_called_once_with(partition_key_value=tenant_id) + repository_find_all_mock.assert_called_once_with(partition_key_value=tenant_id, + conditions={}) def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, @@ -258,4 +259,4 @@ def test_delete_activity_should_return_422_for_invalid_id_format(client: FlaskCl assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + partition_key_value=tenant_id) \ No newline at end of file diff --git a/tests/time_tracker_api/api_test.py b/tests/time_tracker_api/api_test.py new file mode 100644 index 00000000..a9af25da --- /dev/null +++ b/tests/time_tracker_api/api_test.py @@ -0,0 +1,24 @@ +from flask_restplus.reqparse import RequestParser +from pytest import fail + + +def test_create_attributes_filter_with_invalid_attribute_should_fail(): + from time_tracker_api.api import create_attributes_filter + from time_tracker_api.projects.projects_namespace import project, ns + + try: + create_attributes_filter(ns, project, ['invalid_attribute']) + + fail("It was expected to fail") + except Exception as e: + assert type(e) is ValueError + + +def test_create_attributes_filter_with_valid_attribute_should_succeed(): + from time_tracker_api.api import create_attributes_filter + from time_tracker_api.projects.projects_namespace import project, ns + + filter = create_attributes_filter(ns, project, ['name']) + + assert filter is not None + assert type(filter) is RequestParser diff --git a/tests/time_tracker_api/project_types/project_types_namespace_test.py b/tests/time_tracker_api/project_types/project_types_namespace_test.py index 3d0b3135..0e7385fa 100644 --- a/tests/time_tracker_api/project_types/project_types_namespace_test.py +++ b/tests/time_tracker_api/project_types/project_types_namespace_test.py @@ -269,4 +269,4 @@ def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + partition_key_value=tenant_id) \ No newline at end of file diff --git a/tests/time_tracker_api/projects/projects_namespace_test.py b/tests/time_tracker_api/projects/projects_namespace_test.py index 6c7ff5eb..0f042e2f 100644 --- a/tests/time_tracker_api/projects/projects_namespace_test.py +++ b/tests/time_tracker_api/projects/projects_namespace_test.py @@ -269,4 +269,4 @@ def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + partition_key_value=tenant_id) \ No newline at end of file diff --git a/time_tracker_api/activities/activities_namespace.py b/time_tracker_api/activities/activities_namespace.py index d4f42f20..5f360f7a 100644 --- a/time_tracker_api/activities/activities_namespace.py +++ b/time_tracker_api/activities/activities_namespace.py @@ -1,4 +1,5 @@ from faker import Faker +from flask import request from flask_restplus import fields, Resource, Namespace from flask_restplus._http import HTTPStatus diff --git a/time_tracker_api/api.py b/time_tracker_api/api.py index b269136b..78939da8 100644 --- a/time_tracker_api/api.py +++ b/time_tracker_api/api.py @@ -1,11 +1,14 @@ from azure.cosmos.exceptions import CosmosResourceExistsError, CosmosResourceNotFoundError, CosmosHttpResponseError from faker import Faker from flask import current_app as app -from flask_restplus import Api, fields +from flask_restplus import Api, fields, Model +from flask_restplus import namespace from flask_restplus._http import HTTPStatus +from flask_restplus.reqparse import RequestParser from commons.data_access_layer.cosmos_db import CustomError from time_tracker_api import security +from time_tracker_api.security import UUID_REGEX from time_tracker_api.version import __version__ faker = Faker() @@ -18,8 +21,22 @@ security="TimeTracker JWT", ) -# For matching UUIDs -UUID_REGEX = '[0-9a-f]{8}\-[0-9a-f]{4}\-4[0-9a-f]{3}\-[89ab][0-9a-f]{3}\-[0-9a-f]{12}' + +# Filters +def create_attributes_filter(ns: namespace, model: Model, filter_attrib_names: dict) -> RequestParser: + attribs_parser = ns.parser() + model_attributes = model.resolved + for attrib in filter_attrib_names: + if attrib not in model_attributes: + raise ValueError(f"{attrib} is not a valid filter attribute for {model.name}") + + attribs_parser.add_argument(attrib, required=False, + store_missing=False, + help="(Filter) %s " % model_attributes[attrib].description, + location='args') + + return attribs_parser + # Common models structure common_fields = { diff --git a/time_tracker_api/customers/customers_namespace.py b/time_tracker_api/customers/customers_namespace.py index f3b01fcc..76669c7e 100644 --- a/time_tracker_api/customers/customers_namespace.py +++ b/time_tracker_api/customers/customers_namespace.py @@ -1,6 +1,7 @@ from faker import Faker from flask_restplus import Namespace, Resource, fields from flask_restplus._http import HTTPStatus +from flask import request from time_tracker_api.api import common_fields from time_tracker_api.customers.customers_model import create_dao diff --git a/time_tracker_api/project_types/project_types_namespace.py b/time_tracker_api/project_types/project_types_namespace.py index 0e6c0ac6..b937dc3e 100644 --- a/time_tracker_api/project_types/project_types_namespace.py +++ b/time_tracker_api/project_types/project_types_namespace.py @@ -1,9 +1,11 @@ from faker import Faker +from flask import request from flask_restplus import Namespace, Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import common_fields, UUID_REGEX +from time_tracker_api.api import common_fields, create_attributes_filter from time_tracker_api.project_types.project_types_model import create_dao +from time_tracker_api.security import UUID_REGEX faker = Faker() @@ -16,7 +18,7 @@ required=True, max_length=50, description='Name of the project type', - example=faker.random_element(["Customer","Training","Internal"]), + example=faker.random_element(["Customer", "Training", "Internal"]), ), 'description': fields.String( title='Description', @@ -53,14 +55,21 @@ project_type_dao = create_dao() +attributes_filter = create_attributes_filter(ns, project_type, [ + "customer_id", + "parent_id", +]) + @ns.route('') class ProjectTypes(Resource): @ns.doc('list_project_types') + @ns.expect(attributes_filter) @ns.marshal_list_with(project_type) def get(self): """List all project types""" - return project_type_dao.get_all() + conditions = attributes_filter.parse_args() + return project_type_dao.get_all(conditions=conditions) @ns.doc('create_project_type') @ns.response(HTTPStatus.CONFLICT, 'This project type already exists') diff --git a/time_tracker_api/projects/projects_namespace.py b/time_tracker_api/projects/projects_namespace.py index 5fe4ad1e..15613f92 100644 --- a/time_tracker_api/projects/projects_namespace.py +++ b/time_tracker_api/projects/projects_namespace.py @@ -2,8 +2,9 @@ from flask_restplus import Namespace, Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import common_fields, UUID_REGEX +from time_tracker_api.api import common_fields, create_attributes_filter from time_tracker_api.projects.projects_model import create_dao +from time_tracker_api.security import UUID_REGEX faker = Faker() @@ -36,8 +37,7 @@ 'project_type_id': fields.String( title='Identifier of the project type', required=False, - description='This id allows to created a tree-like structure for projects, ' - 'grouped by project types', + description='Id of the project type it belongs. This allows grouping the projects.', pattern=UUID_REGEX, example=faker.uuid4(), ), @@ -54,14 +54,21 @@ project_dao = create_dao() +attributes_filter = create_attributes_filter(ns, project, [ + "customer_id", + "project_type_id", +]) + @ns.route('') class Projects(Resource): @ns.doc('list_projects') + @ns.expect(attributes_filter) @ns.marshal_list_with(project) def get(self): """List all projects""" - return project_dao.get_all() + conditions = attributes_filter.parse_args() + return project_dao.get_all(conditions=conditions) @ns.doc('create_project') @ns.response(HTTPStatus.CONFLICT, 'This project already exists') diff --git a/time_tracker_api/security.py b/time_tracker_api/security.py index 754671e6..b5919a1d 100644 --- a/time_tracker_api/security.py +++ b/time_tracker_api/security.py @@ -24,8 +24,10 @@ } } -iss_claim_pattern = re.compile( - r"(.*).b2clogin.com/(?P[0-9a-f]{8}\-[0-9a-f]{4}\-4[0-9a-f]{3}\-[89ab][0-9a-f]{3}\-[0-9a-f]{12})") +# For matching UUIDs +UUID_REGEX = '[0-9a-f]{8}\-[0-9a-f]{4}\-4[0-9a-f]{3}\-[89ab][0-9a-f]{3}\-[0-9a-f]{12}' + +iss_claim_pattern = re.compile(r"(.*).b2clogin.com/(?P%s)" % UUID_REGEX) def current_user_id() -> str: diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 2ba04889..16bb557e 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -167,9 +167,10 @@ def check_whether_current_user_owns_item(cls, data: dict): raise CustomError(HTTPStatus.FORBIDDEN, "The current user is not the owner of this time entry") - def get_all(self) -> list: + def get_all(self, conditions: dict = {}) -> list: + conditions.update({"owner_id": self.current_user_id()}) return self.repository.find_all(partition_key_value=self.partition_key_value, - conditions={"owner_id": self.current_user_id()}) + conditions=conditions) def get(self, id): return self.repository.find(id, diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index e107729b..8b960c37 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -6,7 +6,8 @@ from commons.data_access_layer.cosmos_db import current_datetime, datetime_str, current_datetime_str from commons.data_access_layer.database import COMMENTS_MAX_LENGTH -from time_tracker_api.api import common_fields, UUID_REGEX +from time_tracker_api.api import common_fields, create_attributes_filter +from time_tracker_api.security import UUID_REGEX from time_tracker_api.time_entries.time_entries_model import create_dao faker = Faker() @@ -103,6 +104,12 @@ time_entries_dao = create_dao() +attributes_filter = create_attributes_filter(ns, time_entry, [ + "project_id", + "activity_id", + "uri", +]) + @ns.route('') class TimeEntries(Resource):