From f65996304296919c5db220751e3a20bff7faf888 Mon Sep 17 00:00:00 2001 From: EliuX Date: Mon, 20 Apr 2020 18:45:47 -0500 Subject: [PATCH 001/361] feat: Close #89 Add endpoint to get running time entry --- tests/conftest.py | 21 ++++++++++++-- .../time_entries/time_entries_model_test.py | 16 ++++++++++ .../time_entries_namespace_test.py | 29 ++++++++++++++++++- time_tracker_api/api.py | 3 +- .../time_entries/time_entries_model.py | 21 ++++++++++++++ .../time_entries/time_entries_namespace.py | 13 ++++++++- 6 files changed, 98 insertions(+), 5 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 155c29b6..97b80b65 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from flask import Flask from flask.testing import FlaskClient -from commons.data_access_layer.cosmos_db import CosmosDBRepository +from commons.data_access_layer.cosmos_db import CosmosDBRepository, datetime_str, current_datetime from time_tracker_api import create_app from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository @@ -128,6 +128,23 @@ def another_item(cosmos_db_repository: CosmosDBRepository, tenant_id: str) -> di return cosmos_db_repository.create(sample_item_data) -@pytest.yield_fixture(scope="module") +@pytest.fixture(scope="module") def time_entry_repository() -> TimeEntryCosmosDBRepository: return TimeEntryCosmosDBRepository() + + +@pytest.yield_fixture(scope="module") +def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository, + owner_id: str, + tenant_id: str): + created_time_entry = time_entry_repository.create({ + "project_id": fake.uuid4(), + "start_date": datetime_str(current_datetime()), + "owner_id": owner_id, + "tenant_id": tenant_id + }) + + yield created_time_entry + + time_entry_repository.delete(id=created_time_entry.id, + partition_key_value=tenant_id) 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 4d0f42e0..2b58c036 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 @@ -78,3 +78,19 @@ def test_find_interception_should_ignore_id_of_existing_item(owner_id: str, assert not any([existing_item.id == item.id for item in non_colliding_result]) finally: time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_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(partition_key_value=running_time_entry.tenant_id) + + assert found_time_entry is not None + assert found_time_entry.id == running_time_entry.id + + +def test_find_running_should_not_find_any_item(tenant_id: str, + time_entry_repository: TimeEntryCosmosDBRepository): + try: + time_entry_repository.find_running(partition_key_value=tenant_id) + except Exception as e: + assert type(e) is StopIteration 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 975797e5..34d34259 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 @@ -25,7 +25,8 @@ fake_time_entry = ({ "id": fake.random_int(1, 9999), "running": True, -}).update(valid_time_entry_input) +}) +fake_time_entry.update(valid_time_entry_input) def test_create_time_entry_with_invalid_date_range_should_raise_bad_request_error(client: FlaskClient, @@ -309,3 +310,29 @@ def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, moc changes={"end_date": None}, partition_key_value=current_user_tenant_id(), peeker=ANY) + + +def test_get_running_should_call_find_running(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao + repository_update_mock = mocker.patch.object(time_entries_dao.repository, + 'find_running', + return_value=fake_time_entry) + + response = client.get("/time-entries/running", follow_redirects=True) + + assert HTTPStatus.OK == response.status_code + assert json.loads(response.data) is not None + repository_update_mock.assert_called_once_with(partition_key_value=current_user_tenant_id()) + + +def test_get_running_should_return_not_found_if_find_running_throws_StopIteration(client: FlaskClient, + mocker: MockFixture): + from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao + repository_update_mock = mocker.patch.object(time_entries_dao.repository, + 'find_running', + side_effect=StopIteration) + + response = client.get("/time-entries/running", follow_redirects=True) + + assert HTTPStatus.NOT_FOUND == response.status_code + repository_update_mock.assert_called_once_with(partition_key_value=current_user_tenant_id()) diff --git a/time_tracker_api/api.py b/time_tracker_api/api.py index ccf637ac..080dea08 100644 --- a/time_tracker_api/api.py +++ b/time_tracker_api/api.py @@ -77,7 +77,8 @@ def handle_cosmos_resource_exists_error(error): @api.errorhandler(CosmosResourceNotFoundError) -def handle_cosmos_resource_not_found_error(error): +@api.errorhandler(StopIteration) +def handle_not_found_errors(error): return {'message': 'It was not found'}, HTTPStatus.NOT_FOUND diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 9145f6db..f2646edc 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -1,3 +1,4 @@ +import abc from dataclasses import dataclass, field from typing import List, Callable @@ -15,6 +16,10 @@ class TimeEntriesDao(CRUDDao): def current_user_id(): return current_user_id() + @abc.abstractmethod + def find_running(self): + pass + container_definition = { 'id': 'time_entry', @@ -102,6 +107,19 @@ def find_interception_with_date_range(self, start_date, end_date, owner_id, part function_mapper = self.get_mapper_or_dict(mapper) return list(map(function_mapper, result)) + def find_running(self, partition_key_value: str, mapper: Callable = None): + result = self.container.query_items( + query=""" + SELECT * from c + WHERE NOT IS_DEFINED(c.end_date) OR c.end_date = null + OFFSET 0 LIMIT 1 + """, + partition_key=partition_key_value, + max_item_count=1) + + function_mapper = self.get_mapper_or_dict(mapper) + return function_mapper(next(result)) + def validate_data(self, data): if data.get('end_date') is not None: if data['end_date'] <= data.get('start_date'): @@ -156,6 +174,9 @@ def delete(self, id): self.repository.delete(id, partition_key_value=self.partition_key_value, peeker=self.check_whether_current_user_owns_item) + def find_running(self): + return self.repository.find_running(partition_key_value=self.partition_key_value) + 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 0b65c25a..a7bc2a42 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -171,7 +171,7 @@ def post(self, id): @ns.route('//restart') @ns.response(HTTPStatus.NOT_FOUND, 'Stopped time entry not found') -@ns.response(HTTPStatus.UNPROCESSABLE_ENTITY, 'The id has an invalid format.') +@ns.response(HTTPStatus.UNPROCESSABLE_ENTITY, 'The id has an invalid format') @ns.param('id', 'The unique identifier of a stopped time entry') class RestartTimeEntry(Resource): @ns.doc('restart_time_entry') @@ -181,3 +181,14 @@ def post(self, id): return time_entries_dao.update(id, { 'end_date': None }) + + +@ns.route('/running') +@ns.response(HTTPStatus.OK, 'The time entry that is active: currently running') +@ns.response(HTTPStatus.NOT_FOUND, 'There is no time entry running right now') +class ActiveTimeEntry(Resource): + @ns.doc('running_time_entry') + @ns.marshal_with(time_entry) + def get(self): + """Find the time entry that is running""" + return time_entries_dao.find_running() From b8bbd8e145c4bdc6a5dfb495a381c29130101e8f Mon Sep 17 00:00:00 2001 From: semantic-release Date: Tue, 21 Apr 2020 18:13:32 +0000 Subject: [PATCH 002/361] 0.4.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 73e3bb4f..abeeedbf 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.3.2' +__version__ = '0.4.0' From d315d5bded6b39b72468bcaa8500323f89e8ebbe Mon Sep 17 00:00:00 2001 From: roberto Date: Tue, 21 Apr 2020 13:50:30 -0500 Subject: [PATCH 003/361] fix: Add ProxyFix to serve swagger.json over HTTPs --- time_tracker_api/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/time_tracker_api/__init__.py b/time_tracker_api/__init__.py index 6e633c7d..7a33a4db 100644 --- a/time_tracker_api/__init__.py +++ b/time_tracker_api/__init__.py @@ -46,6 +46,8 @@ def init_app(app: Flask): app.logger.setLevel(logging.INFO) add_debug_toolbar(app) + add_werkzeug_proxy_fix(app) + cors_origins = app.config.get('CORS_ORIGINS') if cors_origins: enable_cors(app, cors_origins) @@ -74,3 +76,9 @@ def enable_cors(app: Flask, cors_origins: str): cors_origins_list = cors_origins.split(",") CORS(app, resources={r"/*": {"origins": cors_origins_list}}) app.logger.info("Set CORS access to [%s]" % cors_origins) + + +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.") From 41a73da7d251d6047d60eca0b580c96a7c75ddbc Mon Sep 17 00:00:00 2001 From: semantic-release Date: Tue, 21 Apr 2020 19:06:07 +0000 Subject: [PATCH 004/361] 0.4.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 abeeedbf..f0ede3d3 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.4.0' +__version__ = '0.4.1' From 1a41ed7077c121c595ab29f2b6ff2fcdf8a3618f Mon Sep 17 00:00:00 2001 From: EliuX Date: Tue, 21 Apr 2020 20:00:09 -0500 Subject: [PATCH 005/361] feat: Ack JWT claims for authentication #94 --- requirements/time_tracker_api/prod.txt | 7 +- tests/conftest.py | 52 +++++++++- .../activities/activities_namespace_test.py | 25 +++-- tests/time_tracker_api/security_test.py | 34 +++++++ time_tracker_api/api.py | 5 +- time_tracker_api/config.py | 4 +- time_tracker_api/security.py | 97 +++++++++++++++---- 7 files changed, 194 insertions(+), 30 deletions(-) create mode 100644 tests/time_tracker_api/security_test.py diff --git a/requirements/time_tracker_api/prod.txt b/requirements/time_tracker_api/prod.txt index d72284f5..344bc4bb 100644 --- a/requirements/time_tracker_api/prod.txt +++ b/requirements/time_tracker_api/prod.txt @@ -26,8 +26,11 @@ Flask-Script==2.0.6 #Semantic versioning python-semantic-release==5.2.0 -# The Debug Toolbar +#The Debug Toolbar Flask-DebugToolbar==0.11.0 #CORS -flask-cors==3.0.8 \ No newline at end of file +flask-cors==3.0.8 + +#JWT +PyJWT==1.7.1 diff --git a/tests/conftest.py b/tests/conftest.py index 97b80b65..21de5386 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,50 @@ +from datetime import datetime, timedelta + +import jwt import pytest from faker import Faker -from flask import Flask +from flask import Flask, url_for from flask.testing import FlaskClient from commons.data_access_layer.cosmos_db import CosmosDBRepository, datetime_str, current_datetime from time_tracker_api import create_app +from time_tracker_api.security import get_or_generate_dev_secret_key from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository fake = Faker() Faker.seed() +TEST_USER = { + "name": "testuser@ioet.com", + "password": "secret" +} + + +class User: + def __init__(self, username, password): + self.username = username + self.password = password + + +class AuthActions: + """Auth actions container in tests""" + + def __init__(self, app, client): + self._app = app + self._client = client + + # def login(self, username=TEST_USER["name"], + # password=TEST_USER["password"]): + # login_url = url_for("security.login", self._app) + # return open_with_basic_auth(self._client, + # login_url, + # username, + # password) + # + # def logout(self): + # return self._client.get(url_for("security.logout", self._app), + # follow_redirects=True) + @pytest.fixture(scope='session') def app() -> Flask: @@ -148,3 +183,18 @@ def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository, time_entry_repository.delete(id=created_time_entry.id, partition_key_value=tenant_id) + + +@pytest.fixture(scope="session") +def valid_jwt(app: Flask) -> str: + expiration_time = datetime.utcnow() + timedelta(seconds=3600) + return jwt.encode({ + "iss": "https://securityioet.b2clogin.com/%s/v2.0/" % fake.uuid4(), + "oid": fake.uuid4(), + 'exp': expiration_time + }, key=get_or_generate_dev_secret_key()).decode("UTF-8") + + +@pytest.fixture(scope="session") +def valid_header(valid_jwt: str) -> dict: + return {'Authorization': "Bearer %s" % valid_jwt} diff --git a/tests/time_tracker_api/activities/activities_namespace_test.py b/tests/time_tracker_api/activities/activities_namespace_test.py index 1b69fd74..90f3f1d6 100644 --- a/tests/time_tracker_api/activities/activities_namespace_test.py +++ b/tests/time_tracker_api/activities/activities_namespace_test.py @@ -19,13 +19,18 @@ }).update(valid_activity_data) -def test_create_activity_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture): +def test_create_activity_should_succeed_with_valid_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao repository_create_mock = mocker.patch.object(activity_dao.repository, 'create', return_value=fake_activity) - response = client.post("/activities", json=valid_activity_data, follow_redirects=True) + response = client.post("/activities", + headers=valid_header, + json=valid_activity_data, + follow_redirects=True) assert HTTPStatus.CREATED == response.status_code repository_create_mock.assert_called_once() @@ -57,7 +62,9 @@ def test_list_all_activities(client: FlaskClient, mocker: MockFixture): repository_find_all_mock.assert_called_once() -def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao valid_id = fake.random_int(1, 9999) @@ -66,7 +73,9 @@ def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: 'find', return_value=fake_activity) - response = client.get("/activities/%s" % valid_id, follow_redirects=True) + response = client.get("/activities/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_activity == json.loads(response.data) @@ -74,7 +83,9 @@ def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: partition_key_value=current_user_tenant_id()) -def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao from werkzeug.exceptions import NotFound @@ -84,7 +95,9 @@ def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClien 'find', side_effect=NotFound) - response = client.get("/activities/%s" % invalid_id, follow_redirects=True) + response = client.get("/activities/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), diff --git a/tests/time_tracker_api/security_test.py b/tests/time_tracker_api/security_test.py new file mode 100644 index 00000000..54ffe5e8 --- /dev/null +++ b/tests/time_tracker_api/security_test.py @@ -0,0 +1,34 @@ +from time_tracker_api.security import parse_jwt, parse_tenant_id_from_iss_claim + + +def test_parse_jwt_with_valid_input(valid_jwt: str): + result = parse_jwt("Bearer %s" % valid_jwt) + + assert result is not None + assert type(result) is dict + + +def test_parse_jwt_with_invalid_input(): + result = parse_jwt("whetever") + + assert result is None + + +def test_parse_tenant_id_from_iss_claim_with_valid_input(): + valid_iss_claim = "https://securityioet.b2clogin.com/b21c4e98-c4bf-420f-9d76-e51c2515c7a4/v2.0/" + + result = parse_tenant_id_from_iss_claim(valid_iss_claim) + + assert result is not None + assert type(result) is str + assert result == "b21c4e98-c4bf-420f-9d76-e51c2515c7a4" + + +def test_parse_tenant_id_from_iss_claim_with_invalid_input(): + invalid_iss_claim1 = "https://securityioet.b2clogin.com/whatever/v2.0/" + invalid_iss_claim2 = "" + + result1 = parse_tenant_id_from_iss_claim(invalid_iss_claim1) + result2 = parse_tenant_id_from_iss_claim(invalid_iss_claim2) + + assert result1 == result2 == None diff --git a/time_tracker_api/api.py b/time_tracker_api/api.py index 080dea08..b269136b 100644 --- a/time_tracker_api/api.py +++ b/time_tracker_api/api.py @@ -5,6 +5,7 @@ from flask_restplus._http import HTTPStatus from commons.data_access_layer.cosmos_db import CustomError +from time_tracker_api import security from time_tracker_api.version import __version__ faker = Faker() @@ -12,7 +13,9 @@ api = Api( version=__version__, title="TimeTracker API", - description="API for the TimeTracker project" + description="API for the TimeTracker project", + authorizations=security.authorizations, + security="TimeTracker JWT", ) # For matching UUIDs diff --git a/time_tracker_api/config.py b/time_tracker_api/config.py index c91f50b0..e31d9ab2 100644 --- a/time_tracker_api/config.py +++ b/time_tracker_api/config.py @@ -1,12 +1,12 @@ import os -from time_tracker_api.security import generate_dev_secret_key +from time_tracker_api.security import get_or_generate_dev_secret_key DISABLE_STR_VALUES = ("false", "0", "disabled") class Config: - SECRET_KEY = generate_dev_secret_key() + SECRET_KEY = get_or_generate_dev_secret_key() SQL_DATABASE_URI = os.environ.get('SQL_DATABASE_URI') PROPAGATE_EXCEPTIONS = True RESTPLUS_VALIDATE = True diff --git a/time_tracker_api/security.py b/time_tracker_api/security.py index 684f1dfc..d0c382ec 100644 --- a/time_tracker_api/security.py +++ b/time_tracker_api/security.py @@ -2,36 +2,97 @@ This is where we handle everything regarding to authorization and authentication. Also stores helper functions related to it. """ +import re + +import jwt from faker import Faker +from flask import request +from flask_restplus import abort +from flask_restplus._http import HTTPStatus +from jwt import DecodeError, ExpiredSignatureError fake = Faker() dev_secret_key: str = None +authorizations = { + "TimeTracker JWT": { + 'type': 'apiKey', + 'in': 'header', + 'name': 'Authorization', + 'description': "Specify in the value **'Bearer <JWT>'**, where JWT is the token", + } +} + +iss_claim_pattern = re.compile( + r"securityioet.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})") + def current_user_id() -> str: - """ - Returns the id of the authenticated user in - Azure Active Directory - """ - return 'anonymous' + oid_claim = get_token_json().get("oid") + if oid_claim is None: + abort(message='The claim "oid" is missing in the JWT', code=HTTPStatus.UNAUTHORIZED) + + return oid_claim def current_user_tenant_id() -> str: - # TODO Get this from the JWT - return "ioet" + iss_claim = get_token_json().get("iss") + if iss_claim is None: + abort(message='The claim "iss" is missing in the JWT', code=HTTPStatus.UNAUTHORIZED) + + tenant_id = parse_tenant_id_from_iss_claim(iss_claim) + if tenant_id is None: + abort(message='The format of the claim "iss" cannot be understood. ' + 'Please contact the development team.', + code=HTTPStatus.UNAUTHORIZED) + return tenant_id -def generate_dev_secret_key(): - from time_tracker_api import flask_app as app - """ - Generates a security key for development purposes - :return: str - """ + +def get_or_generate_dev_secret_key(): global dev_secret_key - dev_secret_key = fake.password(length=16, special_chars=True, digits=True, upper_case=True, lower_case=True) - if app.config.get("FLASK_DEBUG", False): # pragma: no cover - print('*********************************************************') - print("The generated secret is \"%s\"" % dev_secret_key) - print('*********************************************************') + if dev_secret_key is None: + from time_tracker_api import flask_app as app + """ + Generates a security key for development purposes + :return: str + """ + dev_secret_key = fake.password(length=16, special_chars=True, digits=True, upper_case=True, lower_case=True) + if app.config.get("FLASK_DEBUG", False): # pragma: no cover + print('*********************************************************') + print("The generated secret is \"%s\"" % dev_secret_key) + print('*********************************************************') return dev_secret_key + + +def parse_jwt(authentication_header_content): + if authentication_header_content is not None: + parsed_content = authentication_header_content.split("Bearer ") + + if len(parsed_content) > 1: + return jwt.decode(parsed_content[1], verify=False) + + return None + + +def get_authorization_jwt(): + auth_header = request.headers.get('Authorization') + return parse_jwt(auth_header) + + +def get_token_json(): + try: + return get_authorization_jwt() + except DecodeError: + abort(message='Malformed token', code=HTTPStatus.UNAUTHORIZED) + except ExpiredSignatureError: + abort(message='Expired token', code=HTTPStatus.UNAUTHORIZED) + + +def parse_tenant_id_from_iss_claim(iss_claim: str) -> str: + m = iss_claim_pattern.search(iss_claim) + if m is not None: + return m.group('tenant_id') + + return None From 7efc921ce662c4d7b2d264e2837c5acb5fe5690c Mon Sep 17 00:00:00 2001 From: EliuX Date: Wed, 22 Apr 2020 12:01:37 -0500 Subject: [PATCH 006/361] fix: Close #94 Fix tests to support authentication --- commons/data_access_layer/database.py | 1 - commons/data_access_layer/sql.py | 12 -- tests/commons/data_access_layer/sql_test.py | 7 - tests/conftest.py | 47 +---- .../activities/activities_namespace_test.py | 99 +++++++--- .../customers/customers_namespace_test.py | 125 ++++++++---- .../project_types_namespace_test.py | 124 ++++++++---- .../projects/projects_namespace_test.py | 121 ++++++++---- .../time_entries_namespace_test.py | 182 +++++++++++++----- 9 files changed, 480 insertions(+), 238 deletions(-) diff --git a/commons/data_access_layer/database.py b/commons/data_access_layer/database.py index 077780cf..27bee0ca 100644 --- a/commons/data_access_layer/database.py +++ b/commons/data_access_layer/database.py @@ -36,7 +36,6 @@ def delete(self, id): def init_app(app: Flask) -> None: - init_sql(app) # TODO Delete after the migration to Cosmos DB has finished. init_cosmos_db(app) diff --git a/commons/data_access_layer/sql.py b/commons/data_access_layer/sql.py index c1afb0e2..eb356620 100644 --- a/commons/data_access_layer/sql.py +++ b/commons/data_access_layer/sql.py @@ -4,10 +4,8 @@ from flask_sqlalchemy import SQLAlchemy from commons.data_access_layer.database import CRUDDao, ID_MAX_LENGTH -from time_tracker_api.security import current_user_id db: SQLAlchemy = None -AuditedSQLModel = None def handle_commit_issues(f): @@ -25,16 +23,6 @@ def init_app(app: Flask) -> None: global db db = SQLAlchemy(app) - global AuditedSQLModel - - class AuditedSQLModelClass(): - created_at = db.Column(db.DateTime, server_default=db.func.now()) - updated_at = db.Column(db.DateTime, onupdate=datetime.utcnow) - created_by = db.Column(db.String(ID_MAX_LENGTH), default=current_user_id) - updated_by = db.Column(db.String(ID_MAX_LENGTH), onupdate=current_user_id) - - AuditedSQLModel = AuditedSQLModelClass - class SQLRepository(): def __init__(self, model_type: type): diff --git a/tests/commons/data_access_layer/sql_test.py b/tests/commons/data_access_layer/sql_test.py index 48f220ff..26f992b8 100644 --- a/tests/commons/data_access_layer/sql_test.py +++ b/tests/commons/data_access_layer/sql_test.py @@ -16,10 +16,6 @@ def test_create(sql_repository): assert result is not None assert result.id is not None - assert result.created_at is not None - assert result.created_by is not None - assert result.updated_at is None - assert result.updated_by is None existing_elements_registry.append(result) @@ -43,9 +39,6 @@ def test_update(sql_repository): assert updated_element.id == existing_element.id assert updated_element.name == "Jon Snow" assert updated_element.age == 34 - assert updated_element.updated_at is not None - assert updated_element.updated_at > updated_element.created_at - assert updated_element.updated_by is not None def test_find_all(sql_repository): diff --git a/tests/conftest.py b/tests/conftest.py index 21de5386..5fad738c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ from flask.testing import FlaskClient from commons.data_access_layer.cosmos_db import CosmosDBRepository, datetime_str, current_datetime +from commons.data_access_layer.database import init_sql from time_tracker_api import create_app from time_tracker_api.security import get_or_generate_dev_secret_key from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository @@ -14,37 +15,6 @@ fake = Faker() Faker.seed() -TEST_USER = { - "name": "testuser@ioet.com", - "password": "secret" -} - - -class User: - def __init__(self, username, password): - self.username = username - self.password = password - - -class AuthActions: - """Auth actions container in tests""" - - def __init__(self, app, client): - self._app = app - self._client = client - - # def login(self, username=TEST_USER["name"], - # password=TEST_USER["password"]): - # login_url = url_for("security.login", self._app) - # return open_with_basic_auth(self._client, - # login_url, - # username, - # password) - # - # def logout(self): - # return self._client.get(url_for("security.logout", self._app), - # follow_redirects=True) - @pytest.fixture(scope='session') def app() -> Flask: @@ -58,9 +28,12 @@ def client(app: Flask) -> FlaskClient: @pytest.fixture(scope="module") -def sql_model_class(): - from commons.data_access_layer.sql import db, AuditedSQLModel - class PersonSQLModel(db.Model, AuditedSQLModel): +def sql_model_class(app: Flask): + with app.app_context(): + init_sql(app) + + from commons.data_access_layer.sql import db + class PersonSQLModel(db.Model): __tablename__ = 'test' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(80), unique=False, nullable=False) @@ -186,11 +159,11 @@ def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository, @pytest.fixture(scope="session") -def valid_jwt(app: Flask) -> str: +def valid_jwt(app: Flask, tenant_id: str, owner_id: str) -> str: expiration_time = datetime.utcnow() + timedelta(seconds=3600) return jwt.encode({ - "iss": "https://securityioet.b2clogin.com/%s/v2.0/" % fake.uuid4(), - "oid": fake.uuid4(), + "iss": "https://securityioet.b2clogin.com/%s/v2.0/" % tenant_id, + "oid": owner_id, 'exp': expiration_time }, key=get_or_generate_dev_secret_key()).decode("UTF-8") diff --git a/tests/time_tracker_api/activities/activities_namespace_test.py b/tests/time_tracker_api/activities/activities_namespace_test.py index 90f3f1d6..508e7f88 100644 --- a/tests/time_tracker_api/activities/activities_namespace_test.py +++ b/tests/time_tracker_api/activities/activities_namespace_test.py @@ -4,8 +4,6 @@ from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture -from time_tracker_api.security import current_user_tenant_id - fake = Faker() valid_activity_data = { @@ -36,34 +34,45 @@ def test_create_activity_should_succeed_with_valid_request(client: FlaskClient, repository_create_mock.assert_called_once() -def test_create_activity_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_create_activity_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao repository_create_mock = mocker.patch.object(activity_dao.repository, 'create', return_value=fake_activity) - response = client.post("/activities", json=None, follow_redirects=True) + response = client.post("/activities", + headers=valid_header, + json=None, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_create_mock.assert_not_called() -def test_list_all_activities(client: FlaskClient, mocker: MockFixture): +def test_list_all_activities(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao repository_find_all_mock = mocker.patch.object(activity_dao.repository, 'find_all', return_value=[]) - response = client.get("/activities", follow_redirects=True) + response = client.get("/activities", + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code json_data = json.loads(response.data) assert [] == json_data - repository_find_all_mock.assert_called_once() + repository_find_all_mock.assert_called_once_with(partition_key_value=tenant_id) def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture, + tenant_id: str, valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao @@ -79,12 +88,12 @@ def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, assert HTTPStatus.OK == response.status_code fake_activity == json.loads(response.data) - repository_find_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + repository_find_mock.assert_called_once_with(str(valid_id), partition_key_value=tenant_id) def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture, + tenant_id: str, valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao from werkzeug.exceptions import NotFound @@ -101,10 +110,11 @@ def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClien assert HTTPStatus.NOT_FOUND == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_get_activity_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture): +def test_get_activity_should_return_422_for_invalid_id_format(client: FlaskClient, + mocker: MockFixture): from time_tracker_api.activities.activities_namespace import activity_dao from werkzeug.exceptions import UnprocessableEntity @@ -117,41 +127,54 @@ def test_get_activity_should_return_422_for_invalid_id_format(client: FlaskClien response = client.get("/activities/%s" % invalid_id, follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + repository_find_mock.assert_not_called() -def test_update_activity_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): +def test_update_activity_should_succeed_with_valid_data(client: FlaskClient, + tenant_id: str, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao repository_update_mock = mocker.patch.object(activity_dao.repository, 'partial_update', return_value=fake_activity) - valid_id = fake.random_int(1, 9999) - response = client.put("/activities/%s" % valid_id, json=valid_activity_data, follow_redirects=True) + valid_id = fake.uuid4() + response = client.put("/activities/%s" % valid_id, + headers=valid_header, + json=valid_activity_data, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_activity == json.loads(response.data) repository_update_mock.assert_called_once_with(str(valid_id), changes=valid_activity_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_update_activity_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_update_activity_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao repository_update_mock = mocker.patch.object(activity_dao.repository, 'partial_update', return_value=fake_activity) valid_id = fake.random_int(1, 9999) - response = client.put("/activities/%s" % valid_id, json=None, follow_redirects=True) + response = client.put("/activities/%s" % valid_id, + headers=valid_header, + json=None, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_update_mock.assert_not_called() -def test_update_activity_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_update_activity_should_return_not_found_with_invalid_id(client: FlaskClient, + tenant_id: str, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao from werkzeug.exceptions import NotFound @@ -162,16 +185,20 @@ def test_update_activity_should_return_not_found_with_invalid_id(client: FlaskCl side_effect=NotFound) response = client.put("/activities/%s" % invalid_id, + headers=valid_header, json=valid_activity_data, follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_update_mock.assert_called_once_with(str(invalid_id), changes=valid_activity_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_activity_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao valid_id = fake.random_int(1, 9999) @@ -180,15 +207,20 @@ def test_delete_activity_should_succeed_with_valid_id(client: FlaskClient, mocke 'delete', return_value=None) - response = client.delete("/activities/%s" % valid_id, follow_redirects=True) + response = client.delete("/activities/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_activity_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_activity_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao from werkzeug.exceptions import NotFound @@ -198,14 +230,19 @@ def test_delete_activity_should_return_not_found_with_invalid_id(client: FlaskCl 'delete', side_effect=NotFound) - response = client.delete("/activities/%s" % invalid_id, follow_redirects=True) + response = client.delete("/activities/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_activity_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture): +def test_delete_activity_should_return_422_for_invalid_id_format(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao from werkzeug.exceptions import UnprocessableEntity @@ -215,8 +252,10 @@ def test_delete_activity_should_return_422_for_invalid_id_format(client: FlaskCl 'delete', side_effect=UnprocessableEntity) - response = client.delete("/activities/%s" % invalid_id, follow_redirects=True) + response = client.delete("/activities/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) diff --git a/tests/time_tracker_api/customers/customers_namespace_test.py b/tests/time_tracker_api/customers/customers_namespace_test.py index 707df28a..1777cbe8 100644 --- a/tests/time_tracker_api/customers/customers_namespace_test.py +++ b/tests/time_tracker_api/customers/customers_namespace_test.py @@ -4,8 +4,6 @@ from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture -from time_tracker_api.security import current_user_tenant_id - fake = Faker() valid_customer_data = { @@ -15,41 +13,55 @@ } fake_customer = ({ - "id": fake.random_int(1, 9999) + "id": fake.random_int(1, 9999), }).update(valid_customer_data) -def test_create_customer_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture): +def test_create_customer_should_succeed_with_valid_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao repository_create_mock = mocker.patch.object(customer_dao.repository, 'create', return_value=fake_customer) - response = client.post("/customers", json=valid_customer_data, follow_redirects=True) + response = client.post("/customers", + headers=valid_header, + json=valid_customer_data, + follow_redirects=True) assert HTTPStatus.CREATED == response.status_code repository_create_mock.assert_called_once() -def test_create_customer_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_create_customer_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao repository_create_mock = mocker.patch.object(customer_dao.repository, 'create', return_value=fake_customer) - response = client.post("/customers", json=None, follow_redirects=True) + response = client.post("/customers", + headers=valid_header, + json=None, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_create_mock.assert_not_called() -def test_list_all_customers(client: FlaskClient, mocker: MockFixture): +def test_list_all_customers(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao repository_find_all_mock = mocker.patch.object(customer_dao.repository, 'find_all', return_value=[]) - response = client.get("/customers", follow_redirects=True) + response = client.get("/customers", + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code json_data = json.loads(response.data) @@ -57,7 +69,10 @@ def test_list_all_customers(client: FlaskClient, mocker: MockFixture): repository_find_all_mock.assert_called_once() -def test_get_customer_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_get_customer_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao valid_id = fake.random_int(1, 9999) @@ -66,15 +81,20 @@ def test_get_customer_should_succeed_with_valid_id(client: FlaskClient, mocker: 'find', return_value=fake_customer) - response = client.get("/customers/%s" % valid_id, follow_redirects=True) + response = client.get("/customers/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_customer == json.loads(response.data) repository_find_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_get_customer_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_get_customer_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao from werkzeug.exceptions import NotFound @@ -84,14 +104,19 @@ def test_get_customer_should_return_not_found_with_invalid_id(client: FlaskClien 'find', side_effect=NotFound) - response = client.get("/customers/%s" % invalid_id, follow_redirects=True) + response = client.get("/customers/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_get_customer_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture): +def test_get_customer_should_return_422_for_invalid_id_format(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao from werkzeug.exceptions import UnprocessableEntity @@ -101,14 +126,19 @@ def test_get_customer_should_return_422_for_invalid_id_format(client: FlaskClien 'find', side_effect=UnprocessableEntity) - response = client.get("/customers/%s" % invalid_id, follow_redirects=True) + response = client.get("/customers/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_update_customer_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): +def test_update_customer_should_succeed_with_valid_data(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao repository_update_mock = mocker.patch.object(customer_dao.repository, @@ -116,29 +146,40 @@ def test_update_customer_should_succeed_with_valid_data(client: FlaskClient, moc return_value=fake_customer) valid_id = fake.random_int(1, 9999) - response = client.put("/customers/%s" % valid_id, json=valid_customer_data, follow_redirects=True) + response = client.put("/customers/%s" % valid_id, + headers=valid_header, + json=valid_customer_data, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_customer == json.loads(response.data) repository_update_mock.assert_called_once_with(str(valid_id), changes=valid_customer_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_update_customer_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_update_customer_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao repository_update_mock = mocker.patch.object(customer_dao.repository, 'partial_update', return_value=fake_customer) valid_id = fake.random_int(1, 9999) - response = client.put("/customers/%s" % valid_id, json=None, follow_redirects=True) + response = client.put("/customers/%s" % valid_id, + headers=valid_header, + json=None, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_update_mock.assert_not_called() -def test_update_customer_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_update_customer_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao from werkzeug.exceptions import NotFound @@ -149,16 +190,20 @@ def test_update_customer_should_return_not_found_with_invalid_id(client: FlaskCl side_effect=NotFound) response = client.put("/customers/%s" % invalid_id, + headers=valid_header, json=valid_customer_data, follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_update_mock.assert_called_once_with(str(invalid_id), changes=valid_customer_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_customer_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_customer_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao valid_id = fake.random_int(1, 9999) @@ -167,15 +212,20 @@ def test_delete_customer_should_succeed_with_valid_id(client: FlaskClient, mocke 'delete', return_value=None) - response = client.delete("/customers/%s" % valid_id, follow_redirects=True) + response = client.delete("/customers/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_customer_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_customer_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao from werkzeug.exceptions import NotFound @@ -185,14 +235,19 @@ def test_delete_customer_should_return_not_found_with_invalid_id(client: FlaskCl 'delete', side_effect=NotFound) - response = client.delete("/customers/%s" % invalid_id, follow_redirects=True) + response = client.delete("/customers/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_customer_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture): +def test_delete_customer_should_return_422_for_invalid_id_format(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao from werkzeug.exceptions import UnprocessableEntity @@ -202,8 +257,10 @@ def test_delete_customer_should_return_422_for_invalid_id_format(client: FlaskCl 'delete', side_effect=UnprocessableEntity) - response = client.delete("/customers/%s" % invalid_id, follow_redirects=True) + response = client.delete("/customers/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) 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 613b0cdc..3d0b3135 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 @@ -4,8 +4,6 @@ from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture -from time_tracker_api.security import current_user_tenant_id - fake = Faker() valid_project_type_data = { @@ -21,19 +19,26 @@ }).update(valid_project_type_data) -def test_create_project_type_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture): +def test_create_project_type_should_succeed_with_valid_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao repository_create_mock = mocker.patch.object(project_type_dao.repository, 'create', return_value=fake_project_type) - response = client.post("/project-types", json=valid_project_type_data, follow_redirects=True) + response = client.post("/project-types", + headers=valid_header, + json=valid_project_type_data, + follow_redirects=True) assert HTTPStatus.CREATED == response.status_code repository_create_mock.assert_called_once() -def test_create_project_type_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_create_project_type_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao invalid_project_type_data = valid_project_type_data.copy() invalid_project_type_data.update({ @@ -43,59 +48,77 @@ def test_create_project_type_should_reject_bad_request(client: FlaskClient, mock 'create', return_value=fake_project_type) - response = client.post("/project-types", json=invalid_project_type_data, follow_redirects=True) + response = client.post("/project-types", + headers=valid_header, + json=invalid_project_type_data, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_create_mock.assert_not_called() -def test_list_all_project_types(client: FlaskClient, mocker: MockFixture): +def test_list_all_project_types(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao repository_find_all_mock = mocker.patch.object(project_type_dao.repository, 'find_all', return_value=[]) - response = client.get("/project-types", follow_redirects=True) + response = client.get("/project-types", + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code assert [] == json.loads(response.data) repository_find_all_mock.assert_called_once() -def test_get_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_get_project_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.project_types.project_types_namespace import project_type_dao valid_id = fake.random_int(1, 9999) repository_find_mock = mocker.patch.object(project_type_dao.repository, 'find', return_value=fake_project_type) - response = client.get("/project-types/%s" % valid_id, follow_redirects=True) + response = client.get("/project-types/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_project_type == json.loads(response.data) repository_find_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.project_types.project_types_namespace import project_type_dao from werkzeug.exceptions import NotFound - invalid_id = fake.random_int(1, 9999) + invalid_id = str(fake.random_int(1, 9999)) repository_find_mock = mocker.patch.object(project_type_dao.repository, 'find', side_effect=NotFound) - response = client.get("/project-types/%s" % invalid_id, follow_redirects=True) + response = client.get("/project-types/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + repository_find_mock.assert_called_once_with(invalid_id, partition_key_value=tenant_id) def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.project_types.project_types_namespace import project_type_dao from werkzeug.exceptions import UnprocessableEntity @@ -105,14 +128,19 @@ def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_fo 'find', side_effect=UnprocessableEntity) - response = client.get("/project-types/%s" % invalid_id, follow_redirects=True) + response = client.get("/project-types/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_update_project_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): +def test_update_project_should_succeed_with_valid_data(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.project_types.project_types_namespace import project_type_dao repository_update_mock = mocker.patch.object(project_type_dao.repository, @@ -120,16 +148,21 @@ def test_update_project_should_succeed_with_valid_data(client: FlaskClient, mock return_value=fake_project_type) valid_id = fake.random_int(1, 9999) - response = client.put("/project-types/%s" % valid_id, json=valid_project_type_data, follow_redirects=True) + response = client.put("/project-types/%s" % valid_id, + headers=valid_header, + json=valid_project_type_data, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_project_type == json.loads(response.data) repository_update_mock.assert_called_once_with(str(valid_id), changes=valid_project_type_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_update_project_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao invalid_project_type_data = valid_project_type_data.copy() invalid_project_type_data.update({ @@ -140,13 +173,19 @@ def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: M return_value=fake_project_type) valid_id = fake.random_int(1, 9999) - response = client.put("/project-types/%s" % valid_id, json=invalid_project_type_data, follow_redirects=True) + response = client.put("/project-types/%s" % valid_id, + headers=valid_header, + json=invalid_project_type_data, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_update_mock.assert_not_called() -def test_update_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_update_project_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao from werkzeug.exceptions import NotFound @@ -157,16 +196,20 @@ def test_update_project_should_return_not_found_with_invalid_id(client: FlaskCli side_effect=NotFound) response = client.put("/project-types/%s" % invalid_id, + headers=valid_header, json=valid_project_type_data, follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_update_mock.assert_called_once_with(str(invalid_id), changes=valid_project_type_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao valid_id = fake.random_int(1, 9999) @@ -175,15 +218,20 @@ def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker 'delete', return_value=None) - response = client.delete("/project-types/%s" % valid_id, follow_redirects=True) + response = client.delete("/project-types/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao from werkzeug.exceptions import NotFound @@ -193,15 +241,19 @@ def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskCli 'delete', side_effect=NotFound) - response = client.delete("/project-types/%s" % invalid_id, follow_redirects=True) + response = client.delete("/project-types/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + tenant_id: str, + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao from werkzeug.exceptions import UnprocessableEntity @@ -211,8 +263,10 @@ def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format 'delete', side_effect=UnprocessableEntity) - response = client.delete("/project-types/%s" % invalid_id, follow_redirects=True) + response = client.delete("/project-types/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) diff --git a/tests/time_tracker_api/projects/projects_namespace_test.py b/tests/time_tracker_api/projects/projects_namespace_test.py index e8707dc3..6c7ff5eb 100644 --- a/tests/time_tracker_api/projects/projects_namespace_test.py +++ b/tests/time_tracker_api/projects/projects_namespace_test.py @@ -4,8 +4,6 @@ from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture -from time_tracker_api.security import current_user_tenant_id - fake = Faker() valid_project_data = { @@ -20,19 +18,26 @@ }).update(valid_project_data) -def test_create_project_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture): +def test_create_project_should_succeed_with_valid_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao repository_create_mock = mocker.patch.object(project_dao.repository, 'create', return_value=fake_project) - response = client.post("/projects", json=valid_project_data, follow_redirects=True) + response = client.post("/projects", + headers=valid_header, + json=valid_project_data, + follow_redirects=True) assert HTTPStatus.CREATED == response.status_code repository_create_mock.assert_called_once() -def test_create_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_create_project_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao invalid_project_data = valid_project_data.copy() invalid_project_data.update({ @@ -42,41 +47,56 @@ def test_create_project_should_reject_bad_request(client: FlaskClient, mocker: M 'create', return_value=fake_project) - response = client.post("/projects", json=invalid_project_data, follow_redirects=True) + response = client.post("/projects", + headers=valid_header, + json=invalid_project_data, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_create_mock.assert_not_called() -def test_list_all_projects(client: FlaskClient, mocker: MockFixture): +def test_list_all_projects(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao repository_find_all_mock = mocker.patch.object(project_dao.repository, 'find_all', return_value=[]) - response = client.get("/projects", follow_redirects=True) + response = client.get("/projects", + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code assert [] == json.loads(response.data) repository_find_all_mock.assert_called_once() -def test_get_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_get_project_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao valid_id = fake.random_int(1, 9999) repository_find_mock = mocker.patch.object(project_dao.repository, 'find', return_value=fake_project) - response = client.get("/projects/%s" % valid_id, follow_redirects=True) + response = client.get("/projects/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_project == json.loads(response.data) repository_find_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import NotFound @@ -86,15 +106,19 @@ def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient 'find', side_effect=NotFound) - response = client.get("/projects/%s" % invalid_id, follow_redirects=True) + response = client.get("/projects/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import UnprocessableEntity @@ -104,14 +128,19 @@ def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_fo 'find', side_effect=UnprocessableEntity) - response = client.get("/projects/%s" % invalid_id, follow_redirects=True) + response = client.get("/projects/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_update_project_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): +def test_update_project_should_succeed_with_valid_data(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao repository_update_mock = mocker.patch.object(project_dao.repository, @@ -119,16 +148,21 @@ def test_update_project_should_succeed_with_valid_data(client: FlaskClient, mock return_value=fake_project) valid_id = fake.random_int(1, 9999) - response = client.put("/projects/%s" % valid_id, json=valid_project_data, follow_redirects=True) + response = client.put("/projects/%s" % valid_id, + headers=valid_header, + json=valid_project_data, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_project == json.loads(response.data) repository_update_mock.assert_called_once_with(str(valid_id), changes=valid_project_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_update_project_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao invalid_project_data = valid_project_data.copy() invalid_project_data.update({ @@ -139,13 +173,19 @@ def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: M return_value=fake_project) valid_id = fake.random_int(1, 9999) - response = client.put("/projects/%s" % valid_id, json=invalid_project_data, follow_redirects=True) + response = client.put("/projects/%s" % valid_id, + headers=valid_header, + json=invalid_project_data, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_update_mock.assert_not_called() -def test_update_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_update_project_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import NotFound @@ -156,16 +196,20 @@ def test_update_project_should_return_not_found_with_invalid_id(client: FlaskCli side_effect=NotFound) response = client.put("/projects/%s" % invalid_id, + headers=valid_header, json=valid_project_data, follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_update_mock.assert_called_once_with(str(invalid_id), changes=valid_project_data, - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao valid_id = fake.random_int(1, 9999) @@ -174,15 +218,20 @@ def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker 'delete', return_value=None) - response = client.delete("/projects/%s" % valid_id, follow_redirects=True) + response = client.delete("/projects/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) -def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import NotFound @@ -192,15 +241,19 @@ def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskCli 'delete', side_effect=NotFound) - response = client.delete("/projects/%s" % invalid_id, follow_redirects=True) + response = client.delete("/projects/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import UnprocessableEntity @@ -210,8 +263,10 @@ def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format 'delete', side_effect=UnprocessableEntity) - response = client.delete("/projects/%s" % invalid_id, follow_redirects=True) + response = client.delete("/projects/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id()) + partition_key_value=tenant_id) 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 34d34259..f3fe83bb 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 @@ -8,7 +8,6 @@ from pytest_mock import MockFixture from commons.data_access_layer.cosmos_db import current_datetime -from time_tracker_api.security import current_user_tenant_id fake = Faker() @@ -30,7 +29,8 @@ def test_create_time_entry_with_invalid_date_range_should_raise_bad_request_error(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_container_create_item_mock = mocker.patch.object(time_entries_dao.repository.container, 'create_item', @@ -40,14 +40,18 @@ def test_create_time_entry_with_invalid_date_range_should_raise_bad_request_erro invalid_time_entry_input.update({ "end_date": str(yesterday.isoformat()) }) - response = client.post("/time-entries", json=invalid_time_entry_input, follow_redirects=True) + response = client.post("/time-entries", + headers=valid_header, + json=invalid_time_entry_input, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_container_create_item_mock.assert_not_called() def test_create_time_entry_with_end_date_in_future_should_raise_bad_request_error(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_container_create_item_mock = mocker.patch.object(time_entries_dao.repository.container, 'create_item', @@ -56,25 +60,35 @@ def test_create_time_entry_with_end_date_in_future_should_raise_bad_request_erro invalid_time_entry_input.update({ "end_date": str(fake.future_datetime().isoformat()) }) - response = client.post("/time-entries", json=invalid_time_entry_input, follow_redirects=True) + response = client.post("/time-entries", + headers=valid_header, + json=invalid_time_entry_input, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_container_create_item_mock.assert_not_called() -def test_create_time_entry_should_succeed_with_valid_request(client: FlaskClient, mocker: MockFixture): +def test_create_time_entry_should_succeed_with_valid_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_create_mock = mocker.patch.object(time_entries_dao.repository, 'create', return_value=fake_time_entry) - response = client.post("/time-entries", json=valid_time_entry_input, follow_redirects=True) + response = client.post("/time-entries", + headers=valid_header, + json=valid_time_entry_input, + follow_redirects=True) assert HTTPStatus.CREATED == response.status_code repository_create_mock.assert_called_once() -def test_create_time_entry_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_create_time_entry_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao invalid_time_entry_input = valid_time_entry_input.copy() invalid_time_entry_input.update({ @@ -84,43 +98,57 @@ def test_create_time_entry_should_reject_bad_request(client: FlaskClient, mocker 'create', return_value=fake_time_entry) - response = client.post("/time-entries", json=invalid_time_entry_input, follow_redirects=True) + response = client.post("/time-entries", + headers=valid_header, + json=invalid_time_entry_input, + follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code repository_create_mock.assert_not_called() -def test_list_all_time_entries(client: FlaskClient, mocker: MockFixture): +def test_list_all_time_entries(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_find_all_mock = mocker.patch.object(time_entries_dao.repository, 'find_all', return_value=[]) - response = client.get("/time-entries", follow_redirects=True) + response = client.get("/time-entries", + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code assert [] == json.loads(response.data) repository_find_all_mock.assert_called_once() -def test_get_time_entry_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_get_time_entry_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_find_mock = mocker.patch.object(time_entries_dao.repository, 'find', return_value=fake_time_entry) valid_id = fake.random_int(1, 9999) - response = client.get("/time-entries/%s" % valid_id, follow_redirects=True) + response = client.get("/time-entries/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code fake_time_entry == json.loads(response.data) repository_find_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) def test_get_time_entry_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import UnprocessableEntity @@ -130,15 +158,20 @@ def test_get_time_entry_should_response_with_unprocessable_entity_for_invalid_id 'find', side_effect=UnprocessableEntity) - response = client.get("/time-entries/%s" % invalid_id, follow_redirects=True) + response = client.get("/time-entries/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture): +def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'partial_update', @@ -146,6 +179,7 @@ def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, m valid_id = fake.random_int(1, 9999) response = client.put("/time-entries/%s" % valid_id, + headers=valid_header, json=valid_time_entry_input, follow_redirects=True) @@ -153,11 +187,14 @@ def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, m fake_time_entry == json.loads(response.data) repository_update_mock.assert_called_once_with(str(valid_id), changes=valid_time_entry_input, - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_update_time_entry_should_reject_bad_request(client: FlaskClient, mocker: MockFixture): +def test_update_time_entry_should_reject_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao invalid_time_entry_data = valid_time_entry_input.copy() invalid_time_entry_data.update({ @@ -169,6 +206,7 @@ def test_update_time_entry_should_reject_bad_request(client: FlaskClient, mocker valid_id = fake.random_int(1, 9999) response = client.put("/time-entries/%s" % valid_id, + headers=valid_header, json=invalid_time_entry_data, follow_redirects=True) @@ -176,7 +214,10 @@ def test_update_time_entry_should_reject_bad_request(client: FlaskClient, mocker repository_update_mock.assert_not_called() -def test_update_time_entry_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture): +def test_update_time_entry_should_return_not_found_with_invalid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import NotFound repository_update_mock = mocker.patch.object(time_entries_dao.repository, @@ -185,34 +226,42 @@ def test_update_time_entry_should_return_not_found_with_invalid_id(client: Flask invalid_id = fake.random_int(1, 9999) response = client.put("/time-entries/%s" % invalid_id, + headers=valid_header, json=valid_time_entry_input, follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_update_mock.assert_called_once_with(str(invalid_id), changes=valid_time_entry_input, - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_delete_time_entry_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_delete_time_entry_should_succeed_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_remove_mock = mocker.patch.object(time_entries_dao.repository, 'delete', return_value=None) valid_id = fake.random_int(1, 9999) - response = client.delete("/time-entries/%s" % valid_id, follow_redirects=True) + response = client.delete("/time-entries/%s" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) def test_delete_time_entry_should_return_not_found_with_invalid_id(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import NotFound repository_remove_mock = mocker.patch.object(time_entries_dao.repository, @@ -220,16 +269,20 @@ def test_delete_time_entry_should_return_not_found_with_invalid_id(client: Flask side_effect=NotFound) invalid_id = fake.random_int(1, 9999) - response = client.delete("/time-entries/%s" % invalid_id, follow_redirects=True) + response = client.delete("/time-entries/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) def test_delete_time_entry_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import UnprocessableEntity repository_remove_mock = mocker.patch.object(time_entries_dao.repository, @@ -237,31 +290,41 @@ def test_delete_time_entry_should_return_unprocessable_entity_for_invalid_id_for side_effect=UnprocessableEntity) invalid_id = fake.word() - response = client.delete("/time-entries/%s" % invalid_id, follow_redirects=True) + response = client.delete("/time-entries/%s" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_stop_time_entry_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_stop_time_entry_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'partial_update', return_value=fake_time_entry) valid_id = fake.random_int(1, 9999) - response = client.post("/time-entries/%s/stop" % valid_id, follow_redirects=True) + response = client.post("/time-entries/%s/stop" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code repository_update_mock.assert_called_once_with(str(valid_id), changes={"end_date": mocker.ANY}, - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_stop_time_entry_with_id_with_invalid_format(client: FlaskClient, mocker: MockFixture): +def test_stop_time_entry_with_id_with_invalid_format(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import UnprocessableEntity repository_update_mock = mocker.patch.object(time_entries_dao.repository, @@ -269,32 +332,42 @@ def test_stop_time_entry_with_id_with_invalid_format(client: FlaskClient, mocker side_effect=UnprocessableEntity) invalid_id = fake.word() - response = client.post("/time-entries/%s/stop" % invalid_id, follow_redirects=True) + response = client.post("/time-entries/%s/stop" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_update_mock.assert_called_once_with(invalid_id, changes={"end_date": ANY}, - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_restart_time_entry_with_valid_id(client: FlaskClient, mocker: MockFixture): +def test_restart_time_entry_with_valid_id(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'partial_update', return_value=fake_time_entry) valid_id = fake.random_int(1, 9999) - response = client.post("/time-entries/%s/restart" % valid_id, follow_redirects=True) + response = client.post("/time-entries/%s/restart" % valid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code repository_update_mock.assert_called_once_with(str(valid_id), changes={"end_date": None}, - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, mocker: MockFixture): +def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import UnprocessableEntity repository_update_mock = mocker.patch.object(time_entries_dao.repository, @@ -303,36 +376,47 @@ def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, moc peeker=ANY) invalid_id = fake.word() - response = client.post("/time-entries/%s/restart" % invalid_id, follow_redirects=True) + response = client.post("/time-entries/%s/restart" % invalid_id, + headers=valid_header, + follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_update_mock.assert_called_once_with(invalid_id, changes={"end_date": None}, - partition_key_value=current_user_tenant_id(), + partition_key_value=tenant_id, peeker=ANY) -def test_get_running_should_call_find_running(client: FlaskClient, mocker: MockFixture): +def test_get_running_should_call_find_running(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'find_running', return_value=fake_time_entry) - response = client.get("/time-entries/running", follow_redirects=True) + response = client.get("/time-entries/running", + headers=valid_header, + follow_redirects=True) assert HTTPStatus.OK == response.status_code assert json.loads(response.data) is not None - repository_update_mock.assert_called_once_with(partition_key_value=current_user_tenant_id()) + repository_update_mock.assert_called_once_with(partition_key_value=tenant_id) def test_get_running_should_return_not_found_if_find_running_throws_StopIteration(client: FlaskClient, - mocker: MockFixture): + mocker: MockFixture, + valid_header: dict, + tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'find_running', side_effect=StopIteration) - response = client.get("/time-entries/running", follow_redirects=True) + response = client.get("/time-entries/running", + headers=valid_header, + follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_update_mock.assert_called_once_with(partition_key_value=current_user_tenant_id()) + repository_update_mock.assert_called_once_with(partition_key_value=tenant_id) From be9fec2b59dc0a5c125c1156b057b61038b578f2 Mon Sep 17 00:00:00 2001 From: EliuX Date: Wed, 22 Apr 2020 13:18:50 -0500 Subject: [PATCH 007/361] fix: Ignore deleted-running time entry --- tests/conftest.py | 2 +- time_tracker_api/time_entries/time_entries_model.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5fad738c..92781c03 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -154,7 +154,7 @@ def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository, yield created_time_entry - time_entry_repository.delete(id=created_time_entry.id, + time_entry_repository.delete_permanently(id=created_time_entry.id, partition_key_value=tenant_id) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index f2646edc..3c2bd098 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -111,9 +111,11 @@ def find_running(self, partition_key_value: str, mapper: Callable = None): result = self.container.query_items( query=""" SELECT * from c - WHERE NOT IS_DEFINED(c.end_date) OR c.end_date = null + WHERE (NOT IS_DEFINED(c.end_date) OR c.end_date = null) {visibility_condition} OFFSET 0 LIMIT 1 - """, + """.format( + visibility_condition=self.create_sql_condition_for_visibility(True), + ), partition_key=partition_key_value, max_item_count=1) From fe27bbb22d5f0e04333938c58eaff0909a083e6e Mon Sep 17 00:00:00 2001 From: semantic-release Date: Wed, 22 Apr 2020 21:03:52 +0000 Subject: [PATCH 008/361] 0.5.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 f0ede3d3..2b8877c5 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.4.1' +__version__ = '0.5.0' From 9a951ae631036961a0c291649bf15975d3b33493 Mon Sep 17 00:00:00 2001 From: roberto Date: Wed, 22 Apr 2020 13:26:10 -0500 Subject: [PATCH 009/361] chore: add pre-commit library and config to enforce semantic commit messages --- .pre-commit-config.yaml | 8 +++++ .../git_hooks/enforce_semantic_commit_msg.py | 29 +++++++++++++++++++ requirements/time_tracker_api/dev.txt | 3 ++ 3 files changed, 40 insertions(+) create mode 100644 .pre-commit-config.yaml create mode 100644 commons/git_hooks/enforce_semantic_commit_msg.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..797fb4b0 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +repos: +- repo: local + hooks: + - id: semantic-commit-msg + name: Check semantic commit message format + entry: python ./commons/git_hooks/enforce_semantic_commit_msg.py + language: python + stages : [commit-msg] diff --git a/commons/git_hooks/enforce_semantic_commit_msg.py b/commons/git_hooks/enforce_semantic_commit_msg.py new file mode 100644 index 00000000..c01a3605 --- /dev/null +++ b/commons/git_hooks/enforce_semantic_commit_msg.py @@ -0,0 +1,29 @@ +import re +import sys + +ERROR_MSG = """ +Commit failed! +Please use semantic commit message format. Examples: + 'feat: Applying some changes' + 'fix: Fixing something broken' + 'feat(config): Fix something in config files' + +For more details in commit message format, review https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits +""" + +SUCCESS_MSG = "Commit succeed!. Semantic commit message is correct." + +COMMIT_MSG_REGEX = r'(build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert)(\([\w\-]+\))?:\s.*' + + +# Get the commit message file +commit_msg_file = open(sys.argv[1]) # The first argument is the file +commit_msg = commit_msg_file.read() + + +if re.match(COMMIT_MSG_REGEX, commit_msg) is None: + print(ERROR_MSG) + sys.exit(1) + +print(SUCCESS_MSG) +sys.exit(0) diff --git a/requirements/time_tracker_api/dev.txt b/requirements/time_tracker_api/dev.txt index c242e4a1..3a26760c 100644 --- a/requirements/time_tracker_api/dev.txt +++ b/requirements/time_tracker_api/dev.txt @@ -13,3 +13,6 @@ pytest-mock==2.0.0 # Coverage coverage==4.5.1 + +# Git hooks +pre-commit==2.2.0 \ No newline at end of file From 7d3388330527a7ff363ac1e06d08f2cc917e7846 Mon Sep 17 00:00:00 2001 From: roberto Date: Thu, 23 Apr 2020 09:53:55 -0500 Subject: [PATCH 010/361] docs: add instructions to enable enforce-semantic-commit-message git hook --- README.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a4fdaff7..c49b99f3 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,9 @@ automatically [pip](https://pip.pypa.io/en/stable/) as well. Remember to do it with Python 3. - + +- Run `pre-commit install`. For more details, check out Development > Git hooks. + ### How to use it - Set the env var `FLASK_APP` to `time_tracker_api` and start the app: @@ -74,6 +76,16 @@ DateTime strings in Azure Cosmos DB is `YYYY-MM-DDThh:mm:ss.fffffffZ` which foll ## Development +### Git hooks +We use [pre-commit](https://github.com/pre-commit/pre-commit) library to manage local git hooks, as developers we just need to run in our virtual environment: + +``` +pre-commit install +``` +With this command the library will take configuration from `.pre-commit-config.yaml` and will set up the hooks by us. + +Currently, we only have a hook to enforce semantic commit message. + ### Test We are using [Pytest](https://docs.pytest.org/en/latest/index.html) for tests. The tests are located in the package `tests` and use the [conventions for python test discovery](https://docs.pytest.org/en/latest/goodpractices.html#test-discovery). From 53613dfcdf692515667b6fe2ac0e4bfa5f7420c6 Mon Sep 17 00:00:00 2001 From: EliuX Date: Wed, 22 Apr 2020 17:53:04 -0500 Subject: [PATCH 011/361] fix: Close #96 Autogenerate start_date if not present --- commons/data_access_layer/cosmos_db.py | 17 +++++++++--- tests/conftest.py | 11 +++++--- .../time_entries/time_entries_model_test.py | 2 +- .../time_entries/time_entries_model.py | 26 ++++++++++++------- .../time_entries/time_entries_namespace.py | 8 +++--- 5 files changed, 42 insertions(+), 22 deletions(-) diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index 4778d5d2..f8f6c391 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -139,7 +139,8 @@ def create(self, data: dict, mapper: Callable = None): function_mapper = self.get_mapper_or_dict(mapper) return function_mapper(self.container.create_item(body=data)) - def find(self, id: str, partition_key_value, peeker: 'function' = None, visible_only=True, mapper: Callable = None): + def find(self, id: str, partition_key_value, peeker: 'function' = None, + visible_only=True, mapper: Callable = None): found_item = self.container.read_item(id, partition_key_value) if peeker: peeker(found_item) @@ -201,7 +202,7 @@ def get_page_size_or(self, custom_page_size: int) -> int: def on_create(self, new_item_data: dict): if new_item_data.get('id') is None: - new_item_data['id'] = str(uuid.uuid4()) + new_item_data['id'] = generate_uuid4() def on_update(self, update_item_data: dict): pass @@ -245,17 +246,25 @@ def __init__(self, status_code: int, description: str = None): self.description = description -def current_datetime(): +def current_datetime() -> datetime: return datetime.utcnow() -def datetime_str(value: datetime): +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 init_app(app: Flask) -> None: global cosmos_helper cosmos_helper = CosmosDBFacade.from_flask_config(app) diff --git a/tests/conftest.py b/tests/conftest.py index 97b80b65..f42686eb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -129,7 +129,13 @@ def another_item(cosmos_db_repository: CosmosDBRepository, tenant_id: str) -> di @pytest.fixture(scope="module") -def time_entry_repository() -> TimeEntryCosmosDBRepository: +def time_entry_repository(app: Flask) -> TimeEntryCosmosDBRepository: + with app.app_context(): + from commons.data_access_layer.cosmos_db import init_app, cosmos_helper + + if cosmos_helper is None: + init_app(app) + return TimeEntryCosmosDBRepository() @@ -139,12 +145,11 @@ def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository, tenant_id: str): created_time_entry = time_entry_repository.create({ "project_id": fake.uuid4(), - "start_date": datetime_str(current_datetime()), "owner_id": owner_id, "tenant_id": tenant_id }) yield created_time_entry - time_entry_repository.delete(id=created_time_entry.id, + time_entry_repository.delete_permanently(id=created_time_entry.id, partition_key_value=tenant_id) 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 2b58c036..43bd998c 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 @@ -48,7 +48,7 @@ def test_find_interception_with_date_range_should_find(start_date: datetime, partition_key_value=tenant_id) assert result is not None - assert len(result) >= 0 + assert len(result) > 0 assert any([existing_item.id == item.id for item in result]) finally: time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index f2646edc..1b63202b 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -5,8 +5,8 @@ from azure.cosmos import PartitionKey from flask_restplus._http import HTTPStatus -from commons.data_access_layer.cosmos_db import CosmosDBDao, CosmosDBRepository, CustomError, CosmosDBModel, \ - current_datetime, datetime_str +from commons.data_access_layer.cosmos_db import CosmosDBDao, CosmosDBRepository, CustomError, current_datetime_str, \ + CosmosDBModel from commons.data_access_layer.database import CRUDDao from time_tracker_api.security import current_user_id @@ -34,15 +34,15 @@ def find_running(self): @dataclass() class TimeEntryCosmosDBModel(CosmosDBModel): - id: str - description: str project_id: str - activity_id: str + start_date: str owner_id: str + id: str tenant_id: str + description: str = field(default=None) + activity_id: str = field(default=None) uri: str = field(default=None) technologies: List[str] = field(default_factory=list) - start_date: str = field(default_factory=current_datetime) end_date: str = field(default=None) deleted: str = field(default=None) @@ -76,6 +76,10 @@ def create_sql_ignore_id_condition(id: str): def on_create(self, new_item_data: dict): CosmosDBRepository.on_create(self, new_item_data) + + if new_item_data.get("start_date") is None: + new_item_data['start_date'] = current_datetime_str() + self.validate_data(new_item_data) def on_update(self, updated_item_data: dict): @@ -88,7 +92,7 @@ def find_interception_with_date_range(self, start_date, end_date, owner_id, part params = self.append_conditions_values([ {"name": "@partition_key_value", "value": partition_key_value}, {"name": "@start_date", "value": start_date}, - {"name": "@end_date", "value": end_date or datetime_str(current_datetime())}, + {"name": "@end_date", "value": end_date or current_datetime_str()}, {"name": "@ignore_id", "value": ignore_id}, ], conditions) result = self.container.query_items( @@ -121,15 +125,17 @@ def find_running(self, partition_key_value: str, mapper: Callable = None): return function_mapper(next(result)) def validate_data(self, data): + start_date = data.get('start_date') + if data.get('end_date') is not None: - if data['end_date'] <= data.get('start_date'): + if data['end_date'] <= start_date: raise CustomError(HTTPStatus.BAD_REQUEST, description="You must end the time entry after it started") - if data['end_date'] >= datetime_str(current_datetime()): + if data['end_date'] >= current_datetime_str(): raise CustomError(HTTPStatus.BAD_REQUEST, description="You cannot end a time entry in the future") - collision = self.find_interception_with_date_range(start_date=data.get('start_date'), + collision = self.find_interception_with_date_range(start_date=start_date, end_date=data.get('end_date'), owner_id=data.get('owner_id'), partition_key_value=data.get('tenant_id'), diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index a7bc2a42..e107729b 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -4,7 +4,7 @@ from flask_restplus import fields, Resource, Namespace from flask_restplus._http import HTTPStatus -from commons.data_access_layer.cosmos_db import current_datetime, datetime_str +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.time_entries.time_entries_model import create_dao @@ -25,7 +25,7 @@ 'start_date': fields.DateTime( dt_format='iso8601', title='Start date', - required=True, + required=False, description='When the user started doing this activity', example=datetime_str(current_datetime() - timedelta(days=1)), ), @@ -48,7 +48,7 @@ title='End date', required=False, description='When the user ended doing this activity', - example=datetime_str(current_datetime()), + example=current_datetime_str(), ), 'uri': fields.String( title='Uniform Resource identifier', @@ -165,7 +165,7 @@ class StopTimeEntry(Resource): def post(self, id): """Stop a running time entry""" return time_entries_dao.update(id, { - 'end_date': datetime_str(current_datetime()) + 'end_date': current_datetime_str() }) From 5012ff7d31073d2f90a9303409d04305198cbb4d Mon Sep 17 00:00:00 2001 From: semantic-release Date: Thu, 23 Apr 2020 15:33:14 +0000 Subject: [PATCH 012/361] 0.5.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 2b8877c5..93b60a1d 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.5.0' +__version__ = '0.5.1' From a1b1a6ab121050f7efb904807b022fd8ca73a6df Mon Sep 17 00:00:00 2001 From: Rene Enriquez Date: Tue, 21 Apr 2020 13:03:13 -0500 Subject: [PATCH 013/361] fix: #91 adding filter by attributes --- commons/data_access_layer/cosmos_db.py | 4 ++-- .../activities/activities_namespace_test.py | 2 +- .../customers/customers_namespace_test.py | 15 +++++++++++++++ .../project_types/project_types_namespace_test.py | 2 +- .../projects/projects_namespace_test.py | 2 +- .../activities/activities_namespace.py | 3 ++- time_tracker_api/customers/customers_namespace.py | 3 ++- .../project_types/project_types_namespace.py | 3 ++- time_tracker_api/projects/projects_namespace.py | 3 ++- 9 files changed, 28 insertions(+), 9 deletions(-) diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index f8f6c391..8c17abf0 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -217,8 +217,8 @@ 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: []) -> 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/tests/time_tracker_api/activities/activities_namespace_test.py b/tests/time_tracker_api/activities/activities_namespace_test.py index 508e7f88..e5cbad43 100644 --- a/tests/time_tracker_api/activities/activities_namespace_test.py +++ b/tests/time_tracker_api/activities/activities_namespace_test.py @@ -258,4 +258,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/customers/customers_namespace_test.py b/tests/time_tracker_api/customers/customers_namespace_test.py index 1777cbe8..f36fc3a9 100644 --- a/tests/time_tracker_api/customers/customers_namespace_test.py +++ b/tests/time_tracker_api/customers/customers_namespace_test.py @@ -3,6 +3,7 @@ from flask.testing import FlaskClient from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture +from werkzeug.datastructures import ImmutableMultiDict fake = Faker() @@ -68,6 +69,20 @@ def test_list_all_customers(client: FlaskClient, assert [] == json_data repository_find_all_mock.assert_called_once() +def test_list_all_customers_with_conditions(client: FlaskClient, mocker: MockFixture): + from time_tracker_api.customers.customers_namespace import customer_dao + repository_find_all_mock = mocker.patch.object(customer_dao.repository, + 'find_all', + return_value=[]) + + response = client.get("/customers?a=b", follow_redirects=True) + + assert HTTPStatus.OK == response.status_code + json_data = json.loads(response.data) + assert [] == json_data + repository_find_all_mock.assert_called_once_with(conditions=ImmutableMultiDict({'a': 'b'}), + partition_key_value='ioet') + def test_get_customer_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture, 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..5d79cb5a 100644 --- a/time_tracker_api/activities/activities_namespace.py +++ b/time_tracker_api/activities/activities_namespace.py @@ -1,6 +1,7 @@ from faker import Faker from flask_restplus import fields, Resource, Namespace from flask_restplus._http import HTTPStatus +from flask import request from time_tracker_api.activities.activities_model import create_dao from time_tracker_api.api import common_fields @@ -44,7 +45,7 @@ class Activities(Resource): @ns.marshal_list_with(activity) def get(self): """List all activities""" - return activity_dao.get_all() + return activity_dao.get_all(conditions=request.args) @ns.doc('create_activity') @ns.response(HTTPStatus.CONFLICT, 'This activity already exists') diff --git a/time_tracker_api/customers/customers_namespace.py b/time_tracker_api/customers/customers_namespace.py index f3b01fcc..e4a8dc57 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 @@ -45,7 +46,7 @@ class Customers(Resource): @ns.marshal_list_with(customer) def get(self): """List all customers""" - return customer_dao.get_all() + return customer_dao.get_all(conditions=request.args) @ns.doc('create_customer') @ns.response(HTTPStatus.CONFLICT, 'This customer already exists') diff --git a/time_tracker_api/project_types/project_types_namespace.py b/time_tracker_api/project_types/project_types_namespace.py index 0e6c0ac6..c01ffb9d 100644 --- a/time_tracker_api/project_types/project_types_namespace.py +++ b/time_tracker_api/project_types/project_types_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, UUID_REGEX from time_tracker_api.project_types.project_types_model import create_dao @@ -60,7 +61,7 @@ class ProjectTypes(Resource): @ns.marshal_list_with(project_type) def get(self): """List all project types""" - return project_type_dao.get_all() + return project_type_dao.get_all(conditions=request.args) @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..e0fdfc6d 100644 --- a/time_tracker_api/projects/projects_namespace.py +++ b/time_tracker_api/projects/projects_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, UUID_REGEX from time_tracker_api.projects.projects_model import create_dao @@ -61,7 +62,7 @@ class Projects(Resource): @ns.marshal_list_with(project) def get(self): """List all projects""" - return project_dao.get_all() + return project_dao.get_all(conditions=request.args) @ns.doc('create_project') @ns.response(HTTPStatus.CONFLICT, 'This project already exists') From beb8c84f36be17eac9e88979211cb01ec53573df Mon Sep 17 00:00:00 2001 From: Rene Enriquez Date: Thu, 23 Apr 2020 14:55:33 -0500 Subject: [PATCH 014/361] fix: #91 applying filters criteria on query parameter --- time_tracker_api/api.py | 10 +++++++++- time_tracker_api/collections/dictionary_utils.py | 16 ++++++++++++++++ time_tracker_api/projects/projects_namespace.py | 10 +++++++--- 3 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 time_tracker_api/collections/dictionary_utils.py diff --git a/time_tracker_api/api.py b/time_tracker_api/api.py index b269136b..df554c13 100644 --- a/time_tracker_api/api.py +++ b/time_tracker_api/api.py @@ -1,7 +1,7 @@ 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, reqparse from flask_restplus._http import HTTPStatus from commons.data_access_layer.cosmos_db import CustomError @@ -18,6 +18,14 @@ security="TimeTracker JWT", ) +# Filters +def create_attributes_filter(attributes_filter): + filter_attributes_parser = reqparse.RequestParser() + for attribute in attributes_filter: + filter_attributes_parser.add_argument(f'filters[{attribute}]', location='args') + + return filter_attributes_parser + # 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}' diff --git a/time_tracker_api/collections/dictionary_utils.py b/time_tracker_api/collections/dictionary_utils.py new file mode 100644 index 00000000..d6682860 --- /dev/null +++ b/time_tracker_api/collections/dictionary_utils.py @@ -0,0 +1,16 @@ +import re + +def remove_none_values(dictionary): + dictionary_with_values = {} + for key, value in dictionary.items(): + if value is not None: + dictionary_with_values.update({key: value}) + return dictionary_with_values + + +def remove_filters_wrapper_from_keys(dictionary): + dictionary_with_unwrapped_keys = {} + for key, value in dictionary.items(): + unwrapped_key = re.search('\\[(.+?)\\]', key).groups()[0] + dictionary_with_unwrapped_keys.update({unwrapped_key: value}) + return dictionary_with_unwrapped_keys diff --git a/time_tracker_api/projects/projects_namespace.py b/time_tracker_api/projects/projects_namespace.py index e0fdfc6d..6767c2a6 100644 --- a/time_tracker_api/projects/projects_namespace.py +++ b/time_tracker_api/projects/projects_namespace.py @@ -1,9 +1,10 @@ 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, UUID_REGEX +from time_tracker_api.api import common_fields, UUID_REGEX, create_attributes_filter + +from time_tracker_api.collections.dictionary_utils import remove_none_values, remove_filters_wrapper_from_keys from time_tracker_api.projects.projects_model import create_dao faker = Faker() @@ -55,14 +56,17 @@ project_dao = create_dao() +attributes_filter = create_attributes_filter(['customer_id']) @ns.route('') class Projects(Resource): @ns.doc('list_projects') @ns.marshal_list_with(project) + @ns.expect(attributes_filter) def get(self): """List all projects""" - return project_dao.get_all(conditions=request.args) + conditions = remove_none_values(attributes_filter.parse_args()) + return project_dao.get_all(conditions=remove_filters_wrapper_from_keys(conditions)) @ns.doc('create_project') @ns.response(HTTPStatus.CONFLICT, 'This project already exists') From 3646c123d16458a08c074efd0d89ba76801771b0 Mon Sep 17 00:00:00 2001 From: EliuX Date: Thu, 23 Apr 2020 15:54:40 -0500 Subject: [PATCH 015/361] fix: Close #103 Filter running time entry by owner_id --- README.md | 35 +++++++++++++++++-- commons/data_access_layer/cosmos_db.py | 16 +++++---- .../data_access_layer/cosmos_db_test.py | 20 +++++++++-- tests/commons/data_access_layer/sql_test.py | 4 +-- tests/conftest.py | 2 +- tests/time_tracker_api/security_test.py | 9 +++-- .../time_entries/time_entries_model_test.py | 13 ++++--- .../time_entries_namespace_test.py | 8 +++-- time_tracker_api/security.py | 2 +- .../time_entries/time_entries_model.py | 32 +++++++++++------ 10 files changed, 106 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index c49b99f3..000fb54b 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,31 @@ automatically [pip](https://pip.pypa.io/en/stable/) as well. - Open `http://127.0.0.1:5000/` in a browser. You will find in the presented UI a link to the swagger.json with the definition of the api. +### Security +In this API we are requiring authenticated users using JWT. To do so, we are using the library +[PyJWT](https://pypi.org/project/PyJWT/), so in every request to the API we expect a header `Authorization` with a format +like: + +>Bearer + +In the Swagger UI, you will now see a new button called "Authorize": +![image](https://user-images.githubusercontent.com/6514740/80011459-841f7580-8491-11ea-9c23-5bfb8822afe6.png) + +when you click it then you will be notified that you must enter the content of the Authorization header, as mentioned +before: +![image](https://user-images.githubusercontent.com/6514740/80011702-d95b8700-8491-11ea-973a-8aaf3cdadb00.png) + +Click "Authorize" and then close that dialog. From that moment forward you will not have to do it anymore because the +Swagger UI will use that JWT in every call, e.g. +![image](https://user-images.githubusercontent.com/6514740/80011852-0e67d980-8492-11ea-9dd3-2b1efeaa57d8.png) + +If you want to check out the data (claims) that your JWT contains, you can also use the CLI of +[PyJWT](https://pypi.org/project/PyJWT/): +``` +pyjwt decode --no-verify "" +``` + +Bear in mind that this API is not in charge of verifying the authenticity of the JWT, but the API Management. ### Important notes Due to the used technology and particularities on the implementation of this API, it is important that you respect the @@ -164,13 +189,17 @@ python cli.py gen_swagger_json -f ~/Downloads/swagger.json ## Semantic versioning ### Style -We use [angular commit message style](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits) as the standard commit message style. +We use [angular commit message style](https://github.com/angular/angular.js/blob/master/DEVELOPERS.md#commits) as the +standard commit message style. ### Release -1. The release is automatically done by the [TimeTracker CI](https://dev.azure.com/IOET-DevOps/TimeTracker-API/_build?definitionId=1&_a=summary) although can also be done manually. The variable `GH_TOKEN` is required to post releases to Github. The `GH_TOKEN` can be generated following [these steps](https://help.github.com/es/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line). +1. The release is automatically done by the [TimeTracker CI](https://dev.azure.com/IOET-DevOps/TimeTracker-API/_build?definitionId=1&_a=summary) +although can also be done manually. The variable `GH_TOKEN` is required to post releases to Github. The `GH_TOKEN` can +be generated following [these steps](https://help.github.com/es/github/authenticating-to-github/creating-a-personal-access-token-for-the-command-line). 2. We use the command `semantic-release publish` after a successful PR to make a release. Check the library -[python-semantic-release](https://python-semantic-release.readthedocs.io/en/latest/commands.html#publish) for details of underlying operations. +[python-semantic-release](https://python-semantic-release.readthedocs.io/en/latest/commands.html#publish) for details of +underlying operations. ## Run as docker container 1. Build image diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index f8f6c391..f064b175 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -37,7 +37,7 @@ def from_flask_config(cls, app: Flask): raise EnvironmentError("DATABASE_MASTER_KEY is not defined in the environment") client = cosmos_client.CosmosClient(account_uri, {'masterKey': master_key}, - user_agent="CosmosDBDotnetQuickstart", + user_agent="TimeTrackerAPI", user_agent_overwrite=True) else: client = cosmos_client.CosmosClient.from_connection_string(db_uri) @@ -108,7 +108,7 @@ def create_sql_condition_for_visibility(visible_only: bool, container_name='c') def create_sql_where_conditions(conditions: dict, container_name='c') -> str: where_conditions = [] for k in conditions.keys(): - where_conditions.append('{c}.{var} = @{var}'.format(c=container_name, var=k)) + where_conditions.append(f'{container_name}.{k} = @{k}') if len(where_conditions) > 0: return "AND {where_conditions_clause}".format( @@ -117,14 +117,15 @@ def create_sql_where_conditions(conditions: dict, container_name='c') -> str: return "" @staticmethod - def append_conditions_values(params: list, conditions: dict) -> dict: + def generate_condition_values(conditions: dict) -> dict: + result = [] for k, v in conditions.items(): - params.append({ + result.append({ "name": "@%s" % k, "value": v }) - return params + return result @staticmethod def check_visibility(item, throw_not_found_if_deleted): @@ -152,11 +153,12 @@ def find_all(self, partition_key_value: str, conditions: dict = {}, max_count=No visible_only=True, mapper: Callable = None): # TODO Use the tenant_id param and change container alias max_count = self.get_page_size_or(max_count) - params = self.append_conditions_values([ + params = [ {"name": "@partition_key_value", "value": partition_key_value}, {"name": "@offset", "value": offset}, {"name": "@max_count", "value": max_count}, - ], conditions) + ] + params.extend(self.generate_condition_values(conditions)) result = self.container.query_items(query=""" SELECT * FROM c WHERE c.{partition_key_attribute}=@partition_key_value {conditions_clause} {visibility_condition} {order_clause} diff --git a/tests/commons/data_access_layer/cosmos_db_test.py b/tests/commons/data_access_layer/cosmos_db_test.py index b19cb5c6..571311bc 100644 --- a/tests/commons/data_access_layer/cosmos_db_test.py +++ b/tests/commons/data_access_layer/cosmos_db_test.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from datetime import timedelta from typing import Callable import pytest @@ -7,7 +8,8 @@ from flask_restplus._http import HTTPStatus from pytest import fail -from commons.data_access_layer.cosmos_db import CosmosDBRepository, CosmosDBModel, CustomError +from commons.data_access_layer.cosmos_db import CosmosDBRepository, CosmosDBModel, CustomError, current_datetime, \ + datetime_str fake = Faker() Faker.seed() @@ -557,7 +559,7 @@ def test_repository_create_sql_where_conditions_with_no_values(cosmos_db_reposit def test_repository_append_conditions_values(cosmos_db_repository: CosmosDBRepository): - result = cosmos_db_repository.append_conditions_values([], {'owner_id': 'mark', 'customer_id': 'ioet'}) + result = cosmos_db_repository.generate_condition_values({'owner_id': 'mark', 'customer_id': 'ioet'}) assert result is not None assert result == [{'name': '@owner_id', 'value': 'mark'}, @@ -586,3 +588,17 @@ def raise_bad_request_if_name_diff_the_one_from_sample_item(data: dict): except Exception as e: assert e.code == HTTPStatus.BAD_REQUEST assert e.description == "Anything" + + +def test_datetime_str_comparison(): + now = current_datetime() + now_str = datetime_str(now) + + assert now_str > datetime_str(now - timedelta(microseconds=1)) + assert now_str < datetime_str(now + timedelta(microseconds=1)) + + assert now_str > datetime_str(now - timedelta(seconds=1)) + assert now_str < datetime_str(now + timedelta(seconds=1)) + + assert now_str > datetime_str(now - timedelta(days=1)) + assert now_str < datetime_str(now + timedelta(days=1)) diff --git a/tests/commons/data_access_layer/sql_test.py b/tests/commons/data_access_layer/sql_test.py index 26f992b8..0c602f15 100644 --- a/tests/commons/data_access_layer/sql_test.py +++ b/tests/commons/data_access_layer/sql_test.py @@ -56,10 +56,10 @@ def test_find_all_that_contains_property_with_string_case_insensitive(sql_reposi existing_elements_registry.append(new_element) search_snow_result = sql_repository.find_all_contain_str('name', 'Snow') - assert len(search_snow_result) == 2 + assert 2 == len(search_snow_result) search_jon_result = sql_repository.find_all_contain_str('name', 'Jon') - assert len(search_jon_result) == 1 + assert 1 == len(search_jon_result) search_ram_result = sql_repository.find_all_contain_str('name', fake_name) assert search_ram_result[0].name == new_element['name'] diff --git a/tests/conftest.py b/tests/conftest.py index 0f1feb23..97ca2862 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -167,7 +167,7 @@ def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository, def valid_jwt(app: Flask, tenant_id: str, owner_id: str) -> str: expiration_time = datetime.utcnow() + timedelta(seconds=3600) return jwt.encode({ - "iss": "https://securityioet.b2clogin.com/%s/v2.0/" % tenant_id, + "iss": "https://ioetec.b2clogin.com/%s/v2.0/" % tenant_id, "oid": owner_id, 'exp': expiration_time }, key=get_or_generate_dev_secret_key()).decode("UTF-8") diff --git a/tests/time_tracker_api/security_test.py b/tests/time_tracker_api/security_test.py index 54ffe5e8..7b62eb45 100644 --- a/tests/time_tracker_api/security_test.py +++ b/tests/time_tracker_api/security_test.py @@ -1,3 +1,5 @@ +import pytest + from time_tracker_api.security import parse_jwt, parse_tenant_id_from_iss_claim @@ -14,8 +16,11 @@ def test_parse_jwt_with_invalid_input(): assert result is None -def test_parse_tenant_id_from_iss_claim_with_valid_input(): - valid_iss_claim = "https://securityioet.b2clogin.com/b21c4e98-c4bf-420f-9d76-e51c2515c7a4/v2.0/" +@pytest.mark.parametrize( + 'domain_prefix', ['securityioet', 'ioetec', 'anything-else'] +) +def test_parse_tenant_id_from_iss_claim_with_valid_input(domain_prefix): + valid_iss_claim = f'https://{domain_prefix}.b2clogin.com/b21c4e98-c4bf-420f-9d76-e51c2515c7a4/v2.0/' result = parse_tenant_id_from_iss_claim(valid_iss_claim) 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 43bd998c..80505625 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 @@ -81,16 +81,21 @@ def test_find_interception_should_ignore_id_of_existing_item(owner_id: str, def test_find_running_should_return_running_time_entry(running_time_entry, + owner_id: str, time_entry_repository: TimeEntryCosmosDBRepository): - found_time_entry = time_entry_repository.find_running(partition_key_value=running_time_entry.tenant_id) + found_time_entry = time_entry_repository.find_running(partition_key_value=running_time_entry.tenant_id, + owner_id=owner_id) - assert found_time_entry is not None - assert found_time_entry.id == running_time_entry.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): try: - time_entry_repository.find_running(partition_key_value=tenant_id) + time_entry_repository.find_running(partition_key_value=tenant_id, + owner_id=owner_id) except Exception as e: assert type(e) is StopIteration 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 f3fe83bb..5ddc9741 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 @@ -390,6 +390,7 @@ def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, def test_get_running_should_call_find_running(client: FlaskClient, mocker: MockFixture, valid_header: dict, + owner_id: str, tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, @@ -402,12 +403,14 @@ def test_get_running_should_call_find_running(client: FlaskClient, assert HTTPStatus.OK == response.status_code assert json.loads(response.data) is not None - repository_update_mock.assert_called_once_with(partition_key_value=tenant_id) + repository_update_mock.assert_called_once_with(partition_key_value=tenant_id, + owner_id=owner_id) def test_get_running_should_return_not_found_if_find_running_throws_StopIteration(client: FlaskClient, mocker: MockFixture, valid_header: dict, + owner_id: str, tenant_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, @@ -419,4 +422,5 @@ def test_get_running_should_return_not_found_if_find_running_throws_StopIteratio follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_update_mock.assert_called_once_with(partition_key_value=tenant_id) + repository_update_mock.assert_called_once_with(partition_key_value=tenant_id, + owner_id=owner_id) diff --git a/time_tracker_api/security.py b/time_tracker_api/security.py index d0c382ec..754671e6 100644 --- a/time_tracker_api/security.py +++ b/time_tracker_api/security.py @@ -25,7 +25,7 @@ } iss_claim_pattern = re.compile( - r"securityioet.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})") + 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})") 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 cfecfc79..2ba04889 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -88,20 +88,22 @@ def on_update(self, updated_item_data: dict): def find_interception_with_date_range(self, start_date, end_date, owner_id, partition_key_value, ignore_id=None, visible_only=True, mapper: Callable = None): - conditions = {"owner_id": owner_id} - params = self.append_conditions_values([ - {"name": "@partition_key_value", "value": partition_key_value}, + conditions = { + "owner_id": owner_id, + "tenant_id": partition_key_value, + } + params = [ {"name": "@start_date", "value": start_date}, {"name": "@end_date", "value": end_date or current_datetime_str()}, {"name": "@ignore_id", "value": ignore_id}, - ], conditions) + ] + params.extend(self.generate_condition_values(conditions)) result = self.container.query_items( query=""" - SELECT * FROM c WHERE c.tenant_id=@partition_key_value - AND ((c.start_date BETWEEN @start_date AND @end_date) OR (c.end_date BETWEEN @start_date AND @end_date)) + SELECT * FROM c WHERE ((c.start_date BETWEEN @start_date AND @end_date) + OR (c.end_date BETWEEN @start_date AND @end_date)) {conditions_clause} {ignore_id_condition} {visibility_condition} {order_clause} - """.format(partition_key_attribute=self.partition_key_attribute, - ignore_id_condition=self.create_sql_ignore_id_condition(ignore_id), + """.format(ignore_id_condition=self.create_sql_ignore_id_condition(ignore_id), visibility_condition=self.create_sql_condition_for_visibility(visible_only), conditions_clause=self.create_sql_where_conditions(conditions), order_clause=self.create_sql_order_clause()), @@ -111,15 +113,22 @@ def find_interception_with_date_range(self, start_date, end_date, owner_id, part function_mapper = self.get_mapper_or_dict(mapper) return list(map(function_mapper, result)) - def find_running(self, partition_key_value: str, mapper: Callable = None): + def find_running(self, partition_key_value: str, owner_id: str, mapper: Callable = None): + conditions = { + "owner_id": owner_id, + "tenant_id": partition_key_value, + } result = self.container.query_items( query=""" SELECT * from c - WHERE (NOT IS_DEFINED(c.end_date) OR c.end_date = null) {visibility_condition} + WHERE (NOT IS_DEFINED(c.end_date) OR c.end_date = null) + {conditions_clause} {visibility_condition} OFFSET 0 LIMIT 1 """.format( visibility_condition=self.create_sql_condition_for_visibility(True), + conditions_clause=self.create_sql_where_conditions(conditions), ), + parameters=self.generate_condition_values(conditions), partition_key=partition_key_value, max_item_count=1) @@ -183,7 +192,8 @@ def delete(self, id): peeker=self.check_whether_current_user_owns_item) def find_running(self): - return self.repository.find_running(partition_key_value=self.partition_key_value) + return self.repository.find_running(partition_key_value=self.partition_key_value, + owner_id=self.current_user_id()) def create_dao() -> TimeEntriesDao: From afad986100545580d5a21e186af1b3d8bb619805 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Thu, 23 Apr 2020 21:18:33 +0000 Subject: [PATCH 016/361] 0.5.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 93b60a1d..45869b62 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.5.1' +__version__ = '0.5.2' From 9d9083ee1c73e6cab9a452f7cdfe84243872aae5 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 24 Apr 2020 15:44:59 +0000 Subject: [PATCH 017/361] 0.6.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 45869b62..ef7eb44d 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.5.2' +__version__ = '0.6.0' From 32a80b4c44eea9dda8803b46d84813f680242ae0 Mon Sep 17 00:00:00 2001 From: EliuX Date: Fri, 24 Apr 2020 17:24:34 -0500 Subject: [PATCH 018/361] fix:bug: Close #107 Allow removing id by specifying null --- .../project_types_namespace_test.py | 4 ++-- .../time_entries_namespace_test.py | 23 +++++++++---------- .../activities/activities_namespace.py | 6 ++--- time_tracker_api/api.py | 21 +++++++++++------ .../customers/customers_namespace.py | 6 ++--- .../project_types/project_types_namespace.py | 13 ++++------- .../projects/projects_namespace.py | 13 ++++------- .../time_entries/time_entries_namespace.py | 15 +++++------- 8 files changed, 49 insertions(+), 52 deletions(-) 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..c14122fe 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 @@ -42,7 +42,7 @@ def test_create_project_type_should_reject_bad_request(client: FlaskClient, from time_tracker_api.project_types.project_types_namespace import project_type_dao invalid_project_type_data = valid_project_type_data.copy() invalid_project_type_data.update({ - "parent_id": None, + "name": None, }) repository_create_mock = mocker.patch.object(project_type_dao.repository, 'create', @@ -166,7 +166,7 @@ def test_update_project_should_reject_bad_request(client: FlaskClient, from time_tracker_api.project_types.project_types_namespace import project_type_dao invalid_project_type_data = valid_project_type_data.copy() invalid_project_type_data.update({ - "parent_id": None, + "name": None, }) repository_update_mock = mocker.patch.object(project_type_dao.repository, 'partial_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 5ddc9741..65639cfe 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,7 +7,7 @@ from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture -from commons.data_access_layer.cosmos_db import current_datetime +from commons.data_access_layer.cosmos_db import current_datetime, current_datetime_str fake = Faker() @@ -16,14 +16,14 @@ "project_id": fake.uuid4(), "activity_id": fake.uuid4(), "description": fake.paragraph(nb_sentences=2), - "start_date": str(yesterday.isoformat()), - "owner_id": fake.uuid4(), - "tenant_id": fake.uuid4() + "start_date": current_datetime_str(), } fake_time_entry = ({ "id": fake.random_int(1, 9999), "running": True, + "owner_id": fake.uuid4(), + "tenant_id": fake.uuid4(), }) fake_time_entry.update(valid_time_entry_input) @@ -86,21 +86,20 @@ def test_create_time_entry_should_succeed_with_valid_request(client: FlaskClient repository_create_mock.assert_called_once() -def test_create_time_entry_should_reject_bad_request(client: FlaskClient, - mocker: MockFixture, - valid_header: dict): +def test_create_time_entry_with_missing_req_field_should_return_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao - invalid_time_entry_input = valid_time_entry_input.copy() - invalid_time_entry_input.update({ - "project_id": None, - }) repository_create_mock = mocker.patch.object(time_entries_dao.repository, 'create', return_value=fake_time_entry) response = client.post("/time-entries", headers=valid_header, - json=invalid_time_entry_input, + json={ + "activity_id": fake.uuid4(), + "start_date": current_datetime_str(), + }, follow_redirects=True) assert HTTPStatus.BAD_REQUEST == response.status_code diff --git a/time_tracker_api/activities/activities_namespace.py b/time_tracker_api/activities/activities_namespace.py index d4f42f20..ba783bc2 100644 --- a/time_tracker_api/activities/activities_namespace.py +++ b/time_tracker_api/activities/activities_namespace.py @@ -1,13 +1,13 @@ from faker import Faker -from flask_restplus import fields, Resource, Namespace +from flask_restplus import fields, Resource from flask_restplus._http import HTTPStatus from time_tracker_api.activities.activities_model import create_dao -from time_tracker_api.api import common_fields +from time_tracker_api.api import common_fields, api faker = Faker() -ns = Namespace('activities', description='API for activities') +ns = api.namespace('activities', description='Namespace of the API for activities') # Activity Model activity_input = ns.model('ActivityInput', { diff --git a/time_tracker_api/api.py b/time_tracker_api/api.py index 3b686d22..a1618a69 100644 --- a/time_tracker_api/api.py +++ b/time_tracker_api/api.py @@ -38,30 +38,37 @@ def create_attributes_filter(ns: namespace, model: Model, filter_attrib_names: l return attribs_parser -# Common models structure +# Custom fields +class NullableString(fields.String): + __schema_type__ = ['string', 'null'] + + +class UUID(NullableString): + def __init__(self, *args, **kwargs): + super(UUID, self).__init__(*args, **kwargs) + self.pattern = UUID_REGEX + + common_fields = { - 'id': fields.String( + 'id': UUID( title='Identifier', readOnly=True, required=True, description='The unique identifier', - pattern=UUID_REGEX, example=faker.uuid4(), ), - 'tenant_id': fields.String( + 'tenant_id': UUID( title='Identifier of Tenant', readOnly=True, required=True, description='Tenant it belongs to', - # pattern=UUID_REGEX, This must be confirmed example=faker.uuid4(), ), - 'deleted': fields.String( + 'deleted': UUID( readOnly=True, required=True, title='Last event Identifier', description='Last event over this resource', - pattern=UUID_REGEX, ), } diff --git a/time_tracker_api/customers/customers_namespace.py b/time_tracker_api/customers/customers_namespace.py index f3b01fcc..f455cb22 100644 --- a/time_tracker_api/customers/customers_namespace.py +++ b/time_tracker_api/customers/customers_namespace.py @@ -1,13 +1,13 @@ from faker import Faker -from flask_restplus import Namespace, Resource, fields +from flask_restplus import Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import common_fields +from time_tracker_api.api import common_fields, api from time_tracker_api.customers.customers_model import create_dao faker = Faker() -ns = Namespace('customers', description='API for customers') +ns = api.namespace('customers', description='Namespace of the API for customers') # Customer Model customer_input = ns.model('CustomerInput', { diff --git a/time_tracker_api/project_types/project_types_namespace.py b/time_tracker_api/project_types/project_types_namespace.py index 116aa901..8e1e759e 100644 --- a/time_tracker_api/project_types/project_types_namespace.py +++ b/time_tracker_api/project_types/project_types_namespace.py @@ -1,14 +1,13 @@ from faker import Faker -from flask_restplus import Namespace, Resource, fields +from flask_restplus import Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import common_fields, create_attributes_filter +from time_tracker_api.api import common_fields, create_attributes_filter, api, UUID from time_tracker_api.project_types.project_types_model import create_dao -from time_tracker_api.security import UUID_REGEX faker = Faker() -ns = Namespace('project-types', description='API for project types') +ns = api.namespace('project-types', description='Namespace of the API for project types') # ProjectType Model project_type_input = ns.model('ProjectTypeInput', { @@ -26,19 +25,17 @@ description='Comments about the project type', example=faker.paragraph(), ), - 'customer_id': fields.String( + 'customer_id': UUID( title='Identifier of the Customer', required=False, description='Customer this project type belongs to. ' 'If not specified, it will be considered an internal project of the tenant.', - pattern=UUID_REGEX, example=faker.uuid4(), ), - 'parent_id': fields.String( + 'parent_id': UUID( title='Identifier of the parent project type', required=False, description='This parent node allows to created a tree-like structure for project types', - pattern=UUID_REGEX, example=faker.uuid4(), ), }) diff --git a/time_tracker_api/projects/projects_namespace.py b/time_tracker_api/projects/projects_namespace.py index 15613f92..634af17e 100644 --- a/time_tracker_api/projects/projects_namespace.py +++ b/time_tracker_api/projects/projects_namespace.py @@ -1,14 +1,13 @@ from faker import Faker -from flask_restplus import Namespace, Resource, fields +from flask_restplus import Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import common_fields, create_attributes_filter +from time_tracker_api.api import common_fields, create_attributes_filter, UUID, api from time_tracker_api.projects.projects_model import create_dao -from time_tracker_api.security import UUID_REGEX faker = Faker() -ns = Namespace('projects', description='API for projects (clients)') +ns = api.namespace('projects', description='Namespace of the API for projects') # Project Model project_input = ns.model('ProjectInput', { @@ -26,19 +25,17 @@ description='Description about the project', example=faker.paragraph(), ), - 'customer_id': fields.String( + 'customer_id': UUID( title='Identifier of the Customer', required=False, description='Customer this project type belongs to. ' 'If not specified, it will be considered an internal project of the tenant.', - pattern=UUID_REGEX, example=faker.uuid4(), ), - 'project_type_id': fields.String( + 'project_type_id': UUID( title='Identifier of the project type', required=False, description='Id of the project type it belongs. This allows grouping the projects.', - pattern=UUID_REGEX, example=faker.uuid4(), ), }) diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index 66a7eedf..87ba7f46 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -1,26 +1,24 @@ from datetime import timedelta from faker import Faker -from flask_restplus import fields, Resource, Namespace +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 commons.data_access_layer.database import COMMENTS_MAX_LENGTH -from time_tracker_api.api import common_fields, create_attributes_filter -from time_tracker_api.security import UUID_REGEX +from time_tracker_api.api import common_fields, create_attributes_filter, api, UUID from time_tracker_api.time_entries.time_entries_model import create_dao faker = Faker() -ns = Namespace('time-entries', description='API for time entries') +ns = api.namespace('time-entries', description='Namespace of the API for time entries') # TimeEntry Model time_entry_input = ns.model('TimeEntryInput', { - 'project_id': fields.String( + 'project_id': UUID( title='Project', required=True, description='The id of the selected project', - pattern=UUID_REGEX, example=faker.uuid4(), ), 'start_date': fields.DateTime( @@ -30,11 +28,10 @@ description='When the user started doing this activity', example=datetime_str(current_datetime() - timedelta(days=1)), ), - 'activity_id': fields.String( + 'activity_id': UUID( title='Activity', required=False, description='The id of the selected activity', - pattern=UUID_REGEX, example=faker.uuid4(), ), 'description': fields.String( @@ -86,7 +83,7 @@ description='Whether this time entry is currently running or not', example=faker.boolean(), ), - 'owner_id': fields.String( + 'owner_id': UUID( required=True, readOnly=True, title='Owner of time entry', From 7dc262f3a61f541a3cb6ba3293ee7c57a5aa96f2 Mon Sep 17 00:00:00 2001 From: EliuX Date: Fri, 24 Apr 2020 19:09:55 -0500 Subject: [PATCH 019/361] feat: Close #107 Allow empty char values to be converted to null --- commons/data_access_layer/cosmos_db.py | 9 +++- .../data_access_layer/cosmos_db_test.py | 25 ++++++++++ .../time_entries_namespace_test.py | 49 ++++++++++++++++++- time_tracker_api/api.py | 2 +- .../time_entries/time_entries_model.py | 1 + 5 files changed, 83 insertions(+), 3 deletions(-) diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index cb2ab0c1..eb104a2c 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -132,9 +132,14 @@ def check_visibility(item, throw_not_found_if_deleted): if throw_not_found_if_deleted and item.get('deleted') is not None: raise exceptions.CosmosResourceNotFoundError(message='Deleted item', status_code=404) - return item + @staticmethod + def replace_empty_value_per_none(item_data: dict) -> dict: + for k, v in item_data.items(): + if isinstance(v, str) and len(v) == 0: + item_data[k] = None + def create(self, data: dict, mapper: Callable = None): self.on_create(data) function_mapper = self.get_mapper_or_dict(mapper) @@ -207,6 +212,8 @@ def on_create(self, new_item_data: dict): if new_item_data.get('id') is None: new_item_data['id'] = generate_uuid4() + self.replace_empty_value_per_none(new_item_data) + def on_update(self, update_item_data: dict): pass diff --git a/tests/commons/data_access_layer/cosmos_db_test.py b/tests/commons/data_access_layer/cosmos_db_test.py index 571311bc..0059370d 100644 --- a/tests/commons/data_access_layer/cosmos_db_test.py +++ b/tests/commons/data_access_layer/cosmos_db_test.py @@ -602,3 +602,28 @@ def test_datetime_str_comparison(): assert now_str > datetime_str(now - timedelta(days=1)) assert now_str < datetime_str(now + timedelta(days=1)) + + +def test_replace_empty_value_per_none(tenant_id: str): + initial_value = dict(id=fake.uuid4(), + name=fake.name(), + empty_str_attrib="", + array_attrib=[1, 2, 3], + empty_array_attrib=[], + description=" ", + age=fake.pyint(min_value=10, max_value=80), + size=0, + tenant_id=tenant_id) + + input = initial_value.copy() + + CosmosDBRepository.replace_empty_value_per_none(input) + + assert input["name"] == initial_value["name"] + assert input["empty_str_attrib"] is None + assert input["array_attrib"] == initial_value["array_attrib"] + assert input["empty_array_attrib"] == initial_value["empty_array_attrib"] + assert input["description"] == initial_value["description"] + assert input["age"] == initial_value["age"] + assert input["size"] == initial_value["size"] + assert input["tenant_id"] == initial_value["tenant_id"] 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 65639cfe..dc001333 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 @@ -5,7 +5,7 @@ from flask import json from flask.testing import FlaskClient from flask_restplus._http import HTTPStatus -from pytest_mock import MockFixture +from pytest_mock import MockFixture, pytest from commons.data_access_layer.cosmos_db import current_datetime, current_datetime_str @@ -423,3 +423,50 @@ def test_get_running_should_return_not_found_if_find_running_throws_StopIteratio assert HTTPStatus.NOT_FOUND == response.status_code repository_update_mock.assert_called_once_with(partition_key_value=tenant_id, owner_id=owner_id) + +@pytest.mark.parametrize( + 'invalid_uuid', ["zxy", "zxy%s" % fake.uuid4(), "%szxy" % fake.uuid4(), " "] +) +def test_create_with_invalid_uuid_format_should_return_bad_request(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + invalid_uuid: str): + from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao + repository_container_create_item_mock = mocker.patch.object(time_entries_dao.repository.container, + 'create_item', + return_value=fake_time_entry) + invalid_time_entry_input = { + "project_id": fake.uuid4(), + "activity_id": invalid_uuid, + } + response = client.post("/time-entries", + headers=valid_header, + json=invalid_time_entry_input, + follow_redirects=True) + + assert HTTPStatus.BAD_REQUEST == response.status_code + repository_container_create_item_mock.assert_not_called() + +@pytest.mark.parametrize( + 'valid_uuid', ["", fake.uuid4()] +) +def test_create_with_valid_uuid_format_should_return_created(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + valid_uuid: str): + from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao + repository_container_create_item_mock = mocker.patch.object(time_entries_dao.repository.container, + 'create_item', + return_value=fake_time_entry) + invalid_time_entry_input = { + "project_id": fake.uuid4(), + "activity_id": valid_uuid, + } + response = client.post("/time-entries", + headers=valid_header, + json=invalid_time_entry_input, + follow_redirects=True) + + assert HTTPStatus.CREATED == response.status_code + repository_container_create_item_mock.assert_called() + diff --git a/time_tracker_api/api.py b/time_tracker_api/api.py index a1618a69..089a04d6 100644 --- a/time_tracker_api/api.py +++ b/time_tracker_api/api.py @@ -46,7 +46,7 @@ class NullableString(fields.String): class UUID(NullableString): def __init__(self, *args, **kwargs): super(UUID, self).__init__(*args, **kwargs) - self.pattern = UUID_REGEX + self.pattern = r"^(|%s)$" % UUID_REGEX common_fields = { diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 16bb557e..43f8aacf 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -85,6 +85,7 @@ def on_create(self, new_item_data: dict): def on_update(self, updated_item_data: dict): CosmosDBRepository.on_update(self, updated_item_data) self.validate_data(updated_item_data) + self.replace_empty_value_per_none(updated_item_data) def find_interception_with_date_range(self, start_date, end_date, owner_id, partition_key_value, ignore_id=None, visible_only=True, mapper: Callable = None): From 5bee71ff4408801bdc1874db284766f45729f515 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sat, 25 Apr 2020 00:25:07 +0000 Subject: [PATCH 020/361] 0.7.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 ef7eb44d..a71c5c7f 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.6.0' +__version__ = '0.7.0' From 19e94bcfe7e11e1d4e95326aa57a6d5f1a784f40 Mon Sep 17 00:00:00 2001 From: EliuX Date: Mon, 27 Apr 2020 14:14:08 -0500 Subject: [PATCH 021/361] fix: Close #111 Set non required strings fields to NullableString --- time_tracker_api/time_entries/time_entries_namespace.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index 87ba7f46..78690df2 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -6,7 +6,7 @@ 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, create_attributes_filter, api, UUID +from time_tracker_api.api import common_fields, create_attributes_filter, api, UUID, NullableString from time_tracker_api.time_entries.time_entries_model import create_dao faker = Faker() @@ -48,7 +48,7 @@ description='When the user ended doing this activity', example=current_datetime_str(), ), - 'uri': fields.String( + 'uri': NullableString( title='Uniform Resource identifier', description='Either identifier or locator of a resource in the Internet that helps to understand' ' what this time entry was about. For example, A Jira ticket, a Github issue, a Google document.', From 36563eef3d6662e87826c40b15f6cd1599d92446 Mon Sep 17 00:00:00 2001 From: EliuX Date: Mon, 27 Apr 2020 15:44:26 -0500 Subject: [PATCH 022/361] fix: Close #112 Ignore required fields to update items --- tests/time_tracker_api/api_test.py | 21 +++++++++++ time_tracker_api/__init__.py | 4 +- .../activities/activities_namespace.py | 4 +- time_tracker_api/api.py | 37 ++++++++++++------- .../customers/customers_namespace.py | 4 +- .../project_types/project_types_namespace.py | 4 +- .../projects/projects_namespace.py | 4 +- .../time_entries/time_entries_namespace.py | 5 ++- 8 files changed, 58 insertions(+), 25 deletions(-) diff --git a/tests/time_tracker_api/api_test.py b/tests/time_tracker_api/api_test.py index a9af25da..6f9ad353 100644 --- a/tests/time_tracker_api/api_test.py +++ b/tests/time_tracker_api/api_test.py @@ -22,3 +22,24 @@ def test_create_attributes_filter_with_valid_attribute_should_succeed(): assert filter is not None assert type(filter) is RequestParser + + +def test_remove_required_constraint(): + from time_tracker_api.api import remove_required_constraint + from flask_restplus import fields + from flask_restplus import Namespace + + ns = Namespace('todos', description='Namespace for testing') + sample_model = ns.model('Todo', { + 'id': fields.Integer(readonly=True, description='The task unique identifier'), + 'task': fields.String(required=True, description='The task details'), + 'done': fields.Boolean(required=False, description='Has it being done or not') + }) + + new_model = remove_required_constraint(sample_model) + + assert new_model is not sample_model + + for attrib in sample_model: + assert new_model[attrib].required is False, "No attribute should be required" + assert new_model[attrib] is not sample_model[attrib], "No attribute should be required" diff --git a/time_tracker_api/__init__.py b/time_tracker_api/__init__.py index 848a01ac..b23a3c6b 100644 --- a/time_tracker_api/__init__.py +++ b/time_tracker_api/__init__.py @@ -38,8 +38,8 @@ def init_app(app: Flask): from commons.data_access_layer.database import init_app as init_database init_database(app) - from time_tracker_api.api import api - api.init_app(app) + from time_tracker_api.api import init_app + init_app(app) if app.config.get('DEBUG'): app.logger.setLevel(logging.INFO) diff --git a/time_tracker_api/activities/activities_namespace.py b/time_tracker_api/activities/activities_namespace.py index ba783bc2..1dffc3dc 100644 --- a/time_tracker_api/activities/activities_namespace.py +++ b/time_tracker_api/activities/activities_namespace.py @@ -3,7 +3,7 @@ from flask_restplus._http import HTTPStatus from time_tracker_api.activities.activities_model import create_dao -from time_tracker_api.api import common_fields, api +from time_tracker_api.api import common_fields, api, remove_required_constraint faker = Faker() @@ -68,7 +68,7 @@ def get(self, id): return activity_dao.get(id) @ns.doc('update_activity') - @ns.expect(activity_input) + @ns.expect(remove_required_constraint(activity_input)) @ns.response(HTTPStatus.BAD_REQUEST, 'Invalid format or structure of the activity') @ns.response(HTTPStatus.CONFLICT, 'An activity already exists with this new data') @ns.marshal_with(activity) diff --git a/time_tracker_api/api.py b/time_tracker_api/api.py index 089a04d6..59b8b3cd 100644 --- a/time_tracker_api/api.py +++ b/time_tracker_api/api.py @@ -1,6 +1,6 @@ from azure.cosmos.exceptions import CosmosResourceExistsError, CosmosResourceNotFoundError, CosmosHttpResponseError from faker import Faker -from flask import current_app as app +from flask import current_app as app, Flask from flask_restplus import Api, fields, Model from flask_restplus import namespace from flask_restplus._http import HTTPStatus @@ -22,7 +22,14 @@ ) -# Filters +def remove_required_constraint(model: Model): + result = model.resolved + for attrib in result: + result[attrib].required = False + + return result + + def create_attributes_filter(ns: namespace, model: Model, filter_attrib_names: list) -> RequestParser: attribs_parser = ns.parser() model_attributes = model.resolved @@ -72,26 +79,30 @@ def __init__(self, *args, **kwargs): ), } -# APIs -from time_tracker_api.projects import projects_namespace -api.add_namespace(projects_namespace.ns) +def init_app(app: Flask): + api.init_app(app) + + from time_tracker_api.projects import projects_namespace + + api.add_namespace(projects_namespace.ns) + + from time_tracker_api.activities import activities_namespace -from time_tracker_api.activities import activities_namespace + api.add_namespace(activities_namespace.ns) -api.add_namespace(activities_namespace.ns) + from time_tracker_api.time_entries import time_entries_namespace -from time_tracker_api.time_entries import time_entries_namespace + api.add_namespace(time_entries_namespace.ns) -api.add_namespace(time_entries_namespace.ns) + from time_tracker_api.project_types import project_types_namespace -from time_tracker_api.project_types import project_types_namespace + api.add_namespace(project_types_namespace.ns) -api.add_namespace(project_types_namespace.ns) + from time_tracker_api.customers import customers_namespace -from time_tracker_api.customers import customers_namespace + api.add_namespace(customers_namespace.ns) -api.add_namespace(customers_namespace.ns) """ Error handlers diff --git a/time_tracker_api/customers/customers_namespace.py b/time_tracker_api/customers/customers_namespace.py index f455cb22..d38f4566 100644 --- a/time_tracker_api/customers/customers_namespace.py +++ b/time_tracker_api/customers/customers_namespace.py @@ -2,7 +2,7 @@ from flask_restplus import Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import common_fields, api +from time_tracker_api.api import common_fields, api, remove_required_constraint from time_tracker_api.customers.customers_model import create_dao faker = Faker() @@ -74,7 +74,7 @@ def get(self, id): @ns.response(HTTPStatus.BAD_REQUEST, 'Invalid format or structure ' 'of the attributes of the customer') @ns.response(HTTPStatus.CONFLICT, 'A customer already exists with this new data') - @ns.expect(customer_input) + @ns.expect(remove_required_constraint(customer_input)) @ns.marshal_with(customer) def put(self, id): """Update a customer""" diff --git a/time_tracker_api/project_types/project_types_namespace.py b/time_tracker_api/project_types/project_types_namespace.py index 8e1e759e..7d717b7f 100644 --- a/time_tracker_api/project_types/project_types_namespace.py +++ b/time_tracker_api/project_types/project_types_namespace.py @@ -2,7 +2,7 @@ from flask_restplus import Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import common_fields, create_attributes_filter, api, UUID +from time_tracker_api.api import common_fields, create_attributes_filter, api, UUID, remove_required_constraint from time_tracker_api.project_types.project_types_model import create_dao faker = Faker() @@ -94,7 +94,7 @@ def get(self, id): @ns.response(HTTPStatus.BAD_REQUEST, 'Invalid format or structure ' 'of the attributes of the project type') @ns.response(HTTPStatus.CONFLICT, 'A project type already exists with this new data') - @ns.expect(project_type_input) + @ns.expect(remove_required_constraint(project_type_input)) @ns.marshal_with(project_type) def put(self, id): """Update a project type""" diff --git a/time_tracker_api/projects/projects_namespace.py b/time_tracker_api/projects/projects_namespace.py index 634af17e..9c39e53e 100644 --- a/time_tracker_api/projects/projects_namespace.py +++ b/time_tracker_api/projects/projects_namespace.py @@ -2,7 +2,7 @@ from flask_restplus import Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import common_fields, create_attributes_filter, UUID, api +from time_tracker_api.api import common_fields, create_attributes_filter, UUID, api, remove_required_constraint from time_tracker_api.projects.projects_model import create_dao faker = Faker() @@ -94,7 +94,7 @@ def get(self, id): @ns.response(HTTPStatus.BAD_REQUEST, 'Invalid format or structure ' 'of the attributes of the project') @ns.response(HTTPStatus.CONFLICT, 'A project already exists with this new data') - @ns.expect(project_input) + @ns.expect(remove_required_constraint(project_input)) @ns.marshal_with(project) def put(self, id): """Update a project""" diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index 78690df2..9df422a4 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, create_attributes_filter, api, UUID, NullableString +from time_tracker_api.api import common_fields, create_attributes_filter, api, UUID, NullableString, \ + remove_required_constraint from time_tracker_api.time_entries.time_entries_model import create_dao faker = Faker() @@ -146,7 +147,7 @@ def get(self, id): 'of the attributes of the time entry') @ns.response(HTTPStatus.CONFLICT, 'A time entry already exists with this new data or there' ' is a bad reference for the project or activity') - @ns.expect(time_entry_input) + @ns.expect(remove_required_constraint(time_entry_input)) @ns.marshal_with(time_entry) def put(self, id): """Update a time entry""" From 24f2fcf1ca04d840845fa935d16bc46a3a99975f Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 27 Apr 2020 22:03:43 +0000 Subject: [PATCH 023/361] 0.7.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 a71c5c7f..f0788a87 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.7.0' +__version__ = '0.7.1' From 627dd6781049dab0fddce92007ef18bc8ae8d1a5 Mon Sep 17 00:00:00 2001 From: Rene Enriquez Date: Mon, 27 Apr 2020 17:20:56 -0500 Subject: [PATCH 024/361] fix: #114 description should be optional --- time_tracker_api/time_entries/time_entries_namespace.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index 9df422a4..4723f111 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -35,7 +35,7 @@ description='The id of the selected activity', example=faker.uuid4(), ), - 'description': fields.String( + 'description': NullableString( title='Comments', required=False, description='Comments about the time entry', From d31cfc9426e9f4335dc2c8f61c7bf3ecb36621dc Mon Sep 17 00:00:00 2001 From: Rene Enriquez Date: Mon, 27 Apr 2020 17:37:51 -0500 Subject: [PATCH 025/361] fix: #114 marking description fields as optional on different namespaces --- time_tracker_api/activities/activities_namespace.py | 4 ++-- time_tracker_api/customers/customers_namespace.py | 4 ++-- time_tracker_api/project_types/project_types_namespace.py | 5 +++-- time_tracker_api/projects/projects_namespace.py | 5 +++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/time_tracker_api/activities/activities_namespace.py b/time_tracker_api/activities/activities_namespace.py index 1dffc3dc..6b3dddfc 100644 --- a/time_tracker_api/activities/activities_namespace.py +++ b/time_tracker_api/activities/activities_namespace.py @@ -3,7 +3,7 @@ from flask_restplus._http import HTTPStatus from time_tracker_api.activities.activities_model import create_dao -from time_tracker_api.api import common_fields, api, remove_required_constraint +from time_tracker_api.api import common_fields, api, remove_required_constraint, NullableString faker = Faker() @@ -18,7 +18,7 @@ description='Canonical name of the activity', example=faker.word(['Development', 'Training']), ), - 'description': fields.String( + 'description': NullableString( title='Description', required=False, description='Comments about the activity', diff --git a/time_tracker_api/customers/customers_namespace.py b/time_tracker_api/customers/customers_namespace.py index d38f4566..81550438 100644 --- a/time_tracker_api/customers/customers_namespace.py +++ b/time_tracker_api/customers/customers_namespace.py @@ -2,7 +2,7 @@ from flask_restplus import Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import common_fields, api, remove_required_constraint +from time_tracker_api.api import common_fields, api, remove_required_constraint, NullableString from time_tracker_api.customers.customers_model import create_dao faker = Faker() @@ -18,7 +18,7 @@ description='Name of the customer', example=faker.company(), ), - 'description': fields.String( + 'description': NullableString( title='Description', required=False, max_length=250, diff --git a/time_tracker_api/project_types/project_types_namespace.py b/time_tracker_api/project_types/project_types_namespace.py index 7d717b7f..3e197365 100644 --- a/time_tracker_api/project_types/project_types_namespace.py +++ b/time_tracker_api/project_types/project_types_namespace.py @@ -2,7 +2,8 @@ from flask_restplus import Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import common_fields, create_attributes_filter, api, UUID, remove_required_constraint +from time_tracker_api.api import common_fields, create_attributes_filter, api, UUID, remove_required_constraint, \ + NullableString from time_tracker_api.project_types.project_types_model import create_dao faker = Faker() @@ -18,7 +19,7 @@ description='Name of the project type', example=faker.random_element(["Customer", "Training", "Internal"]), ), - 'description': fields.String( + 'description': NullableString( title='Description', required=False, max_length=250, diff --git a/time_tracker_api/projects/projects_namespace.py b/time_tracker_api/projects/projects_namespace.py index 9c39e53e..c06f5e57 100644 --- a/time_tracker_api/projects/projects_namespace.py +++ b/time_tracker_api/projects/projects_namespace.py @@ -2,7 +2,8 @@ from flask_restplus import Resource, fields from flask_restplus._http import HTTPStatus -from time_tracker_api.api import common_fields, create_attributes_filter, UUID, api, remove_required_constraint +from time_tracker_api.api import common_fields, create_attributes_filter, UUID, api, remove_required_constraint, \ + NullableString from time_tracker_api.projects.projects_model import create_dao faker = Faker() @@ -18,7 +19,7 @@ description='Name of the project', example=faker.company(), ), - 'description': fields.String( + 'description': NullableString( title='Description', required=False, max_length=250, From 495190d8f9461959adcd6e6aefdfe4c51d6093ae Mon Sep 17 00:00:00 2001 From: semantic-release Date: Mon, 27 Apr 2020 22:47:05 +0000 Subject: [PATCH 026/361] 0.7.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 f0788a87..fb9b668f 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.7.1' +__version__ = '0.7.2' From b494a756c5c921db7dd3854040d3661711aa52e6 Mon Sep 17 00:00:00 2001 From: roberto Date: Tue, 28 Apr 2020 01:31:36 -0500 Subject: [PATCH 027/361] feat: add filter per month and year --- commons/data_access_layer/cosmos_db.py | 21 ++++- .../time_entries/time_entries_model.py | 81 ++++++++++++++++++- .../time_entries/time_entries_namespace.py | 10 +++ 3 files changed, 106 insertions(+), 6 deletions(-) diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index eb104a2c..85795027 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -2,7 +2,7 @@ import logging import uuid from datetime import datetime -from typing import Callable +from typing import Callable, List import azure.cosmos.cosmos_client as cosmos_client import azure.cosmos.exceptions as exceptions @@ -116,6 +116,17 @@ def create_sql_where_conditions(conditions: dict, container_name='c') -> str: else: return "" + @staticmethod + def create_sql_custom_conditions(custom_conditions: List[str]) -> str: + if len(custom_conditions) > 0: + return """ + AND {custom_conditions_clause} + """.format( + custom_conditions_clause=" AND ".join(custom_conditions) + ) + else: + return '' + @staticmethod def generate_condition_values(conditions: dict) -> dict: result = [] @@ -154,8 +165,8 @@ def find(self, id: str, partition_key_value, peeker: 'function' = None, function_mapper = self.get_mapper_or_dict(mapper) return function_mapper(self.check_visibility(found_item, visible_only)) - def find_all(self, partition_key_value: str, conditions: dict = {}, max_count=None, offset=0, - visible_only=True, mapper: Callable = None): + def find_all(self, partition_key_value: str, conditions: dict = {}, custom_conditions: List[str] = [], + custom_params: dict = {}, max_count=None, offset=0, visible_only=True, mapper: Callable = None): # TODO Use the tenant_id param and change container alias max_count = self.get_page_size_or(max_count) params = [ @@ -164,13 +175,15 @@ def find_all(self, partition_key_value: str, conditions: dict = {}, max_count=No {"name": "@max_count", "value": max_count}, ] params.extend(self.generate_condition_values(conditions)) + params.extend(custom_params) result = self.container.query_items(query=""" SELECT * FROM c WHERE c.{partition_key_attribute}=@partition_key_value - {conditions_clause} {visibility_condition} {order_clause} + {conditions_clause} {visibility_condition} {custom_conditions_clause} {order_clause} OFFSET @offset LIMIT @max_count """.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_conditions_clause=self.create_sql_custom_conditions(custom_conditions), order_clause=self.create_sql_order_clause()), parameters=params, partition_key=partition_key_value, diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 43f8aacf..73fb9e12 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -1,6 +1,7 @@ import abc from dataclasses import dataclass, field -from typing import List, Callable +from typing import List, Callable, Tuple +from datetime import datetime from azure.cosmos import PartitionKey from flask_restplus._http import HTTPStatus @@ -74,6 +75,35 @@ def create_sql_ignore_id_condition(id: str): else: return "AND c.id!=@ignore_id" + @staticmethod + def create_sql_date_range_filter(custom_args: dict) -> str: + if 'start_date' and 'end_date' in custom_args: + return """ + ((c.start_date BETWEEN @start_date AND @end_date) OR + (c.end_date BETWEEN @start_date AND @end_date)) + """ + else: + return '' + + def find_all(self, partition_key_value: str, conditions: dict, custom_args: dict): + custom_conditions = [] + custom_conditions.append( + self.create_sql_date_range_filter(custom_args) + ) + + custom_params = [ + {"name": "@start_date", "value": custom_args.get('start_date')}, + {"name": "@end_date", "value": custom_args.get('end_date')}, + ] + + return CosmosDBRepository.find_all( + self, + partition_key_value=partition_key_value, + conditions=conditions, + custom_conditions=custom_conditions, + custom_params=custom_params + ) + def on_create(self, new_item_data: dict): CosmosDBRepository.on_create(self, new_item_data) @@ -157,6 +187,32 @@ def validate_data(self, data): description="There is another time entry in that date range") +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_date_range_of_month( + year: int, + month: int +) -> Tuple[datetime, datetime]: + first_day_of_month = 1 + start_date = datetime(year=year, month=month, day=first_day_of_month) + + # TODO : fix bound as this would exclude the last day + 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) + return start_date, end_date + + class TimeEntriesCosmosDBDao(TimeEntriesDao, CosmosDBDao): def __init__(self, repository): CosmosDBDao.__init__(self, repository) @@ -170,8 +226,29 @@ def check_whether_current_user_owns_item(cls, data: dict): def get_all(self, conditions: dict = {}) -> list: conditions.update({"owner_id": self.current_user_id()}) + + if 'month' and 'year' in conditions: + month = int(conditions.get("month")) + year = int(conditions.get("year")) + conditions.pop('month') + conditions.pop('year') + elif 'month' in conditions: + month = int(conditions.get("month")) + year = get_current_year() + conditions.pop('month') + else: + month = get_current_month() + year = get_current_year() + + start_date, end_date = get_date_range_of_month(year, month) + + custom_args = { + 'start_date': start_date.isoformat(), + 'end_date': end_date.isoformat() + } return self.repository.find_all(partition_key_value=self.partition_key_value, - conditions=conditions) + conditions=conditions, + custom_args=custom_args) 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 4723f111..eab9b585 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -108,10 +108,20 @@ "uri", ]) +# custom attributes filter +attributes_filter.add_argument('month', required=False, + store_missing=False, + help="(Filter) month to filter", + location='args') +attributes_filter.add_argument('year', required=False, + store_missing=False, + help="(Filter) year to filter", + location='args') @ns.route('') class TimeEntries(Resource): @ns.doc('list_time_entries') + @ns.expect(attributes_filter) @ns.marshal_list_with(time_entry) def get(self): """List all time entries""" From 8d2bc90f5fe722b64da37cda16944671574e2d55 Mon Sep 17 00:00:00 2001 From: EliuX Date: Tue, 28 Apr 2020 20:11:49 -0500 Subject: [PATCH 028/361] feat: Close #101 #53 Add time_tracker_events and bind it to CosmosDB to track events --- .DS_Store | Bin 0 -> 6148 bytes commons/data_access_layer/cosmos_db.py | 76 ++++++++++++------ commons/data_access_layer/database.py | 29 +++++++ commons/data_access_layer/events_model.py | 6 ++ migrations/02-add-events.py | 19 +++++ requirements/time_tracker_events/dev.txt | 5 ++ requirements/time_tracker_events/prod.txt | 5 ++ time_tracker_events/.funcignore | 1 + time_tracker_events/.gitignore | 43 ++++++++++ .../handle_events_trigger/__init__.py | 34 ++++++++ .../handle_events_trigger/function.json | 26 ++++++ time_tracker_events/host.json | 7 ++ time_tracker_events/local.settings.json | 8 ++ time_tracker_events/run.sh | 4 + 14 files changed, 238 insertions(+), 25 deletions(-) create mode 100644 .DS_Store create mode 100644 commons/data_access_layer/events_model.py create mode 100644 migrations/02-add-events.py create mode 100644 requirements/time_tracker_events/dev.txt create mode 100644 requirements/time_tracker_events/prod.txt create mode 100644 time_tracker_events/.funcignore create mode 100644 time_tracker_events/.gitignore create mode 100644 time_tracker_events/handle_events_trigger/__init__.py create mode 100644 time_tracker_events/handle_events_trigger/function.json create mode 100644 time_tracker_events/host.json create mode 100644 time_tracker_events/local.settings.json create mode 100644 time_tracker_events/run.sh diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..8875004838d0167978fba48f8292ee9ce2fb4fa0 GIT binary patch literal 6148 zcmeHKIZgvX5Ud6VmPjm-5Y89)!OAi(-~k3~BOwk;HaOnM@8W4xAIWMVu|#53OFcEy z(>0^1V0#;Y&A-;Szzo2Y?uergWAlCYkzG{8h;*K@#3y!m#0syY?B4^_Gou#{PM|Up;NNZ%&a(0VyB_q<|EV0w*d^g>-s#B40EqAO+4>0slTUx?@+^ zC&s6PL$mFQ# z{1)l3E>WWtkOD&mE^|5a`hQ3NqyHb0w2}f+;H(s|$>M%7=aZ_ojvnW=w$UHxp7Tw2 q<2)!Fq8t;W9CP92_$HDvulbz&U16UXbjE{D)X#wHB9j9Dt-uch?io-3 literal 0 HcmV?d00001 diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index eb104a2c..33c7df92 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -10,8 +10,7 @@ from flask import Flask from werkzeug.exceptions import HTTPException -from commons.data_access_layer.database import CRUDDao -from time_tracker_api.security import current_user_tenant_id +from commons.data_access_layer.database import CRUDDao, EventContext class CosmosDBFacade: @@ -140,13 +139,25 @@ def replace_empty_value_per_none(item_data: dict) -> dict: if isinstance(v, str) and len(v) == 0: item_data[k] = None - def create(self, data: dict, mapper: Callable = None): - self.on_create(data) + def attach_context(data: dict, event_context: EventContext): + data["_last_event_ctx"] = { + "user_id": event_context.user_id, + "tenant_id": event_context.tenant_id, + "action": event_context.action, + "description": event_context.description, + "container_id": event_context.container_id, + "session_id": event_context.session_id, + } + + def create(self, data: dict, event_context: EventContext, mapper: Callable = None): + self.on_create(data, event_context) function_mapper = self.get_mapper_or_dict(mapper) + self.attach_context(data, event_context) return function_mapper(self.container.create_item(body=data)) - def find(self, id: str, partition_key_value, peeker: 'function' = None, + def find(self, id: str, event_context: EventContext, peeker: 'function' = None, visible_only=True, mapper: Callable = None): + partition_key_value = self.find_partition_key_value(event_context) found_item = self.container.read_item(id, partition_key_value) if peeker: peeker(found_item) @@ -154,9 +165,9 @@ def find(self, id: str, partition_key_value, peeker: 'function' = None, function_mapper = self.get_mapper_or_dict(mapper) return function_mapper(self.check_visibility(found_item, visible_only)) - def find_all(self, partition_key_value: str, conditions: dict = {}, max_count=None, offset=0, - visible_only=True, mapper: Callable = None): - # TODO Use the tenant_id param and change container alias + def find_all(self, event_context: EventContext, conditions: dict = {}, max_count=None, + offset=0, visible_only=True, mapper: Callable = None): + partition_key_value = self.find_partition_key_value(event_context) max_count = self.get_page_size_or(max_count) params = [ {"name": "@partition_key_value", "value": partition_key_value}, @@ -179,20 +190,22 @@ def find_all(self, partition_key_value: str, conditions: dict = {}, max_count=No function_mapper = self.get_mapper_or_dict(mapper) return list(map(function_mapper, result)) - def partial_update(self, id: str, changes: dict, partition_key_value: str, + def partial_update(self, id: str, changes: dict, event_context: EventContext, peeker: 'function' = None, visible_only=True, mapper: Callable = None): - item_data = self.find(id, partition_key_value, peeker=peeker, - visible_only=visible_only, mapper=dict) + item_data = self.find(id, event_context, peeker=peeker, visible_only=visible_only, mapper=dict) item_data.update(changes) - return self.update(id, item_data, mapper=mapper) + return self.update(id, item_data, event_context=event_context, mapper=mapper) - def update(self, id: str, item_data: dict, mapper: Callable = None): + def update(self, id: str, item_data: dict, event_context: EventContext, + mapper: Callable = None): self.on_update(item_data) function_mapper = self.get_mapper_or_dict(mapper) + self.attach_context(item_data, event_context) return function_mapper(self.container.replace_item(id, body=item_data)) - def delete(self, id: str, partition_key_value: str, + def delete(self, id: str, event_context: EventContext, peeker: 'function' = None, mapper: Callable = None): + partition_key_value = self.find_partition_key_value(event_context) return self.partial_update(id, { 'deleted': str(uuid.uuid4()) }, partition_key_value, peeker=peeker, visible_only=True, mapper=mapper) @@ -200,6 +213,9 @@ def delete(self, id: str, partition_key_value: str, def delete_permanently(self, id: str, partition_key_value: str) -> None: self.container.delete_item(id, partition_key_value) + def find_partition_key_value(self, event_context: EventContext): + return getattr(event_context, self.partition_key_attribute) + def get_mapper_or_dict(self, alternative_mapper: Callable) -> Callable: return alternative_mapper or self.mapper or dict @@ -208,10 +224,12 @@ def get_page_size_or(self, custom_page_size: int) -> int: # or any other repository for the settings return custom_page_size or 100 - def on_create(self, new_item_data: dict): + def on_create(self, new_item_data: dict, event_context: EventContext): if new_item_data.get('id') is None: new_item_data['id'] = generate_uuid4() + new_item_data[self.partition_key_attribute] = self.find_partition_key_value(event_context) + self.replace_empty_value_per_none(new_item_data) def on_update(self, update_item_data: dict): @@ -228,27 +246,35 @@ def __init__(self, repository: CosmosDBRepository): self.repository = repository def get_all(self, conditions: dict = {}) -> list: - return self.repository.find_all(partition_key_value=self.partition_key_value, + event_ctx = self.create_event_context("read-many") + return self.repository.find_all(event_context=event_ctx, conditions=conditions) def get(self, id): - return self.repository.find(id, partition_key_value=self.partition_key_value) + event_ctx = self.create_event_context("read") + return self.repository.find(id, event_context=event_ctx) def create(self, data: dict): - data[self.repository.partition_key_attribute] = self.partition_key_value - return self.repository.create(data) + event_ctx = self.create_event_context("create") + return self.repository.create(data, event_context=event_ctx) def update(self, id, data: dict): - return self.repository.partial_update(id, - changes=data, - partition_key_value=self.partition_key_value) + event_ctx = self.create_event_context("update") + return self.repository.partial_update(id, changes=data, event_context=event_ctx) def delete(self, id): - self.repository.delete(id, partition_key_value=self.partition_key_value) + event_ctx = self.create_event_context("update") + self.repository.delete(id, event_context=event_ctx) @property - def partition_key_value(self): - return current_user_tenant_id() + def find_partition_key_value(self, event_context: EventContext): + return event_context.tenant_id + + # Replace by decorator and put it in the repository + def create_event_context(self, action: str = None, description: str = None): + return EventContext(self.repository.container.id, + action, + description=description) class CustomError(HTTPException): diff --git a/commons/data_access_layer/database.py b/commons/data_access_layer/database.py index b30afe2c..386aeeb7 100644 --- a/commons/data_access_layer/database.py +++ b/commons/data_access_layer/database.py @@ -9,6 +9,8 @@ from flask import Flask +from time_tracker_api.security import current_user_id, current_user_tenant_id + COMMENTS_MAX_LENGTH = 500 ID_MAX_LENGTH = 64 @@ -35,6 +37,33 @@ def delete(self, id): raise NotImplementedError # pragma: no cover +class EventContext: + def __init__(self, container_id: str, action: str, description: str = None, + user_id: str = None, tenant_id: str = None, session_id: str = None): + self.container_id = container_id + self.action = action + self.description = description + self._user_id = user_id + self._tenant_id = tenant_id + self._session_id = session_id + + @property + def user_id(self) -> str: + if self._user_id is None: + self._user_id = current_user_id() + return self._user_id + + @property + def tenant_id(self) -> str: + if self._tenant_id is None: + self._tenant_id = current_user_tenant_id() + return self._tenant_id + + @property + def session_id(self) -> str: + return self._session_id + + def init_app(app: Flask) -> None: init_cosmos_db(app) diff --git a/commons/data_access_layer/events_model.py b/commons/data_access_layer/events_model.py new file mode 100644 index 00000000..67b84d94 --- /dev/null +++ b/commons/data_access_layer/events_model.py @@ -0,0 +1,6 @@ +from azure.cosmos import PartitionKey + +container_definition = { + 'id': 'event', + 'partition_key': PartitionKey(path='/tenant_id') +} \ No newline at end of file diff --git a/migrations/02-add-events.py b/migrations/02-add-events.py new file mode 100644 index 00000000..f9f4bab1 --- /dev/null +++ b/migrations/02-add-events.py @@ -0,0 +1,19 @@ +def up(): + from commons.data_access_layer.cosmos_db import cosmos_helper + import azure.cosmos.exceptions as exceptions + from commons.data_access_layer.events_model import container_definition as event_definition + from . import app + + app.logger.info("Creating container events...") + + try: + app.logger.info('- Event') + cosmos_helper.create_container(event_definition) + except exceptions.CosmosResourceExistsError as e: + app.logger.warning("Unexpected error while creating container for events: %s" % e.message) + + app.logger.info("Done!") + + +def down(): + print("Not implemented!") diff --git a/requirements/time_tracker_events/dev.txt b/requirements/time_tracker_events/dev.txt new file mode 100644 index 00000000..10bb63e0 --- /dev/null +++ b/requirements/time_tracker_events/dev.txt @@ -0,0 +1,5 @@ +# requirements/time_tracker_events/dev.txt + + +# Include the prod resources +-r prod.txt diff --git a/requirements/time_tracker_events/prod.txt b/requirements/time_tracker_events/prod.txt new file mode 100644 index 00000000..56cb7a97 --- /dev/null +++ b/requirements/time_tracker_events/prod.txt @@ -0,0 +1,5 @@ +# requirements/time_tracker_events/prod.txt + +# Azure Functions library +azure-functions + diff --git a/time_tracker_events/.funcignore b/time_tracker_events/.funcignore new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/time_tracker_events/.funcignore @@ -0,0 +1 @@ + diff --git a/time_tracker_events/.gitignore b/time_tracker_events/.gitignore new file mode 100644 index 00000000..fbbe2efa --- /dev/null +++ b/time_tracker_events/.gitignore @@ -0,0 +1,43 @@ +bin +obj +csx +.vs +edge +Publish + +*.user +*.suo +*.cscfg +*.Cache +project.lock.json + +/packages +/TestResults + +/tools/NuGet.exe +/App_Data +/secrets +/data +.secrets +appsettings.json +local.settings.json + +node_modules +dist + +# Local python packages +.python_packages/ + +# Python Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class \ No newline at end of file diff --git a/time_tracker_events/handle_events_trigger/__init__.py b/time_tracker_events/handle_events_trigger/__init__.py new file mode 100644 index 00000000..8ef76037 --- /dev/null +++ b/time_tracker_events/handle_events_trigger/__init__.py @@ -0,0 +1,34 @@ +import logging +import uuid +from datetime import datetime + +import azure.functions as func + + +def main(documents: func.DocumentList, events: func.Out[func.Document]): + if documents: + new_events = func.DocumentList() + + for doc in documents: + logging.info(doc.to_json()) + + event_context = doc.get("_last_event_ctx") + if event_context is not None: + new_events.append(func.Document.from_dict({ + "id": str(uuid.uuid4()), + "date": datetime.utcnow().isoformat(), + "user_id": event_context.get("user_id"), + "action": event_context.get("action"), + "description": event_context.get("description"), + "item_id": doc.get("id"), + "container_id": event_context.get("container_id"), + "session_id": event_context.get("session_id"), + "tenant_id": event_context.get("tenant_id"), + })) + else: + logging.warning("- Not saved!") + + if len(new_events): + events.set(new_events) + else: + logging.warning("No valid events were found!") diff --git a/time_tracker_events/handle_events_trigger/function.json b/time_tracker_events/handle_events_trigger/function.json new file mode 100644 index 00000000..9da66162 --- /dev/null +++ b/time_tracker_events/handle_events_trigger/function.json @@ -0,0 +1,26 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "type": "cosmosDBTrigger", + "name": "documents", + "direction": "in", + "leaseCollectionName": "leases", + "connectionStringSetting": "COSMOS_DATABASE_URI", + "databaseName": "time-tracker-db", + "collectionName": "activity", + "createLeaseCollectionIfNotExists": "true" + }, + { + "direction": "out", + "type": "cosmosDB", + "name": "events", + "databaseName": "time-tracker-db", + "collectionName": "event", + "leaseCollectionName": "leases", + "createLeaseCollectionIfNotExists": true, + "connectionStringSetting": "COSMOS_DATABASE_URI", + "createIfNotExists": true + } + ] +} diff --git a/time_tracker_events/host.json b/time_tracker_events/host.json new file mode 100644 index 00000000..8f3cf9db --- /dev/null +++ b/time_tracker_events/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[1.*, 2.0.0)" + } +} \ No newline at end of file diff --git a/time_tracker_events/local.settings.json b/time_tracker_events/local.settings.json new file mode 100644 index 00000000..89cb8861 --- /dev/null +++ b/time_tracker_events/local.settings.json @@ -0,0 +1,8 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "AzureWebJobsStorage": "{AzureWebJobsStorage}", + "COSMOS_DATABASE_URI": "AccountEndpoint=https://time-tracker-db.documents.azure.com:443/;AccountKey=6lyRD8ma0VQjcMbeSMFOTDGwNDptcEGfngp3c9DStNAMCNh2MRbkNWmRinoNvuB6aH51EMEkgeP5WfW3VZiV9g==;" + } +} diff --git a/time_tracker_events/run.sh b/time_tracker_events/run.sh new file mode 100644 index 00000000..85e872c9 --- /dev/null +++ b/time_tracker_events/run.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash + +echo Running Azure Functions locally.... +func host start From 253ba3d9a17ae81a7d56bc8d6950a5a7e4aac88e Mon Sep 17 00:00:00 2001 From: EliuX Date: Wed, 29 Apr 2020 18:41:37 -0500 Subject: [PATCH 029/361] fix: Fix tests #101 --- commons/data_access_layer/cosmos_db.py | 25 +- .../data_access_layer/cosmos_db_test.py | 332 +++++++++--------- tests/conftest.py | 59 +++- .../activities/activities_namespace_test.py | 37 +- .../customers/customers_namespace_test.py | 37 +- .../project_types_namespace_test.py | 39 +- .../projects/projects_namespace_test.py | 52 +-- .../time_entries/time_entries_model_test.py | 46 +-- .../time_entries_namespace_test.py | 117 +++--- .../time_entries/time_entries_model.py | 61 ++-- 10 files changed, 372 insertions(+), 433 deletions(-) diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index 33c7df92..744a762a 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -139,6 +139,7 @@ def replace_empty_value_per_none(item_data: dict) -> dict: if isinstance(v, str) and len(v) == 0: item_data[k] = None + @staticmethod def attach_context(data: dict, event_context: EventContext): data["_last_event_ctx"] = { "user_id": event_context.user_id, @@ -194,21 +195,20 @@ def partial_update(self, id: str, changes: dict, event_context: EventContext, peeker: 'function' = None, visible_only=True, mapper: Callable = None): item_data = self.find(id, event_context, peeker=peeker, visible_only=visible_only, mapper=dict) item_data.update(changes) - return self.update(id, item_data, event_context=event_context, mapper=mapper) + return self.update(id, item_data, event_context, mapper=mapper) def update(self, id: str, item_data: dict, event_context: EventContext, mapper: Callable = None): - self.on_update(item_data) + self.on_update(item_data, event_context) function_mapper = self.get_mapper_or_dict(mapper) self.attach_context(item_data, event_context) return function_mapper(self.container.replace_item(id, body=item_data)) def delete(self, id: str, event_context: EventContext, peeker: 'function' = None, mapper: Callable = None): - partition_key_value = self.find_partition_key_value(event_context) return self.partial_update(id, { - 'deleted': str(uuid.uuid4()) - }, partition_key_value, peeker=peeker, visible_only=True, mapper=mapper) + 'deleted': generate_uuid4() + }, event_context, peeker=peeker, visible_only=True, mapper=mapper) def delete_permanently(self, id: str, partition_key_value: str) -> None: self.container.delete_item(id, partition_key_value) @@ -232,7 +232,7 @@ def on_create(self, new_item_data: dict, event_context: EventContext): self.replace_empty_value_per_none(new_item_data) - def on_update(self, update_item_data: dict): + def on_update(self, update_item_data: dict, event_context: EventContext): pass def create_sql_order_clause(self): @@ -247,24 +247,23 @@ def __init__(self, repository: CosmosDBRepository): def get_all(self, conditions: dict = {}) -> list: event_ctx = self.create_event_context("read-many") - return self.repository.find_all(event_context=event_ctx, - conditions=conditions) + return self.repository.find_all(event_ctx, conditions=conditions) def get(self, id): event_ctx = self.create_event_context("read") - return self.repository.find(id, event_context=event_ctx) + return self.repository.find(id, event_ctx) def create(self, data: dict): event_ctx = self.create_event_context("create") - return self.repository.create(data, event_context=event_ctx) + return self.repository.create(data, event_ctx) def update(self, id, data: dict): event_ctx = self.create_event_context("update") - return self.repository.partial_update(id, changes=data, event_context=event_ctx) + return self.repository.partial_update(id, data, event_ctx) def delete(self, id): - event_ctx = self.create_event_context("update") - self.repository.delete(id, event_context=event_ctx) + event_ctx = self.create_event_context("delete") + self.repository.delete(id, event_ctx) @property def find_partition_key_value(self, event_context: EventContext): diff --git a/tests/commons/data_access_layer/cosmos_db_test.py b/tests/commons/data_access_layer/cosmos_db_test.py index 0059370d..f6c0d196 100644 --- a/tests/commons/data_access_layer/cosmos_db_test.py +++ b/tests/commons/data_access_layer/cosmos_db_test.py @@ -10,12 +10,11 @@ from commons.data_access_layer.cosmos_db import CosmosDBRepository, CosmosDBModel, CustomError, current_datetime, \ datetime_str +from commons.data_access_layer.database import EventContext fake = Faker() Faker.seed() -existing_item: dict = None - @dataclass() class Person(CosmosDBModel): @@ -36,24 +35,26 @@ def test_repository_exists(cosmos_db_repository): assert cosmos_db_repository is not None -def test_create_should_succeed(cosmos_db_repository: CosmosDBRepository, tenant_id: str): - global existing_item - existing_item = dict(id=fake.uuid4(), - name=fake.name(), - email=fake.safe_email(), - age=fake.pyint(min_value=10, max_value=80), - tenant_id=tenant_id) +def test_create_should_succeed(cosmos_db_repository: CosmosDBRepository, + tenant_id: str, + event_context: EventContext): + sample_item = dict(id=fake.uuid4(), + name=fake.name(), + email=fake.safe_email(), + age=fake.pyint(min_value=10, max_value=80), + tenant_id=tenant_id) - created_item = cosmos_db_repository.create(existing_item) + created_item = cosmos_db_repository.create(sample_item, event_context) assert created_item is not None - assert all(item in created_item.items() for item in existing_item.items()) + assert all(item in created_item.items() for item in sample_item.items()) -def test_create_should_fail_if_user_is_same(cosmos_db_repository: CosmosDBRepository): +def test_create_should_fail_if_user_is_same(cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + event_context: EventContext): try: - global existing_elemen - cosmos_db_repository.create(existing_item) + cosmos_db_repository.create(sample_item, event_context) fail('It should have failed') except Exception as e: @@ -61,29 +62,29 @@ def test_create_should_fail_if_user_is_same(cosmos_db_repository: CosmosDBReposi assert e.status_code == 409 -def test_create_with_diff_unique_data_but_same_tenant_should_succeed( - cosmos_db_repository: CosmosDBRepository): - global existing_item - new_data = existing_item.copy() +def test_create_with_diff_unique_data_but_same_tenant_should_succeed(cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + event_context: EventContext): + new_data = sample_item.copy() new_data.update({ 'id': fake.uuid4(), 'email': fake.safe_email(), }) - result = cosmos_db_repository.create(new_data) - assert result["id"] != existing_item["id"], 'It should be a new element' + result = cosmos_db_repository.create(new_data, event_context) + assert result["id"] != sample_item["id"], 'It should be a new element' -def test_create_with_same_id_should_fail( - cosmos_db_repository: CosmosDBRepository): +def test_create_with_same_id_should_fail(cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + event_context: EventContext): try: - global existing_item - new_data = existing_item.copy() + new_data = sample_item.copy() new_data.update({ 'email': fake.safe_email(), }) - cosmos_db_repository.create(new_data) + cosmos_db_repository.create(new_data, event_context) fail('It should have failed') except Exception as e: @@ -91,16 +92,16 @@ def test_create_with_same_id_should_fail( assert e.status_code == 409 -def test_create_with_diff_id_but_same_unique_field_should_fail( - cosmos_db_repository: CosmosDBRepository): +def test_create_with_diff_id_but_same_unique_field_should_fail(cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + event_context: EventContext): try: - global existing_item - new_data = existing_item.copy() + new_data = sample_item.copy() new_data.update({ 'id': fake.uuid4() }) - cosmos_db_repository.create(new_data) + cosmos_db_repository.create(new_data, event_context) fail('It should have failed') except Exception as e: @@ -108,42 +109,30 @@ def test_create_with_diff_id_but_same_unique_field_should_fail( assert e.status_code == 409 -def test_create_with_no_partition_key_attrib_should_pass( - cosmos_db_repository: CosmosDBRepository): - global existing_item - new_data = existing_item.copy() - - new_data.update({ - 'tenant_id': None, - }) - - result = cosmos_db_repository.create(new_data) - assert result["tenant_id"] is None, "A None value in a partition key is valid" - - -def test_create_with_same_id_but_diff_partition_key_attrib_should_succeed( - cosmos_db_repository: CosmosDBRepository, - another_tenant_id: str): - global existing_item - new_data = existing_item.copy() +def test_create_with_same_id_but_diff_partition_key_attrib_should_succeed(cosmos_db_repository: CosmosDBRepository, + another_event_context: EventContext, + sample_item: dict, + another_tenant_id: str): + new_data = sample_item.copy() new_data.update({ 'tenant_id': another_tenant_id, }) - result = cosmos_db_repository.create(new_data) - assert result["id"] == existing_item["id"], "Should have allowed same id" + result = cosmos_db_repository.create(new_data, another_event_context) + assert result["id"] == sample_item["id"], "Should have allowed same id" -def test_create_with_mapper_should_provide_calculated_fields( - cosmos_db_repository: CosmosDBRepository, tenant_id): +def test_create_with_mapper_should_provide_calculated_fields(cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + tenant_id: str): new_item = dict(id=fake.uuid4(), name=fake.name(), email=fake.safe_email(), age=fake.pyint(min_value=10, max_value=80), tenant_id=tenant_id) - created_item: Person = cosmos_db_repository.create(new_item, mapper=Person) + created_item: Person = cosmos_db_repository.create(new_item, event_context, mapper=Person) assert created_item is not None assert all(item in created_item.__dict__.items() for item in new_item.items()) @@ -151,16 +140,18 @@ def test_create_with_mapper_should_provide_calculated_fields( assert created_item.is_adult() is (new_item["age"] >= 18) -def test_find_by_valid_id_should_succeed(cosmos_db_repository: CosmosDBRepository): - found_item = cosmos_db_repository.find(existing_item["id"], - existing_item['tenant_id']) +def test_find_by_valid_id_should_succeed(cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + event_context: EventContext): + found_item = cosmos_db_repository.find(sample_item["id"], event_context) - assert all(item in found_item.items() for item in existing_item.items()) + assert all(item in found_item.items() for item in sample_item.items()) -def test_find_by_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository): +def test_find_by_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository, + event_context: EventContext): try: - cosmos_db_repository.find(fake.uuid4(), existing_item['tenant_id']) + cosmos_db_repository.find(fake.uuid4(), event_context) fail('It should have failed') except Exception as e: @@ -168,9 +159,10 @@ def test_find_by_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository assert e.status_code == 404 -def test_find_by_invalid_partition_key_value_should_fail(cosmos_db_repository: CosmosDBRepository): +def test_find_by_invalid_partition_key_value_should_fail(cosmos_db_repository: CosmosDBRepository, + event_context: EventContext): try: - cosmos_db_repository.find(existing_item["id"], fake.uuid4()) + cosmos_db_repository.find(fake.uuid4(), event_context) fail('It should have failed') except Exception as e: @@ -178,77 +170,80 @@ def test_find_by_invalid_partition_key_value_should_fail(cosmos_db_repository: C assert e.status_code == 404 -def test_find_by_valid_id_and_mapper_should_succeed(cosmos_db_repository: CosmosDBRepository): - found_item: Person = cosmos_db_repository.find(existing_item["id"], - existing_item['tenant_id'], +def test_find_by_valid_id_and_mapper_should_succeed(cosmos_db_repository: CosmosDBRepository, + sample_item: dict, + event_context: EventContext): + found_item: Person = cosmos_db_repository.find(sample_item["id"], + event_context, mapper=Person) + found_item_dict = found_item.__dict__ - assert all(item in found_item.__dict__.items() for item in existing_item.items()) + assert all(attrib in sample_item.items() for attrib in found_item_dict.items()) assert type(found_item) is Person, "The result should be wrapped with a class" - assert found_item.is_adult() is (existing_item["age"] >= 18) + assert found_item.is_adult() is (sample_item["age"] >= 18) @pytest.mark.parametrize( 'mapper,expected_type', [(None, dict), (dict, dict), (Person, Person)] ) def test_find_all_with_mapper(cosmos_db_repository: CosmosDBRepository, - tenant_id: str, + event_context: EventContext, mapper: Callable, expected_type: Callable): - result = cosmos_db_repository.find_all(tenant_id, mapper=mapper) + result = cosmos_db_repository.find_all(event_context, mapper=mapper) assert result is not None assert len(result) > 0 assert type(result[0]) is expected_type, "The result type is not the expected" -def test_find_all_should_return_items_from_specified_partition_key_value( - cosmos_db_repository: CosmosDBRepository, - tenant_id: str, - another_tenant_id: str): - result_tenant_id = cosmos_db_repository.find_all(tenant_id) +def test_find_all_should_return_items_from_specified_partition_key_value(cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + another_event_context: EventContext): + result_tenant_id = cosmos_db_repository.find_all(event_context) assert len(result_tenant_id) > 1 - assert all((i["tenant_id"] == tenant_id for i in result_tenant_id)) + assert all((i["tenant_id"] == event_context.tenant_id for i in result_tenant_id)) - result_another_tenant_id = cosmos_db_repository.find_all(another_tenant_id) + result_another_tenant_id = cosmos_db_repository.find_all(another_event_context) assert len(result_another_tenant_id) > 0 - assert all((i["tenant_id"] == another_tenant_id for i in result_another_tenant_id)) + assert all((i["tenant_id"] == another_event_context.tenant_id for i in result_another_tenant_id)) assert not any(item in result_another_tenant_id for item in result_tenant_id), \ "There should be no interceptions" -def test_find_all_should_succeed_with_partition_key_value_with_no_items( - cosmos_db_repository: CosmosDBRepository): - no_items = cosmos_db_repository.find_all(fake.uuid4()) +def test_find_all_should_succeed_with_partition_key_value_with_no_items(cosmos_db_repository: CosmosDBRepository): + invalid_event_context = EventContext("test", "any", tenant_id=fake.uuid4()) + + no_items = cosmos_db_repository.find_all(invalid_event_context) assert no_items is not None assert len(no_items) == 0, "No items are expected" def test_find_all_with_max_count(cosmos_db_repository: CosmosDBRepository, - tenant_id: str): - all_items = cosmos_db_repository.find_all(tenant_id) + event_context: EventContext): + all_items = cosmos_db_repository.find_all(event_context) assert len(all_items) > 2 - first_two_items = cosmos_db_repository.find_all(tenant_id, max_count=2) + first_two_items = cosmos_db_repository.find_all(event_context, max_count=2) assert len(first_two_items) == 2, "The result should be limited to 2" def test_find_all_with_offset(cosmos_db_repository: CosmosDBRepository, - tenant_id: str): - result_all_items = cosmos_db_repository.find_all(tenant_id) + event_context: EventContext): + result_all_items = cosmos_db_repository.find_all(event_context) assert len(result_all_items) >= 3 - result_after_the_first_item = cosmos_db_repository.find_all(tenant_id, offset=1) + result_after_the_first_item = cosmos_db_repository.find_all(event_context, offset=1) assert result_after_the_first_item == result_all_items[1:] - result_after_the_second_item = cosmos_db_repository.find_all(tenant_id, offset=2) + result_after_the_second_item = cosmos_db_repository.find_all(event_context, offset=2) assert result_after_the_second_item == result_all_items[2:] @@ -258,16 +253,16 @@ def test_find_all_with_offset(cosmos_db_repository: CosmosDBRepository, ) def test_partial_update_with_mapper(cosmos_db_repository: CosmosDBRepository, mapper: Callable, + sample_item: dict, + event_context: EventContext, expected_type: Callable): changes = { 'name': fake.name(), 'email': fake.safe_email(), } - updated_item = cosmos_db_repository.partial_update(existing_item['id'], - changes, - existing_item['tenant_id'], - mapper=mapper) + updated_item = cosmos_db_repository.partial_update(sample_item['id'], changes, + event_context, mapper=mapper) assert updated_item is not None assert type(updated_item) is expected_type @@ -275,7 +270,7 @@ def test_partial_update_with_mapper(cosmos_db_repository: CosmosDBRepository, def test_partial_update_with_new_partition_key_value_should_fail( cosmos_db_repository: CosmosDBRepository, - another_tenant_id: str, + another_event_context: EventContext, sample_item: dict): changes = { 'name': fake.name(), @@ -283,23 +278,22 @@ def test_partial_update_with_new_partition_key_value_should_fail( } try: - cosmos_db_repository.partial_update(sample_item['id'], changes, another_tenant_id) + cosmos_db_repository.partial_update(sample_item['id'], changes, another_event_context) fail('It should have failed') except Exception as e: assert type(e) is CosmosResourceNotFoundError assert e.status_code == 404 -def test_partial_update_with_invalid_id_should_fail( - cosmos_db_repository: CosmosDBRepository, - sample_item: dict): +def test_partial_update_with_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository, + event_context: EventContext): changes = { 'name': fake.name(), 'email': fake.safe_email(), } try: - cosmos_db_repository.partial_update(fake.uuid4(), changes, sample_item['tenant_id']) + cosmos_db_repository.partial_update(fake.uuid4(), changes, event_context) fail('It should have failed') except Exception as e: assert type(e) is CosmosResourceNotFoundError @@ -308,15 +302,16 @@ def test_partial_update_with_invalid_id_should_fail( def test_partial_update_should_only_update_fields_in_changes( cosmos_db_repository: CosmosDBRepository, - sample_item: dict): + sample_item: dict, + event_context: EventContext): changes = { 'name': fake.name(), 'email': fake.safe_email(), } - updated_item = cosmos_db_repository.partial_update( - sample_item['id'], - changes, sample_item['tenant_id']) + updated_item = cosmos_db_repository.partial_update(sample_item['id'], + changes, + event_context) assert updated_item is not None assert updated_item['name'] == changes["name"] != sample_item["name"] @@ -331,38 +326,42 @@ def test_partial_update_should_only_update_fields_in_changes( ) def test_update_with_mapper(cosmos_db_repository: CosmosDBRepository, mapper: Callable, + sample_item: dict, + event_context: EventContext, expected_type: Callable): - changed_item = existing_item.copy() + changed_item = sample_item.copy() changed_item.update({ 'name': fake.name(), 'email': fake.safe_email(), }) - updated_item = cosmos_db_repository.update(existing_item['id'], + updated_item = cosmos_db_repository.update(sample_item['id'], changed_item, + event_context, mapper=mapper) assert updated_item is not None assert type(updated_item) is expected_type -def test_update_with_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository): +def test_update_with_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository, + event_context: EventContext): changes = { 'name': fake.name(), 'email': fake.safe_email(), } try: - cosmos_db_repository.update(fake.uuid4(), changes) + cosmos_db_repository.update(fake.uuid4(), changes, event_context) fail('It should have failed') except Exception as e: assert type(e) is CosmosResourceNotFoundError assert e.status_code == 404 -def test_update_with_partial_changes_without_required_fields_it_should_fail( - cosmos_db_repository: CosmosDBRepository, - sample_item: dict): +def test_update_with_partial_changes_without_required_fields_it_should_fail(cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + sample_item: dict): changes = { 'id': sample_item['id'], 'email': fake.safe_email(), @@ -370,7 +369,7 @@ def test_update_with_partial_changes_without_required_fields_it_should_fail( } try: - cosmos_db_repository.update(sample_item['id'], changes) + cosmos_db_repository.update(sample_item['id'], changes, event_context) fail('It should have failed') except Exception as e: assert type(e) is CosmosResourceNotFoundError @@ -379,14 +378,15 @@ def test_update_with_partial_changes_without_required_fields_it_should_fail( def test_update_with_partial_changes_with_required_fields_should_delete_the_missing_ones( cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, sample_item: dict): changes = { 'id': fake.uuid4(), 'email': fake.safe_email(), - 'tenant_id': sample_item['tenant_id'], + 'tenant_id': event_context.tenant_id, } - updated_item = cosmos_db_repository.update(sample_item['id'], changes) + updated_item = cosmos_db_repository.update(sample_item['id'], changes, event_context) assert updated_item is not None assert updated_item['id'] == changes["id"] != sample_item["id"] @@ -396,7 +396,7 @@ def test_update_with_partial_changes_with_required_fields_should_delete_the_miss assert updated_item.get('age') is None try: - cosmos_db_repository.find(sample_item['id'], sample_item['tenant_id']) + cosmos_db_repository.find(sample_item['id'], event_context) fail('The previous version should not exist') except Exception as e: assert type(e) is CosmosResourceNotFoundError @@ -404,9 +404,10 @@ def test_update_with_partial_changes_with_required_fields_should_delete_the_miss def test_delete_with_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, tenant_id: str): try: - cosmos_db_repository.delete(fake.uuid4(), tenant_id) + cosmos_db_repository.delete(fake.uuid4(), event_context) except Exception as e: assert type(e) is CosmosResourceNotFoundError assert e.status_code == 404 @@ -417,19 +418,16 @@ def test_delete_with_invalid_id_should_fail(cosmos_db_repository: CosmosDBReposi ) def test_delete_with_mapper(cosmos_db_repository: CosmosDBRepository, sample_item: dict, + event_context: EventContext, mapper: Callable, expected_type: Callable): - deleted_item = cosmos_db_repository.delete(sample_item['id'], - sample_item['tenant_id'], - mapper=mapper) + deleted_item = cosmos_db_repository.delete(sample_item['id'], event_context, mapper=mapper) assert deleted_item is not None assert type(deleted_item) is expected_type try: - cosmos_db_repository.find(sample_item['id'], - sample_item['tenant_id'], - mapper=mapper) + cosmos_db_repository.find(sample_item['id'], event_context, mapper=mapper) fail('It should have not found the deleted item') except Exception as e: assert type(e) is CosmosResourceNotFoundError @@ -437,63 +435,62 @@ def test_delete_with_mapper(cosmos_db_repository: CosmosDBRepository, def test_find_can_find_deleted_item_only_if_visibile_only_is_true(cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, sample_item: dict): - deleted_item = cosmos_db_repository.delete(sample_item['id'], sample_item['tenant_id']) + deleted_item = cosmos_db_repository.delete(sample_item['id'], event_context) assert deleted_item is not None assert deleted_item['deleted'] is not None try: - cosmos_db_repository.find(sample_item['id'], sample_item['tenant_id']) + cosmos_db_repository.find(sample_item['id'], event_context) except Exception as e: assert type(e) is CosmosResourceNotFoundError assert e.status_code == 404 - found_deleted_item = cosmos_db_repository.find(sample_item['id'], - sample_item['tenant_id'], - visible_only=False) + found_deleted_item = cosmos_db_repository.find(sample_item['id'], event_context, visible_only=False) assert found_deleted_item is not None -def test_find_all_can_find_deleted_items_only_if_visibile_only_is_true( - cosmos_db_repository: CosmosDBRepository, - sample_item: dict): - deleted_item = cosmos_db_repository.delete(sample_item['id'], sample_item['tenant_id']) +def test_find_all_can_find_deleted_items_only_if_visibile_only_is_true(cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + sample_item: dict): + deleted_item = cosmos_db_repository.delete(sample_item['id'], event_context) assert deleted_item is not None assert deleted_item['deleted'] is not None - visible_items = cosmos_db_repository.find_all(partition_key_value=sample_item.get('tenant_id')) + visible_items = cosmos_db_repository.find_all(event_context) assert visible_items is not None assert any(item['id'] == sample_item['id'] for item in visible_items) == False, \ 'The deleted item should not be visible' - all_items = cosmos_db_repository.find_all(sample_item['tenant_id'], visible_only=False) + all_items = cosmos_db_repository.find_all(event_context, visible_only=False) assert all_items is not None assert any(item['id'] == sample_item['id'] for item in all_items), \ 'Deleted item should be visible' -def test_delete_should_not_find_element_that_is_already_deleted( - cosmos_db_repository: CosmosDBRepository, - sample_item: dict): - deleted_item = cosmos_db_repository.delete(sample_item['id'], sample_item['tenant_id']) +def test_delete_should_not_find_element_that_is_already_deleted(cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + sample_item: dict): + deleted_item = cosmos_db_repository.delete(sample_item['id'], event_context) assert deleted_item is not None try: - cosmos_db_repository.delete(deleted_item['id'], deleted_item['tenant_id']) + cosmos_db_repository.delete(deleted_item['id'], event_context) fail('It should have not found the deleted item') except Exception as e: assert type(e) is CosmosResourceNotFoundError assert e.status_code == 404 -def test_partial_update_should_not_find_element_that_is_already_deleted( - cosmos_db_repository: CosmosDBRepository, - sample_item: dict): - deleted_item = cosmos_db_repository.delete(sample_item['id'], sample_item['tenant_id']) +def test_partial_update_should_not_find_element_that_is_already_deleted(cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + sample_item: dict): + deleted_item = cosmos_db_repository.delete(sample_item['id'], event_context) assert deleted_item is not None @@ -504,7 +501,7 @@ def test_partial_update_should_not_find_element_that_is_already_deleted( } cosmos_db_repository.partial_update(deleted_item['id'], changes, - deleted_item['tenant_id']) + event_context) fail('It should have not found the deleted item') except Exception as e: @@ -512,29 +509,28 @@ def test_partial_update_should_not_find_element_that_is_already_deleted( assert e.status_code == 404 -def test_delete_permanently_with_invalid_id_should_fail( - cosmos_db_repository: CosmosDBRepository, - sample_item: dict): +def test_delete_permanently_with_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository, + tenant_id: str): try: - cosmos_db_repository.delete_permanently(fake.uuid4(), sample_item['tenant_id']) + cosmos_db_repository.delete_permanently(fake.uuid4(), tenant_id) fail('It should have not found the deleted item') except Exception as e: assert type(e) is CosmosResourceNotFoundError assert e.status_code == 404 -def test_delete_permanently_with_valid_id_should_succeed( - cosmos_db_repository: CosmosDBRepository, - sample_item: dict): - found_item = cosmos_db_repository.find(sample_item['id'], sample_item['tenant_id']) +def test_delete_permanently_with_valid_id_should_succeed(cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, + sample_item: dict): + found_item = cosmos_db_repository.find(sample_item['id'], event_context) assert found_item is not None assert found_item['id'] == sample_item['id'] - cosmos_db_repository.delete_permanently(sample_item['id'], sample_item['tenant_id']) + cosmos_db_repository.delete_permanently(sample_item['id'], event_context.tenant_id) try: - cosmos_db_repository.find(sample_item['id'], sample_item['tenant_id']) + cosmos_db_repository.find(sample_item['id'], event_context) fail('It should have not found the deleted item') except Exception as e: assert type(e) is CosmosResourceNotFoundError @@ -568,20 +564,19 @@ def test_repository_append_conditions_values(cosmos_db_repository: CosmosDBRepos def test_find_should_call_picker_if_it_was_specified(cosmos_db_repository: CosmosDBRepository, sample_item: dict, + event_context: EventContext, another_item: dict): def raise_bad_request_if_name_diff_the_one_from_sample_item(data: dict): if sample_item['name'] != data['name']: raise CustomError(HTTPStatus.BAD_REQUEST, "Anything") - found_item = cosmos_db_repository.find(sample_item['id'], - partition_key_value=sample_item['tenant_id']) + found_item = cosmos_db_repository.find(sample_item['id'], event_context) assert found_item is not None assert found_item['id'] == sample_item['id'] try: - cosmos_db_repository.find(another_item['id'], - partition_key_value=another_item['tenant_id'], + cosmos_db_repository.find(another_item['id'], event_context, peeker=raise_bad_request_if_name_diff_the_one_from_sample_item) fail('It should have not found any item because of condition') @@ -606,14 +601,14 @@ def test_datetime_str_comparison(): def test_replace_empty_value_per_none(tenant_id: str): initial_value = dict(id=fake.uuid4(), - name=fake.name(), - empty_str_attrib="", - array_attrib=[1, 2, 3], - empty_array_attrib=[], - description=" ", - age=fake.pyint(min_value=10, max_value=80), - size=0, - tenant_id=tenant_id) + name=fake.name(), + empty_str_attrib="", + array_attrib=[1, 2, 3], + empty_array_attrib=[], + description=" ", + age=fake.pyint(min_value=10, max_value=80), + size=0, + tenant_id=tenant_id) input = initial_value.copy() @@ -627,3 +622,18 @@ def test_replace_empty_value_per_none(tenant_id: str): assert input["age"] == initial_value["age"] assert input["size"] == initial_value["size"] assert input["tenant_id"] == initial_value["tenant_id"] + + +def test_attach_context_should_create_last_event_context_attrib(owner_id: str, + tenant_id: str, + event_context: EventContext): + data = dict() + + CosmosDBRepository.real_attach_context(data, event_context) + + assert data.get("_last_event_ctx") is not None + assert data["_last_event_ctx"]["container_id"] == "test" + assert data["_last_event_ctx"]["action"] == "any" + assert data["_last_event_ctx"]["description"] == None + assert data["_last_event_ctx"]["user_id"] == owner_id + assert data["_last_event_ctx"]["tenant_id"] == tenant_id diff --git a/tests/conftest.py b/tests/conftest.py index 4361b1f9..f481ce86 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,8 +6,8 @@ from flask import Flask from flask.testing import FlaskClient -from commons.data_access_layer.cosmos_db import CosmosDBRepository -from commons.data_access_layer.database import init_sql +from commons.data_access_layer.cosmos_db import CosmosDBRepository, CosmosDBDao +from commons.data_access_layer.database import init_sql, EventContext from time_tracker_api import create_app from time_tracker_api.security import get_or_generate_dev_secret_key from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository @@ -15,6 +15,9 @@ fake = Faker() Faker.seed() +CosmosDBRepository.real_attach_context = CosmosDBRepository.attach_context +CosmosDBRepository.attach_context = lambda self, data, event_context: data + @pytest.fixture(scope='session') def app() -> Flask: @@ -69,7 +72,7 @@ def sql_repository(app: Flask, sql_model_class): def cosmos_db_model(): from azure.cosmos import PartitionKey return { - 'id': 'tests', + 'id': 'test', 'partition_key': PartitionKey(path='/tenant_id'), 'unique_key_policy': { 'uniqueKeys': [ @@ -99,6 +102,12 @@ def cosmos_db_repository(app: Flask, cosmos_db_model) -> CosmosDBRepository: app.logger.info("Cosmos DB test models removed!") +@pytest.fixture(scope="module") +def cosmos_db_dao(app: Flask, cosmos_db_repository: CosmosDBRepository) -> CosmosDBDao: + with app.app_context(): + return CosmosDBDao(cosmos_db_repository) + + @pytest.fixture(scope="session") def tenant_id() -> str: return fake.uuid4() @@ -115,25 +124,29 @@ def owner_id() -> str: @pytest.fixture(scope="function") -def sample_item(cosmos_db_repository: CosmosDBRepository, tenant_id: str) -> dict: +def sample_item(cosmos_db_repository: CosmosDBRepository, + tenant_id: str, + event_context: EventContext) -> dict: sample_item_data = dict(id=fake.uuid4(), name=fake.name(), email=fake.safe_email(), age=fake.pyint(min_value=10, max_value=80), tenant_id=tenant_id) - return cosmos_db_repository.create(sample_item_data) + return cosmos_db_repository.create(sample_item_data, event_context) @pytest.fixture(scope="function") -def another_item(cosmos_db_repository: CosmosDBRepository, tenant_id: str) -> dict: +def another_item(cosmos_db_repository: CosmosDBRepository, + tenant_id: str, + event_context: EventContext) -> dict: sample_item_data = dict(id=fake.uuid4(), name=fake.name(), email=fake.safe_email(), age=fake.pyint(min_value=10, max_value=80), tenant_id=tenant_id) - return cosmos_db_repository.create(sample_item_data) + return cosmos_db_repository.create(sample_item_data, event_context) @pytest.fixture(scope="module") @@ -150,12 +163,13 @@ def time_entry_repository(app: Flask) -> TimeEntryCosmosDBRepository: @pytest.yield_fixture(scope="module") def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository, owner_id: str, - tenant_id: str): + tenant_id: str, + event_context: EventContext): created_time_entry = time_entry_repository.create({ "project_id": fake.uuid4(), "owner_id": owner_id, "tenant_id": tenant_id - }) + }, event_context) yield created_time_entry @@ -165,14 +179,29 @@ def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository, @pytest.fixture(scope="session") def valid_jwt(app: Flask, tenant_id: str, owner_id: str) -> str: - expiration_time = datetime.utcnow() + timedelta(seconds=3600) - return jwt.encode({ - "iss": "https://ioetec.b2clogin.com/%s/v2.0/" % tenant_id, - "oid": owner_id, - 'exp': expiration_time - }, key=get_or_generate_dev_secret_key()).decode("UTF-8") + with app.app_context(): + expiration_time = datetime.utcnow() + timedelta(seconds=3600) + return jwt.encode({ + "iss": "https://ioetec.b2clogin.com/%s/v2.0/" % tenant_id, + "oid": owner_id, + 'exp': expiration_time + }, key=get_or_generate_dev_secret_key()).decode("UTF-8") @pytest.fixture(scope="session") def valid_header(valid_jwt: str) -> dict: return {'Authorization': "Bearer %s" % valid_jwt} + + +@pytest.fixture(scope="session") +def event_context(owner_id: str, tenant_id: str) -> EventContext: + return EventContext("test", "any", + user_id=owner_id, + tenant_id=tenant_id) + + +@pytest.fixture(scope="session") +def another_event_context(another_tenant_id: str) -> EventContext: + return EventContext("test", "any", + user_id=fake.uuid4(), + tenant_id=another_tenant_id) diff --git a/tests/time_tracker_api/activities/activities_namespace_test.py b/tests/time_tracker_api/activities/activities_namespace_test.py index 04216e18..ba9b3da2 100644 --- a/tests/time_tracker_api/activities/activities_namespace_test.py +++ b/tests/time_tracker_api/activities/activities_namespace_test.py @@ -1,3 +1,5 @@ +from unittest.mock import ANY + from faker import Faker from flask import json from flask.testing import FlaskClient @@ -53,7 +55,6 @@ def test_create_activity_should_reject_bad_request(client: FlaskClient, def test_list_all_activities(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao repository_find_all_mock = mocker.patch.object(activity_dao.repository, @@ -67,13 +68,12 @@ 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, - conditions={}) + + repository_find_all_mock.assert_called_once_with(ANY, conditions={}) def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao @@ -89,12 +89,11 @@ def test_get_activity_should_succeed_with_valid_id(client: FlaskClient, assert HTTPStatus.OK == response.status_code fake_activity == json.loads(response.data) - repository_find_mock.assert_called_once_with(str(valid_id), partition_key_value=tenant_id) + repository_find_mock.assert_called_once_with(str(valid_id), ANY) def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao from werkzeug.exceptions import NotFound @@ -110,8 +109,7 @@ def test_get_activity_should_return_not_found_with_invalid_id(client: FlaskClien follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + repository_find_mock.assert_called_once_with(str(invalid_id), ANY) def test_get_activity_should_return_422_for_invalid_id_format(client: FlaskClient, @@ -128,11 +126,9 @@ def test_get_activity_should_return_422_for_invalid_id_format(client: FlaskClien response = client.get("/activities/%s" % invalid_id, follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_find_mock.assert_not_called() def test_update_activity_should_succeed_with_valid_data(client: FlaskClient, - tenant_id: str, mocker: MockFixture, valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao @@ -149,9 +145,7 @@ def test_update_activity_should_succeed_with_valid_data(client: FlaskClient, assert HTTPStatus.OK == response.status_code fake_activity == json.loads(response.data) - repository_update_mock.assert_called_once_with(str(valid_id), - changes=valid_activity_data, - partition_key_value=tenant_id) + repository_update_mock.assert_called_once_with(str(valid_id), valid_activity_data, ANY) def test_update_activity_should_reject_bad_request(client: FlaskClient, @@ -173,7 +167,6 @@ def test_update_activity_should_reject_bad_request(client: FlaskClient, def test_update_activity_should_return_not_found_with_invalid_id(client: FlaskClient, - tenant_id: str, mocker: MockFixture, valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao @@ -191,14 +184,11 @@ def test_update_activity_should_return_not_found_with_invalid_id(client: FlaskCl follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_update_mock.assert_called_once_with(str(invalid_id), - changes=valid_activity_data, - partition_key_value=tenant_id) + repository_update_mock.assert_called_once_with(str(invalid_id), valid_activity_data, ANY) def test_delete_activity_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao @@ -214,13 +204,11 @@ def test_delete_activity_should_succeed_with_valid_id(client: FlaskClient, assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data - repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=tenant_id) + repository_remove_mock.assert_called_once_with(str(valid_id), ANY) def test_delete_activity_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao from werkzeug.exceptions import NotFound @@ -236,13 +224,11 @@ def test_delete_activity_should_return_not_found_with_invalid_id(client: FlaskCl follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + repository_remove_mock.assert_called_once_with(str(invalid_id), ANY) def test_delete_activity_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.activities.activities_namespace import activity_dao from werkzeug.exceptions import UnprocessableEntity @@ -258,5 +244,4 @@ def test_delete_activity_should_return_422_for_invalid_id_format(client: FlaskCl follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + repository_remove_mock.assert_called_once_with(str(invalid_id), ANY) diff --git a/tests/time_tracker_api/customers/customers_namespace_test.py b/tests/time_tracker_api/customers/customers_namespace_test.py index 1777cbe8..8d844782 100644 --- a/tests/time_tracker_api/customers/customers_namespace_test.py +++ b/tests/time_tracker_api/customers/customers_namespace_test.py @@ -1,3 +1,5 @@ +from unittest.mock import ANY + from faker import Faker from flask import json from flask.testing import FlaskClient @@ -71,11 +73,10 @@ def test_list_all_customers(client: FlaskClient, def test_get_customer_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao - valid_id = fake.random_int(1, 9999) + valid_id = fake.uuid4() repository_find_mock = mocker.patch.object(customer_dao.repository, 'find', @@ -87,13 +88,11 @@ def test_get_customer_should_succeed_with_valid_id(client: FlaskClient, assert HTTPStatus.OK == response.status_code fake_customer == json.loads(response.data) - repository_find_mock.assert_called_once_with(str(valid_id), - partition_key_value=tenant_id) + repository_find_mock.assert_called_once_with(str(valid_id), ANY) def test_get_customer_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao from werkzeug.exceptions import NotFound @@ -109,13 +108,11 @@ def test_get_customer_should_return_not_found_with_invalid_id(client: FlaskClien follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + repository_find_mock.assert_called_once_with(str(invalid_id), ANY) def test_get_customer_should_return_422_for_invalid_id_format(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao from werkzeug.exceptions import UnprocessableEntity @@ -131,13 +128,11 @@ def test_get_customer_should_return_422_for_invalid_id_format(client: FlaskClien follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + repository_find_mock.assert_called_once_with(str(invalid_id), ANY) def test_update_customer_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao @@ -153,9 +148,7 @@ def test_update_customer_should_succeed_with_valid_data(client: FlaskClient, assert HTTPStatus.OK == response.status_code fake_customer == json.loads(response.data) - repository_update_mock.assert_called_once_with(str(valid_id), - changes=valid_customer_data, - partition_key_value=tenant_id) + repository_update_mock.assert_called_once_with(str(valid_id), valid_customer_data, ANY) def test_update_customer_should_reject_bad_request(client: FlaskClient, @@ -178,7 +171,6 @@ def test_update_customer_should_reject_bad_request(client: FlaskClient, def test_update_customer_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao from werkzeug.exceptions import NotFound @@ -195,14 +187,11 @@ def test_update_customer_should_return_not_found_with_invalid_id(client: FlaskCl follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_update_mock.assert_called_once_with(str(invalid_id), - changes=valid_customer_data, - partition_key_value=tenant_id) + repository_update_mock.assert_called_once_with(str(invalid_id), valid_customer_data, ANY) def test_delete_customer_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao @@ -218,13 +207,11 @@ def test_delete_customer_should_succeed_with_valid_id(client: FlaskClient, assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data - repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=tenant_id) + repository_remove_mock.assert_called_once_with(str(valid_id), ANY) def test_delete_customer_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.customers.customers_namespace import customer_dao from werkzeug.exceptions import NotFound @@ -240,8 +227,7 @@ def test_delete_customer_should_return_not_found_with_invalid_id(client: FlaskCl follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + repository_remove_mock.assert_called_once_with(str(invalid_id), ANY) def test_delete_customer_should_return_422_for_invalid_id_format(client: FlaskClient, @@ -262,5 +248,4 @@ def test_delete_customer_should_return_422_for_invalid_id_format(client: FlaskCl follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + repository_remove_mock.assert_called_once_with(str(invalid_id), ANY) 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 c14122fe..3a418224 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 @@ -1,3 +1,5 @@ +from unittest.mock import ANY + from faker import Faker from flask import json from flask.testing import FlaskClient @@ -76,8 +78,7 @@ def test_list_all_project_types(client: FlaskClient, def test_get_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao valid_id = fake.random_int(1, 9999) repository_find_mock = mocker.patch.object(project_type_dao.repository, @@ -90,14 +91,12 @@ def test_get_project_should_succeed_with_valid_id(client: FlaskClient, assert HTTPStatus.OK == response.status_code fake_project_type == json.loads(response.data) - repository_find_mock.assert_called_once_with(str(valid_id), - partition_key_value=tenant_id) + repository_find_mock.assert_called_once_with(str(valid_id), ANY) def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao from werkzeug.exceptions import NotFound @@ -112,13 +111,12 @@ def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_find_mock.assert_called_once_with(invalid_id, partition_key_value=tenant_id) + repository_find_mock.assert_called_once_with(invalid_id, ANY) def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao from werkzeug.exceptions import UnprocessableEntity @@ -134,7 +132,7 @@ def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_fo assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + ANY) def test_update_project_should_succeed_with_valid_data(client: FlaskClient, @@ -155,9 +153,7 @@ def test_update_project_should_succeed_with_valid_data(client: FlaskClient, assert HTTPStatus.OK == response.status_code fake_project_type == json.loads(response.data) - repository_update_mock.assert_called_once_with(str(valid_id), - changes=valid_project_type_data, - partition_key_value=tenant_id) + repository_update_mock.assert_called_once_with(str(valid_id), valid_project_type_data, ANY) def test_update_project_should_reject_bad_request(client: FlaskClient, @@ -184,7 +180,6 @@ def test_update_project_should_reject_bad_request(client: FlaskClient, def test_update_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao from werkzeug.exceptions import NotFound @@ -201,14 +196,11 @@ def test_update_project_should_return_not_found_with_invalid_id(client: FlaskCli follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_update_mock.assert_called_once_with(str(invalid_id), - changes=valid_project_type_data, - partition_key_value=tenant_id) + repository_update_mock.assert_called_once_with(str(invalid_id), valid_project_type_data, ANY) def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao @@ -224,13 +216,11 @@ def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data - repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=tenant_id) + repository_remove_mock.assert_called_once_with(str(valid_id), ANY) def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao from werkzeug.exceptions import NotFound @@ -246,13 +236,11 @@ def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskCli follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + repository_remove_mock.assert_called_once_with(str(invalid_id), ANY) def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient, mocker: MockFixture, - tenant_id: str, valid_header: dict): from time_tracker_api.project_types.project_types_namespace import project_type_dao from werkzeug.exceptions import UnprocessableEntity @@ -268,5 +256,4 @@ def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + repository_remove_mock.assert_called_once_with(str(invalid_id), ANY) diff --git a/tests/time_tracker_api/projects/projects_namespace_test.py b/tests/time_tracker_api/projects/projects_namespace_test.py index 6c7ff5eb..024aeffa 100644 --- a/tests/time_tracker_api/projects/projects_namespace_test.py +++ b/tests/time_tracker_api/projects/projects_namespace_test.py @@ -1,3 +1,5 @@ +from unittest.mock import ANY + from faker import Faker from flask import json from flask.testing import FlaskClient @@ -75,8 +77,7 @@ def test_list_all_projects(client: FlaskClient, def test_get_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao valid_id = fake.random_int(1, 9999) repository_find_mock = mocker.patch.object(project_dao.repository, @@ -89,14 +90,12 @@ def test_get_project_should_succeed_with_valid_id(client: FlaskClient, assert HTTPStatus.OK == response.status_code fake_project == json.loads(response.data) - repository_find_mock.assert_called_once_with(str(valid_id), - partition_key_value=tenant_id) + repository_find_mock.assert_called_once_with(str(valid_id), ANY) def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import NotFound @@ -111,14 +110,12 @@ def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + repository_find_mock.assert_called_once_with(str(invalid_id), ANY) def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import UnprocessableEntity @@ -133,14 +130,12 @@ def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_fo follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + repository_find_mock.assert_called_once_with(str(invalid_id), ANY) def test_update_project_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao repository_update_mock = mocker.patch.object(project_dao.repository, @@ -155,9 +150,7 @@ def test_update_project_should_succeed_with_valid_data(client: FlaskClient, assert HTTPStatus.OK == response.status_code fake_project == json.loads(response.data) - repository_update_mock.assert_called_once_with(str(valid_id), - changes=valid_project_data, - partition_key_value=tenant_id) + repository_update_mock.assert_called_once_with(str(valid_id), valid_project_data, ANY) def test_update_project_should_reject_bad_request(client: FlaskClient, @@ -184,8 +177,7 @@ def test_update_project_should_reject_bad_request(client: FlaskClient, def test_update_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import NotFound @@ -201,15 +193,12 @@ def test_update_project_should_return_not_found_with_invalid_id(client: FlaskCli follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_update_mock.assert_called_once_with(str(invalid_id), - changes=valid_project_data, - partition_key_value=tenant_id) + repository_update_mock.assert_called_once_with(str(invalid_id), valid_project_data, ANY) def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao valid_id = fake.random_int(1, 9999) @@ -224,14 +213,12 @@ def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data - repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=tenant_id) + repository_remove_mock.assert_called_once_with(str(valid_id), ANY) def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import NotFound @@ -246,14 +233,12 @@ def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskCli follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + repository_remove_mock.assert_called_once_with(str(invalid_id), ANY) def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.projects.projects_namespace import project_dao from werkzeug.exceptions import UnprocessableEntity @@ -268,5 +253,4 @@ def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id) + repository_remove_mock.assert_called_once_with(str(invalid_id), ANY) 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 80505625..e1294852 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 @@ -4,7 +4,9 @@ from faker import Faker from commons.data_access_layer.cosmos_db import current_datetime, datetime_str -from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository, TimeEntryCosmosDBModel +from commons.data_access_layer.database import EventContext +from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository, TimeEntryCosmosDBModel, \ + container_definition fake = Faker() @@ -13,10 +15,8 @@ two_days_ago = current_datetime() - timedelta(days=2) -def create_time_entry(start_date: datetime, - end_date: datetime, - owner_id: str, - tenant_id: str, +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(), @@ -28,7 +28,8 @@ def create_time_entry(start_date: datetime, "tenant_id": tenant_id } - created_item = time_entry_repository.create(data, mapper=TimeEntryCosmosDBModel) + created_item = time_entry_repository.create(data, event_context, + mapper=TimeEntryCosmosDBModel) return created_item @@ -40,12 +41,16 @@ def test_find_interception_with_date_range_should_find(start_date: datetime, owner_id: str, tenant_id: str, time_entry_repository: TimeEntryCosmosDBRepository): - existing_item = create_time_entry(start_date, end_date, owner_id, tenant_id, time_entry_repository) + 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=owner_id, - partition_key_value=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 @@ -54,21 +59,18 @@ def test_find_interception_with_date_range_should_find(start_date: datetime, time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id) -def test_find_interception_should_ignore_id_of_existing_item(owner_id: str, - tenant_id: str, +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, 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=owner_id, - partition_key_value=tenant_id) + owner_id, tenant_id) non_colliding_result = time_entry_repository.find_interception_with_date_range(start_date, end_date, - owner_id=owner_id, - partition_key_value=tenant_id, + owner_id, tenant_id, ignore_id=existing_item.id) colliding_result is not None @@ -81,10 +83,9 @@ def test_find_interception_should_ignore_id_of_existing_item(owner_id: str, def test_find_running_should_return_running_time_entry(running_time_entry, - owner_id: str, time_entry_repository: TimeEntryCosmosDBRepository): - found_time_entry = time_entry_repository.find_running(partition_key_value=running_time_entry.tenant_id, - owner_id=owner_id) + 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 @@ -95,7 +96,6 @@ 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(partition_key_value=tenant_id, - owner_id=owner_id) + time_entry_repository.find_running(tenant_id, owner_id) except Exception as e: assert type(e) is StopIteration 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 dc001333..6f5cbec4 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 @@ -125,8 +125,7 @@ def test_list_all_time_entries(client: FlaskClient, def test_get_time_entry_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_find_mock = mocker.patch.object(time_entries_dao.repository, 'find', @@ -139,15 +138,12 @@ def test_get_time_entry_should_succeed_with_valid_id(client: FlaskClient, assert HTTPStatus.OK == response.status_code fake_time_entry == json.loads(response.data) - repository_find_mock.assert_called_once_with(str(valid_id), - partition_key_value=tenant_id, - peeker=ANY) + repository_find_mock.assert_called_once_with(str(valid_id), ANY, peeker=ANY) def test_get_time_entry_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import UnprocessableEntity @@ -162,15 +158,12 @@ def test_get_time_entry_should_response_with_unprocessable_entity_for_invalid_id follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_find_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id, - peeker=ANY) + repository_find_mock.assert_called_once_with(str(invalid_id), ANY, peeker=ANY) def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'partial_update', @@ -184,16 +177,13 @@ def test_update_time_entry_should_succeed_with_valid_data(client: FlaskClient, assert HTTPStatus.OK == response.status_code fake_time_entry == json.loads(response.data) - repository_update_mock.assert_called_once_with(str(valid_id), - changes=valid_time_entry_input, - partition_key_value=tenant_id, - peeker=ANY) + repository_update_mock.assert_called_once_with(str(valid_id), valid_time_entry_input, + ANY, peeker=ANY) def test_update_time_entry_should_reject_bad_request(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao invalid_time_entry_data = valid_time_entry_input.copy() invalid_time_entry_data.update({ @@ -215,8 +205,7 @@ def test_update_time_entry_should_reject_bad_request(client: FlaskClient, def test_update_time_entry_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import NotFound repository_update_mock = mocker.patch.object(time_entries_dao.repository, @@ -230,16 +219,13 @@ def test_update_time_entry_should_return_not_found_with_invalid_id(client: Flask follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_update_mock.assert_called_once_with(str(invalid_id), - changes=valid_time_entry_input, - partition_key_value=tenant_id, - peeker=ANY) + repository_update_mock.assert_called_once_with(str(invalid_id), valid_time_entry_input, + ANY, peeker=ANY) def test_delete_time_entry_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_remove_mock = mocker.patch.object(time_entries_dao.repository, 'delete', @@ -252,15 +238,12 @@ def test_delete_time_entry_should_succeed_with_valid_id(client: FlaskClient, assert HTTPStatus.NO_CONTENT == response.status_code assert b'' == response.data - repository_remove_mock.assert_called_once_with(str(valid_id), - partition_key_value=tenant_id, - peeker=ANY) + repository_remove_mock.assert_called_once_with(str(valid_id), ANY, peeker=ANY) def test_delete_time_entry_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import NotFound repository_remove_mock = mocker.patch.object(time_entries_dao.repository, @@ -273,15 +256,12 @@ def test_delete_time_entry_should_return_not_found_with_invalid_id(client: Flask follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id, - peeker=ANY) + repository_remove_mock.assert_called_once_with(str(invalid_id), ANY, peeker=ANY) def test_delete_time_entry_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import UnprocessableEntity repository_remove_mock = mocker.patch.object(time_entries_dao.repository, @@ -294,15 +274,10 @@ def test_delete_time_entry_should_return_unprocessable_entity_for_invalid_id_for follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_remove_mock.assert_called_once_with(str(invalid_id), - partition_key_value=tenant_id, - peeker=ANY) + repository_remove_mock.assert_called_once_with(str(invalid_id), ANY, peeker=ANY) -def test_stop_time_entry_with_valid_id(client: FlaskClient, - mocker: MockFixture, - valid_header: dict, - tenant_id: str): +def test_stop_time_entry_with_valid_id(client: FlaskClient, mocker: MockFixture, valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'partial_update', @@ -314,16 +289,13 @@ def test_stop_time_entry_with_valid_id(client: FlaskClient, follow_redirects=True) assert HTTPStatus.OK == response.status_code - repository_update_mock.assert_called_once_with(str(valid_id), - changes={"end_date": mocker.ANY}, - partition_key_value=tenant_id, - peeker=ANY) + repository_update_mock.assert_called_once_with(str(valid_id), {"end_date": mocker.ANY}, + ANY, peeker=ANY) def test_stop_time_entry_with_id_with_invalid_format(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import UnprocessableEntity repository_update_mock = mocker.patch.object(time_entries_dao.repository, @@ -336,16 +308,13 @@ def test_stop_time_entry_with_id_with_invalid_format(client: FlaskClient, follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_update_mock.assert_called_once_with(invalid_id, - changes={"end_date": ANY}, - partition_key_value=tenant_id, - peeker=ANY) + repository_update_mock.assert_called_once_with(invalid_id, {"end_date": ANY}, + ANY, peeker=ANY) def test_restart_time_entry_with_valid_id(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'partial_update', @@ -357,16 +326,13 @@ def test_restart_time_entry_with_valid_id(client: FlaskClient, follow_redirects=True) assert HTTPStatus.OK == response.status_code - repository_update_mock.assert_called_once_with(str(valid_id), - changes={"end_date": None}, - partition_key_value=tenant_id, - peeker=ANY) + repository_update_mock.assert_called_once_with(str(valid_id), {"end_date": None}, + ANY, peeker=ANY) def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, mocker: MockFixture, - valid_header: dict, - tenant_id: str): + valid_header: dict): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao from werkzeug.exceptions import UnprocessableEntity repository_update_mock = mocker.patch.object(time_entries_dao.repository, @@ -380,17 +346,15 @@ def test_restart_time_entry_with_id_with_invalid_format(client: FlaskClient, follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_update_mock.assert_called_once_with(invalid_id, - changes={"end_date": None}, - partition_key_value=tenant_id, - peeker=ANY) + repository_update_mock.assert_called_once_with(invalid_id, {"end_date": None}, + ANY, peeker=ANY) def test_get_running_should_call_find_running(client: FlaskClient, mocker: MockFixture, valid_header: dict, - owner_id: str, - tenant_id: str): + tenant_id: str, + owner_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'find_running', @@ -402,15 +366,14 @@ def test_get_running_should_call_find_running(client: FlaskClient, assert HTTPStatus.OK == response.status_code assert json.loads(response.data) is not None - repository_update_mock.assert_called_once_with(partition_key_value=tenant_id, - owner_id=owner_id) + repository_update_mock.assert_called_once_with(tenant_id, owner_id) def test_get_running_should_return_not_found_if_find_running_throws_StopIteration(client: FlaskClient, mocker: MockFixture, valid_header: dict, - owner_id: str, - tenant_id: str): + tenant_id: str, + owner_id: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_update_mock = mocker.patch.object(time_entries_dao.repository, 'find_running', @@ -421,8 +384,8 @@ def test_get_running_should_return_not_found_if_find_running_throws_StopIteratio follow_redirects=True) assert HTTPStatus.NOT_FOUND == response.status_code - repository_update_mock.assert_called_once_with(partition_key_value=tenant_id, - owner_id=owner_id) + repository_update_mock.assert_called_once_with(tenant_id, owner_id) + @pytest.mark.parametrize( 'invalid_uuid', ["zxy", "zxy%s" % fake.uuid4(), "%szxy" % fake.uuid4(), " "] @@ -447,13 +410,14 @@ def test_create_with_invalid_uuid_format_should_return_bad_request(client: Flask assert HTTPStatus.BAD_REQUEST == response.status_code repository_container_create_item_mock.assert_not_called() + @pytest.mark.parametrize( 'valid_uuid', ["", fake.uuid4()] ) def test_create_with_valid_uuid_format_should_return_created(client: FlaskClient, - mocker: MockFixture, - valid_header: dict, - valid_uuid: str): + mocker: MockFixture, + valid_header: dict, + valid_uuid: str): from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao repository_container_create_item_mock = mocker.patch.object(time_entries_dao.repository.container, 'create_item', @@ -469,4 +433,3 @@ def test_create_with_valid_uuid_format_should_return_created(client: FlaskClient assert HTTPStatus.CREATED == response.status_code repository_container_create_item_mock.assert_called() - diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 43f8aacf..421a477b 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -7,7 +7,7 @@ from commons.data_access_layer.cosmos_db import CosmosDBDao, CosmosDBRepository, CustomError, current_datetime_str, \ CosmosDBModel -from commons.data_access_layer.database import CRUDDao +from commons.data_access_layer.database import CRUDDao, EventContext from time_tracker_api.security import current_user_id @@ -74,24 +74,24 @@ def create_sql_ignore_id_condition(id: str): else: return "AND c.id!=@ignore_id" - def on_create(self, new_item_data: dict): - CosmosDBRepository.on_create(self, new_item_data) + def on_create(self, new_item_data: dict, event_context: EventContext): + CosmosDBRepository.on_create(self, new_item_data, event_context) if new_item_data.get("start_date") is None: new_item_data['start_date'] = current_datetime_str() - self.validate_data(new_item_data) + self.validate_data(new_item_data, event_context) - def on_update(self, updated_item_data: dict): - CosmosDBRepository.on_update(self, updated_item_data) - self.validate_data(updated_item_data) + def on_update(self, updated_item_data: dict, event_context: EventContext): + CosmosDBRepository.on_update(self, updated_item_data, event_context) + self.validate_data(updated_item_data, event_context) self.replace_empty_value_per_none(updated_item_data) - def find_interception_with_date_range(self, start_date, end_date, owner_id, partition_key_value, + def find_interception_with_date_range(self, start_date, end_date, owner_id, tenant_id, ignore_id=None, visible_only=True, mapper: Callable = None): conditions = { "owner_id": owner_id, - "tenant_id": partition_key_value, + "tenant_id": tenant_id, } params = [ {"name": "@start_date", "value": start_date}, @@ -109,15 +109,15 @@ def find_interception_with_date_range(self, start_date, end_date, owner_id, part conditions_clause=self.create_sql_where_conditions(conditions), order_clause=self.create_sql_order_clause()), parameters=params, - partition_key=partition_key_value) + partition_key=tenant_id) function_mapper = self.get_mapper_or_dict(mapper) return list(map(function_mapper, result)) - def find_running(self, partition_key_value: str, owner_id: str, mapper: Callable = None): + def find_running(self, tenant_id: str, owner_id: str, mapper: Callable = None): conditions = { "owner_id": owner_id, - "tenant_id": partition_key_value, + "tenant_id": tenant_id, } result = self.container.query_items( query=""" @@ -130,13 +130,13 @@ def find_running(self, partition_key_value: str, owner_id: str, mapper: Callable conditions_clause=self.create_sql_where_conditions(conditions), ), parameters=self.generate_condition_values(conditions), - partition_key=partition_key_value, + partition_key=tenant_id, max_item_count=1) function_mapper = self.get_mapper_or_dict(mapper) return function_mapper(next(result)) - def validate_data(self, data): + def validate_data(self, data, event_context: EventContext): start_date = data.get('start_date') if data.get('end_date') is not None: @@ -149,8 +149,8 @@ def validate_data(self, data): collision = self.find_interception_with_date_range(start_date=start_date, end_date=data.get('end_date'), - owner_id=data.get('owner_id'), - partition_key_value=data.get('tenant_id'), + owner_id=event_context.user_id, + tenant_id=event_context.tenant_id, ignore_id=data.get('id')) if len(collision) > 0: raise CustomError(HTTPStatus.UNPROCESSABLE_ENTITY, @@ -169,33 +169,30 @@ def check_whether_current_user_owns_item(cls, data: dict): "The current user is not the owner of this time entry") 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=conditions) + event_ctx = self.create_event_context("read-many") + conditions.update({"owner_id": event_ctx.user_id}) + return self.repository.find_all(event_ctx, conditions=conditions) def get(self, id): - return self.repository.find(id, - partition_key_value=self.partition_key_value, - peeker=self.check_whether_current_user_owns_item) + event_ctx = self.create_event_context("read") + return self.repository.find(id, event_ctx, peeker=self.check_whether_current_user_owns_item) def create(self, data: dict): - data[self.repository.partition_key_attribute] = self.partition_key_value - data["owner_id"] = self.current_user_id() - return self.repository.create(data) + event_ctx = self.create_event_context("create") + return self.repository.create(data, event_ctx) def update(self, id, data: dict): - return self.repository.partial_update(id, - changes=data, - partition_key_value=self.partition_key_value, + event_ctx = self.create_event_context("update") + return self.repository.partial_update(id, data, event_ctx, peeker=self.check_whether_current_user_owns_item) def delete(self, id): - self.repository.delete(id, partition_key_value=self.partition_key_value, - peeker=self.check_whether_current_user_owns_item) + event_ctx = self.create_event_context("delete") + self.repository.delete(id, event_ctx, peeker=self.check_whether_current_user_owns_item) def find_running(self): - return self.repository.find_running(partition_key_value=self.partition_key_value, - owner_id=self.current_user_id()) + event_ctx = self.create_event_context("find_running") + return self.repository.find_running(event_ctx.tenant_id, event_ctx.user_id) def create_dao() -> TimeEntriesDao: From 0fbfe4c99b6b8cb02277c2141b408cc8227b909f Mon Sep 17 00:00:00 2001 From: EliuX Date: Wed, 29 Apr 2020 19:40:00 -0500 Subject: [PATCH 030/361] fix: Refactor codebase related to EventContext #101 --- .DS_Store | Bin 6148 -> 6148 bytes commons/data_access_layer/cosmos_db.py | 8 +- commons/data_access_layer/database.py | 48 ++++------- tests/conftest.py | 3 +- time_tracker_api/__init__.py | 2 +- .../activities/activities_model.py | 4 +- time_tracker_api/customers/customers_model.py | 4 +- time_tracker_api/database.py | 81 ++++++++++++++++++ .../project_types/project_types_model.py | 4 +- time_tracker_api/projects/projects_model.py | 4 +- .../time_entries/time_entries_model.py | 5 +- 11 files changed, 115 insertions(+), 48 deletions(-) create mode 100644 time_tracker_api/database.py diff --git a/.DS_Store b/.DS_Store index 8875004838d0167978fba48f8292ee9ce2fb4fa0..921b45a43934b513b27a1e1340cf303d5ba7d8ff 100644 GIT binary patch delta 135 zcmZoMXfc=|#>B`mu~2NHo}wr-0|Nsi1A_nqLq0$w1*; zAViYP&jG4u5STbYnlF)|fFTp2B|nE@Vvo_rgNw&zlbgmP&LR3 Qh6W(s>?5*+d13<#0HTN?GXMYp delta 101 zcmZoMXfc=|#>CJzu~2NHo}wrt0|NsP3otOmGn6nCF(fi1Gh|OJRA&WA2v62#l%8D7 xD9p&Xc_L#O+vEj|mo~F=@N)q50|mY_Pv#fV str: - if self._user_id is None: - self._user_id = current_user_id() - return self._user_id + def container_id(self): + return self._container_id @property - def tenant_id(self) -> str: - if self._tenant_id is None: - self._tenant_id = current_user_tenant_id() - return self._tenant_id + def action(self): + return self._action @property - def session_id(self) -> str: - return self._session_id - - -def init_app(app: Flask) -> None: - init_cosmos_db(app) + def description(self): + return self._description + @property + def user_id(self): + return self._user_id -def init_sql(app: Flask) -> None: - from commons.data_access_layer.sql import init_app - init_app(app) - + @property + def tenant_id(self): + return self._tenant_id -def init_cosmos_db(app: Flask) -> None: - from commons.data_access_layer.cosmos_db import init_app - init_app(app) + @property + def session_id(self): + return self._session_id diff --git a/tests/conftest.py b/tests/conftest.py index f481ce86..8b9210d8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,7 +7,8 @@ from flask.testing import FlaskClient from commons.data_access_layer.cosmos_db import CosmosDBRepository, CosmosDBDao -from commons.data_access_layer.database import init_sql, EventContext +from time_tracker_api.database import init_sql +from commons.data_access_layer.database import EventContext from time_tracker_api import create_app from time_tracker_api.security import get_or_generate_dev_secret_key from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository diff --git a/time_tracker_api/__init__.py b/time_tracker_api/__init__.py index b23a3c6b..c7b48da7 100644 --- a/time_tracker_api/__init__.py +++ b/time_tracker_api/__init__.py @@ -35,7 +35,7 @@ def init_app_config(app: Flask, config_path: str, config_data: dict = None): def init_app(app: Flask): - from commons.data_access_layer.database import init_app as init_database + from time_tracker_api.database import init_app as init_database init_database(app) from time_tracker_api.api import init_app diff --git a/time_tracker_api/activities/activities_model.py b/time_tracker_api/activities/activities_model.py index 74813cd2..23e173e8 100644 --- a/time_tracker_api/activities/activities_model.py +++ b/time_tracker_api/activities/activities_model.py @@ -3,7 +3,7 @@ from azure.cosmos import PartitionKey from commons.data_access_layer.cosmos_db import CosmosDBModel, CosmosDBDao, CosmosDBRepository -from commons.data_access_layer.database import CRUDDao +from time_tracker_api.database import CRUDDao, APICosmosDBDao class ActivityDao(CRUDDao): @@ -43,7 +43,7 @@ def create_dao() -> ActivityDao: repository = CosmosDBRepository.from_definition(container_definition, mapper=ActivityCosmosDBModel) - class ActivityCosmosDBDao(CosmosDBDao, ActivityDao): + class ActivityCosmosDBDao(APICosmosDBDao, ActivityDao): def __init__(self): CosmosDBDao.__init__(self, repository) diff --git a/time_tracker_api/customers/customers_model.py b/time_tracker_api/customers/customers_model.py index da9a9fcd..d6a30e9c 100644 --- a/time_tracker_api/customers/customers_model.py +++ b/time_tracker_api/customers/customers_model.py @@ -3,7 +3,7 @@ from azure.cosmos import PartitionKey from commons.data_access_layer.cosmos_db import CosmosDBModel, CosmosDBRepository, CosmosDBDao -from commons.data_access_layer.database import CRUDDao +from time_tracker_api.database import CRUDDao, APICosmosDBDao class CustomerDao(CRUDDao): @@ -43,7 +43,7 @@ def create_dao() -> CustomerDao: repository = CosmosDBRepository.from_definition(container_definition, mapper=CustomerCosmosDBModel) - class CustomerCosmosDBDao(CosmosDBDao, CustomerDao): + class CustomerCosmosDBDao(APICosmosDBDao, CustomerDao): def __init__(self): CosmosDBDao.__init__(self, repository) diff --git a/time_tracker_api/database.py b/time_tracker_api/database.py new file mode 100644 index 00000000..09b9bd4f --- /dev/null +++ b/time_tracker_api/database.py @@ -0,0 +1,81 @@ +""" +Agnostic database assets + +Put here your utils and class independent of +the database solution. +To know more about protocols and subtyping check out PEP-0544 +""" +import abc + +from flask import Flask + +from commons.data_access_layer.cosmos_db import CosmosDBDao +from commons.data_access_layer.database import EventContext +from time_tracker_api.security import current_user_id, current_user_tenant_id + + +class CRUDDao(abc.ABC): + @abc.abstractmethod + def get_all(self, conditions: dict): + raise NotImplementedError # pragma: no cover + + @abc.abstractmethod + def get(self, id): + raise NotImplementedError # pragma: no cover + + @abc.abstractmethod + def create(self, project): + raise NotImplementedError # pragma: no cover + + @abc.abstractmethod + def update(self, id, data): + raise NotImplementedError # pragma: no cover + + @abc.abstractmethod + def delete(self, id): + raise NotImplementedError # pragma: no cover + + +class ApiEventContext(EventContext): + def __init__(self, container_id: str, action: str, description: str = None, + user_id: str = None, tenant_id: str = None, session_id: str = None): + super(ApiEventContext, self).__init__(container_id, action, description) + self._user_id = user_id + self._tenant_id = tenant_id + self._session_id = session_id + + @property + def user_id(self) -> str: + if self._user_id is None: + self._user_id = current_user_id() + return self._user_id + + @property + def tenant_id(self) -> str: + if self._tenant_id is None: + self._tenant_id = current_user_tenant_id() + return self._tenant_id + + @property + def session_id(self) -> str: + return self._session_id + + +class APICosmosDBDao(CosmosDBDao): + def create_event_context(self, action: str = None, description: str = None): + return ApiEventContext(self.repository.container.id, action, + description=description) + + +def init_app(app: Flask) -> None: + init_cosmos_db(app) + + +def init_sql(app: Flask) -> None: + from commons.data_access_layer.sql import init_app + init_app(app) + + +def init_cosmos_db(app: Flask) -> None: + from commons.data_access_layer.cosmos_db import init_app + init_app(app) diff --git a/time_tracker_api/project_types/project_types_model.py b/time_tracker_api/project_types/project_types_model.py index eb8564bc..47f1eb13 100644 --- a/time_tracker_api/project_types/project_types_model.py +++ b/time_tracker_api/project_types/project_types_model.py @@ -3,7 +3,7 @@ from azure.cosmos import PartitionKey from commons.data_access_layer.cosmos_db import CosmosDBModel, CosmosDBDao, CosmosDBRepository -from commons.data_access_layer.database import CRUDDao +from time_tracker_api.database import CRUDDao, APICosmosDBDao class ProjectTypeDao(CRUDDao): @@ -45,7 +45,7 @@ def create_dao() -> ProjectTypeDao: repository = CosmosDBRepository.from_definition(container_definition, mapper=ProjectTypeCosmosDBModel) - class ProjectTypeCosmosDBDao(CosmosDBDao, ProjectTypeDao): + class ProjectTypeCosmosDBDao(APICosmosDBDao, ProjectTypeDao): def __init__(self): CosmosDBDao.__init__(self, repository) diff --git a/time_tracker_api/projects/projects_model.py b/time_tracker_api/projects/projects_model.py index a301840e..9374bbc9 100644 --- a/time_tracker_api/projects/projects_model.py +++ b/time_tracker_api/projects/projects_model.py @@ -3,7 +3,7 @@ from azure.cosmos import PartitionKey from commons.data_access_layer.cosmos_db import CosmosDBModel, CosmosDBDao, CosmosDBRepository -from commons.data_access_layer.database import CRUDDao +from time_tracker_api.database import CRUDDao, APICosmosDBDao class ProjectDao(CRUDDao): @@ -45,7 +45,7 @@ def create_dao() -> ProjectDao: repository = CosmosDBRepository.from_definition(container_definition, mapper=ProjectCosmosDBModel) - class ProjectCosmosDBDao(CosmosDBDao, ProjectDao): + class ProjectCosmosDBDao(APICosmosDBDao, ProjectDao): def __init__(self): CosmosDBDao.__init__(self, repository) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 421a477b..53aa6179 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -7,7 +7,8 @@ from commons.data_access_layer.cosmos_db import CosmosDBDao, CosmosDBRepository, CustomError, current_datetime_str, \ CosmosDBModel -from commons.data_access_layer.database import CRUDDao, EventContext +from commons.data_access_layer.database import EventContext +from time_tracker_api.database import CRUDDao, APICosmosDBDao from time_tracker_api.security import current_user_id @@ -157,7 +158,7 @@ def validate_data(self, data, event_context: EventContext): description="There is another time entry in that date range") -class TimeEntriesCosmosDBDao(TimeEntriesDao, CosmosDBDao): +class TimeEntriesCosmosDBDao(APICosmosDBDao, TimeEntriesDao): def __init__(self, repository): CosmosDBDao.__init__(self, repository) From e2d7eb1929fe77c0065c77a9d3b5905fafc19ee1 Mon Sep 17 00:00:00 2001 From: EliuX Date: Wed, 29 Apr 2020 20:41:38 -0500 Subject: [PATCH 031/361] feat: Check whether time entry is running or not when start and stop --- time_tracker_api/database.py | 2 +- .../time_entries/time_entries_model.py | 38 ++++++++++++++++++- .../time_entries/time_entries_namespace.py | 12 ++---- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/time_tracker_api/database.py b/time_tracker_api/database.py index 09b9bd4f..467f7900 100644 --- a/time_tracker_api/database.py +++ b/time_tracker_api/database.py @@ -28,7 +28,7 @@ def create(self, project): raise NotImplementedError # pragma: no cover @abc.abstractmethod - def update(self, id, data): + def update(self, id, data, description=None): raise NotImplementedError # pragma: no cover @abc.abstractmethod diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 53aa6179..13995e73 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -21,6 +21,14 @@ def current_user_id(): def find_running(self): pass + @abc.abstractmethod + def stop(self, id: str): + pass + + @abc.abstractmethod + def restart(self, id: str): + pass + container_definition = { 'id': 'time_entry', @@ -169,6 +177,20 @@ 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") + @classmethod + def checks_owner_and_is_not_stopped(cls, data: dict): + cls.check_whether_current_user_owns_item(data) + + if data.get('end_date') is not None: + raise CustomError(HTTPStatus.UNPROCESSABLE_ENTITY, "The specified time entry is already stopped") + + @classmethod + def checks_owner_and_is_not_started(cls, data: dict): + cls.check_whether_current_user_owns_item(data) + + if data.get('end_date') is None: + raise CustomError(HTTPStatus.UNPROCESSABLE_ENTITY, "The specified time entry is already running") + def get_all(self, conditions: dict = {}) -> list: event_ctx = self.create_event_context("read-many") conditions.update({"owner_id": event_ctx.user_id}) @@ -182,11 +204,23 @@ def create(self, data: dict): event_ctx = self.create_event_context("create") return self.repository.create(data, event_ctx) - def update(self, id, data: dict): - event_ctx = self.create_event_context("update") + def update(self, id, data: dict, description=None): + event_ctx = self.create_event_context("update", description) return self.repository.partial_update(id, data, event_ctx, peeker=self.check_whether_current_user_owns_item) + def stop(self, id): + event_ctx = self.create_event_context("update", "Stop time entry") + return self.repository.partial_update(id, { + 'end_date': current_datetime_str() + }, event_ctx, peeker=self.checks_owner_and_is_not_stopped) + + def restart(self, id): + event_ctx = self.create_event_context("update", "Restart time entry") + return self.repository.partial_update(id, { + 'end_date': None + }, event_ctx, peeker=self.checks_owner_and_is_not_started) + def delete(self, id): event_ctx = self.create_event_context("delete") self.repository.delete(id, event_ctx, peeker=self.check_whether_current_user_owns_item) diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index 4723f111..26fef7e2 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -163,30 +163,26 @@ def delete(self, id): @ns.route('//stop') @ns.response(HTTPStatus.NOT_FOUND, 'Running time entry not found') -@ns.response(HTTPStatus.UNPROCESSABLE_ENTITY, 'The id has an invalid format') +@ns.response(HTTPStatus.UNPROCESSABLE_ENTITY, '"The specified time entry is already stopped') @ns.param('id', 'The unique identifier of a running time entry') class StopTimeEntry(Resource): @ns.doc('stop_time_entry') @ns.marshal_with(time_entry) def post(self, id): """Stop a running time entry""" - return time_entries_dao.update(id, { - 'end_date': current_datetime_str() - }) + return time_entries_dao.stop(id) @ns.route('//restart') @ns.response(HTTPStatus.NOT_FOUND, 'Stopped time entry not found') -@ns.response(HTTPStatus.UNPROCESSABLE_ENTITY, 'The id has an invalid format') +@ns.response(HTTPStatus.UNPROCESSABLE_ENTITY, 'The specified time entry is already running') @ns.param('id', 'The unique identifier of a stopped time entry') class RestartTimeEntry(Resource): @ns.doc('restart_time_entry') @ns.marshal_with(time_entry) def post(self, id): """Restart a time entry""" - return time_entries_dao.update(id, { - 'end_date': None - }) + return time_entries_dao.restart(id) @ns.route('/running') From 2759f0b425140dd22081596c059f2bc732b11c89 Mon Sep 17 00:00:00 2001 From: EliuX Date: Wed, 29 Apr 2020 21:34:33 -0500 Subject: [PATCH 032/361] fix: Close #102 Tweak config of trigger handlers --- .gitignore | 3 +++ commons/data_access_layer/events_model.py | 2 +- commons/data_access_layer/sql.py | 4 +-- .../git_hooks/enforce_semantic_commit_msg.py | 2 -- setup.py | 3 ++- tests/conftest.py | 2 +- .../time_entries_namespace_test.py | 9 ++++--- .../__init__.py | 3 +++ .../function.json | 3 ++- .../handle_customer_trigger/__init__.py | 3 +++ .../handle_customer_trigger/function.json | 27 +++++++++++++++++++ .../__init__.py => handle_events_trigger.py} | 0 .../handle_project_events_trigger/__init__.py | 3 +++ .../function.json | 27 +++++++++++++++++++ .../__init__.py | 3 +++ .../function.json | 27 +++++++++++++++++++ .../__init__.py | 3 +++ .../function.json | 27 +++++++++++++++++++ 18 files changed, 138 insertions(+), 13 deletions(-) create mode 100644 time_tracker_events/handle_activity_events_trigger/__init__.py rename time_tracker_events/{handle_events_trigger => handle_activity_events_trigger}/function.json (87%) create mode 100644 time_tracker_events/handle_customer_trigger/__init__.py create mode 100644 time_tracker_events/handle_customer_trigger/function.json rename time_tracker_events/{handle_events_trigger/__init__.py => handle_events_trigger.py} (100%) create mode 100644 time_tracker_events/handle_project_events_trigger/__init__.py create mode 100644 time_tracker_events/handle_project_events_trigger/function.json create mode 100644 time_tracker_events/handle_project_type_events_trigger/__init__.py create mode 100644 time_tracker_events/handle_project_type_events_trigger/function.json create mode 100644 time_tracker_events/handle_time_entry_events_trigger/__init__.py create mode 100644 time_tracker_events/handle_time_entry_events_trigger/function.json diff --git a/.gitignore b/.gitignore index 54bf6d95..368d5048 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ swagger.json # Local migration files migration_status.csv + +# Mac +.DS_Store diff --git a/commons/data_access_layer/events_model.py b/commons/data_access_layer/events_model.py index 67b84d94..c2375c93 100644 --- a/commons/data_access_layer/events_model.py +++ b/commons/data_access_layer/events_model.py @@ -3,4 +3,4 @@ container_definition = { 'id': 'event', 'partition_key': PartitionKey(path='/tenant_id') -} \ No newline at end of file +} diff --git a/commons/data_access_layer/sql.py b/commons/data_access_layer/sql.py index eb356620..4c9a2fc5 100644 --- a/commons/data_access_layer/sql.py +++ b/commons/data_access_layer/sql.py @@ -1,9 +1,7 @@ -from datetime import datetime - from flask import Flask from flask_sqlalchemy import SQLAlchemy -from commons.data_access_layer.database import CRUDDao, ID_MAX_LENGTH +from commons.data_access_layer.database import CRUDDao db: SQLAlchemy = None diff --git a/commons/git_hooks/enforce_semantic_commit_msg.py b/commons/git_hooks/enforce_semantic_commit_msg.py index c01a3605..b446a074 100644 --- a/commons/git_hooks/enforce_semantic_commit_msg.py +++ b/commons/git_hooks/enforce_semantic_commit_msg.py @@ -15,12 +15,10 @@ COMMIT_MSG_REGEX = r'(build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert)(\([\w\-]+\))?:\s.*' - # Get the commit message file commit_msg_file = open(sys.argv[1]) # The first argument is the file commit_msg = commit_msg_file.read() - if re.match(COMMIT_MSG_REGEX, commit_msg) is None: print(ERROR_MSG) sys.exit(1) diff --git a/setup.py b/setup.py index 108982d8..872a7a62 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,7 @@ -from setuptools import setup, find_packages import sys +from setuptools import setup, find_packages + def get_version() -> str: version = {} diff --git a/tests/conftest.py b/tests/conftest.py index 8b9210d8..4d833e56 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,9 +7,9 @@ from flask.testing import FlaskClient from commons.data_access_layer.cosmos_db import CosmosDBRepository, CosmosDBDao -from time_tracker_api.database import init_sql from commons.data_access_layer.database import EventContext from time_tracker_api import create_app +from time_tracker_api.database import init_sql from time_tracker_api.security import get_or_generate_dev_secret_key from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository 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 6f5cbec4..aae5d47d 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 @@ -8,6 +8,7 @@ from pytest_mock import MockFixture, pytest from commons.data_access_layer.cosmos_db import current_datetime, current_datetime_str +from time_tracker_api.time_entries.time_entries_model import TimeEntriesCosmosDBDao fake = Faker() @@ -289,8 +290,8 @@ def test_stop_time_entry_with_valid_id(client: FlaskClient, mocker: MockFixture, follow_redirects=True) assert HTTPStatus.OK == response.status_code - repository_update_mock.assert_called_once_with(str(valid_id), {"end_date": mocker.ANY}, - ANY, peeker=ANY) + repository_update_mock.assert_called_once_with(str(valid_id), {"end_date": mocker.ANY}, ANY, + peeker=TimeEntriesCosmosDBDao.checks_owner_and_is_not_stopped) def test_stop_time_entry_with_id_with_invalid_format(client: FlaskClient, @@ -308,8 +309,8 @@ def test_stop_time_entry_with_id_with_invalid_format(client: FlaskClient, follow_redirects=True) assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code - repository_update_mock.assert_called_once_with(invalid_id, {"end_date": ANY}, - ANY, peeker=ANY) + repository_update_mock.assert_called_once_with(invalid_id, {"end_date": ANY}, ANY, + peeker=TimeEntriesCosmosDBDao.checks_owner_and_is_not_stopped) def test_restart_time_entry_with_valid_id(client: FlaskClient, diff --git a/time_tracker_events/handle_activity_events_trigger/__init__.py b/time_tracker_events/handle_activity_events_trigger/__init__.py new file mode 100644 index 00000000..bd818325 --- /dev/null +++ b/time_tracker_events/handle_activity_events_trigger/__init__.py @@ -0,0 +1,3 @@ +from ..handle_events_trigger import main as handler + +main = handler diff --git a/time_tracker_events/handle_events_trigger/function.json b/time_tracker_events/handle_activity_events_trigger/function.json similarity index 87% rename from time_tracker_events/handle_events_trigger/function.json rename to time_tracker_events/handle_activity_events_trigger/function.json index 9da66162..03c8e8c1 100644 --- a/time_tracker_events/handle_events_trigger/function.json +++ b/time_tracker_events/handle_activity_events_trigger/function.json @@ -9,7 +9,8 @@ "connectionStringSetting": "COSMOS_DATABASE_URI", "databaseName": "time-tracker-db", "collectionName": "activity", - "createLeaseCollectionIfNotExists": "true" + "createLeaseCollectionIfNotExists": "true", + "leaseCollectionPrefix": "activity_" }, { "direction": "out", diff --git a/time_tracker_events/handle_customer_trigger/__init__.py b/time_tracker_events/handle_customer_trigger/__init__.py new file mode 100644 index 00000000..bd818325 --- /dev/null +++ b/time_tracker_events/handle_customer_trigger/__init__.py @@ -0,0 +1,3 @@ +from ..handle_events_trigger import main as handler + +main = handler diff --git a/time_tracker_events/handle_customer_trigger/function.json b/time_tracker_events/handle_customer_trigger/function.json new file mode 100644 index 00000000..4849e5f9 --- /dev/null +++ b/time_tracker_events/handle_customer_trigger/function.json @@ -0,0 +1,27 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "type": "cosmosDBTrigger", + "name": "documents", + "direction": "in", + "leaseCollectionName": "leases", + "connectionStringSetting": "COSMOS_DATABASE_URI", + "databaseName": "time-tracker-db", + "collectionName": "customer", + "createLeaseCollectionIfNotExists": "true", + "leaseCollectionPrefix": "customer_" + }, + { + "direction": "out", + "type": "cosmosDB", + "name": "events", + "databaseName": "time-tracker-db", + "collectionName": "event", + "leaseCollectionName": "leases", + "createLeaseCollectionIfNotExists": true, + "connectionStringSetting": "COSMOS_DATABASE_URI", + "createIfNotExists": true + } + ] +} diff --git a/time_tracker_events/handle_events_trigger/__init__.py b/time_tracker_events/handle_events_trigger.py similarity index 100% rename from time_tracker_events/handle_events_trigger/__init__.py rename to time_tracker_events/handle_events_trigger.py diff --git a/time_tracker_events/handle_project_events_trigger/__init__.py b/time_tracker_events/handle_project_events_trigger/__init__.py new file mode 100644 index 00000000..bd818325 --- /dev/null +++ b/time_tracker_events/handle_project_events_trigger/__init__.py @@ -0,0 +1,3 @@ +from ..handle_events_trigger import main as handler + +main = handler diff --git a/time_tracker_events/handle_project_events_trigger/function.json b/time_tracker_events/handle_project_events_trigger/function.json new file mode 100644 index 00000000..8e8bde45 --- /dev/null +++ b/time_tracker_events/handle_project_events_trigger/function.json @@ -0,0 +1,27 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "type": "cosmosDBTrigger", + "name": "documents", + "direction": "in", + "leaseCollectionName": "leases", + "connectionStringSetting": "COSMOS_DATABASE_URI", + "databaseName": "time-tracker-db", + "collectionName": "project", + "createLeaseCollectionIfNotExists": "true", + "leaseCollectionPrefix": "project_" + }, + { + "direction": "out", + "type": "cosmosDB", + "name": "events", + "databaseName": "time-tracker-db", + "collectionName": "event", + "leaseCollectionName": "leases", + "createLeaseCollectionIfNotExists": true, + "connectionStringSetting": "COSMOS_DATABASE_URI", + "createIfNotExists": true + } + ] +} diff --git a/time_tracker_events/handle_project_type_events_trigger/__init__.py b/time_tracker_events/handle_project_type_events_trigger/__init__.py new file mode 100644 index 00000000..bd818325 --- /dev/null +++ b/time_tracker_events/handle_project_type_events_trigger/__init__.py @@ -0,0 +1,3 @@ +from ..handle_events_trigger import main as handler + +main = handler diff --git a/time_tracker_events/handle_project_type_events_trigger/function.json b/time_tracker_events/handle_project_type_events_trigger/function.json new file mode 100644 index 00000000..47000ec1 --- /dev/null +++ b/time_tracker_events/handle_project_type_events_trigger/function.json @@ -0,0 +1,27 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "type": "cosmosDBTrigger", + "name": "documents", + "direction": "in", + "leaseCollectionName": "leases", + "connectionStringSetting": "COSMOS_DATABASE_URI", + "databaseName": "time-tracker-db", + "collectionName": "project_type", + "createLeaseCollectionIfNotExists": "true", + "leaseCollectionPrefix": "project_type_" + }, + { + "direction": "out", + "type": "cosmosDB", + "name": "events", + "databaseName": "time-tracker-db", + "collectionName": "event", + "leaseCollectionName": "leases", + "createLeaseCollectionIfNotExists": true, + "connectionStringSetting": "COSMOS_DATABASE_URI", + "createIfNotExists": true + } + ] +} diff --git a/time_tracker_events/handle_time_entry_events_trigger/__init__.py b/time_tracker_events/handle_time_entry_events_trigger/__init__.py new file mode 100644 index 00000000..bd818325 --- /dev/null +++ b/time_tracker_events/handle_time_entry_events_trigger/__init__.py @@ -0,0 +1,3 @@ +from ..handle_events_trigger import main as handler + +main = handler diff --git a/time_tracker_events/handle_time_entry_events_trigger/function.json b/time_tracker_events/handle_time_entry_events_trigger/function.json new file mode 100644 index 00000000..d18cb56a --- /dev/null +++ b/time_tracker_events/handle_time_entry_events_trigger/function.json @@ -0,0 +1,27 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "type": "cosmosDBTrigger", + "name": "documents", + "direction": "in", + "leaseCollectionName": "leases", + "connectionStringSetting": "COSMOS_DATABASE_URI", + "databaseName": "time-tracker-db", + "collectionName": "time_entry", + "createLeaseCollectionIfNotExists": "true", + "leaseCollectionPrefix": "time_entry_" + }, + { + "direction": "out", + "type": "cosmosDB", + "name": "events", + "databaseName": "time-tracker-db", + "collectionName": "event", + "leaseCollectionName": "leases", + "createLeaseCollectionIfNotExists": true, + "connectionStringSetting": "COSMOS_DATABASE_URI", + "createIfNotExists": true + } + ] +} From 6d887ea63d4d2ff21e02dfe196fc624e53a9e81d Mon Sep 17 00:00:00 2001 From: EliuX Date: Thu, 30 Apr 2020 11:51:01 -0500 Subject: [PATCH 033/361] fix: Update README and test time_tracker_events #101 --- README.md | 39 ++++++++++++++- commons/data_access_layer/events_model.py | 2 +- setup.cfg | 3 ++ tests/time_tracker_events/__init__.py | 0 .../test_handle_events_trigger.py | 49 +++++++++++++++++++ 5 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 tests/time_tracker_events/__init__.py create mode 100644 tests/time_tracker_events/test_handle_events_trigger.py diff --git a/README.md b/README.md index 000fb54b..28e407fc 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Be sure you have installed in your system - [Python version 3](https://www.python.org/download/releases/3.0/) in your path. It will install automatically [pip](https://pip.pypa.io/en/stable/) as well. - A virtual environment, namely [venv](https://docs.python.org/3/library/venv.html). +- Optionally for running Azure functions locally: [Azure functions core tool](https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=macos%2Ccsharp%2Cbash). ### Setup - Create and activate the environment, @@ -38,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`. + 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 @@ -66,6 +67,40 @@ automatically [pip](https://pip.pypa.io/en/stable/) as well. - Open `http://127.0.0.1:5000/` in a browser. You will find in the presented UI a link to the swagger.json with the definition of the api. +#### Handling Cosmos DB triggers for creating events with time_tracker_events +The project `time_tracker_events` is an Azure Function project. Its main responsibility is to respond to calls related to +events, like those [triggered by Change Feed](https://docs.microsoft.com/en-us/azure/cosmos-db/change-feed-functions). +Every time a write action (`create`, `update`, `soft-delete`) is done by CosmosDB, thanks to [bindings](https://docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-cosmosdb?toc=%2Fazure%2Fcosmos-db%2Ftoc.json&bc=%2Fazure%2Fcosmos-db%2Fbreadcrumb%2Ftoc.json&tabs=csharp) +these functions will be called. You can also run them in your local machine: + +- You must have the [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/get-started-with-azure-cli?view=azure-cli-latest) +and the [Azure Functions Core Tools](https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=macos%2Ccsharp%2Cbash) +installed in your local machine. +- Be sure to [authenticate](https://docs.microsoft.com/en-us/cli/azure/authenticate-azure-cli?view=azure-cli-latest) +with the Azure CLI if you are not. +```bash +az login +``` +- Execute the project +```bash +cd time_tracker_events +source run.sh +``` +You will see that a large console log will appear ending with a message like +```log +Now listening on: http://0.0.0.0:7071 +Application started. Press Ctrl+C to shut down. +``` +- Now you are ready to start generating events. Just execute any change in your API and you will see how logs are being +generated by the console app you ran before. For instance, this is the log generated when I restarted a time entry: +```log +[04/30/2020 14:42:12] Executing 'Functions.handle_time_entry_events_trigger' (Reason='New changes on collection time_entry at 2020-04-30T14:42:12.1465310Z', Id=3da87e53-0434-4ff2-8db3-f7c051ccf9fd) +[04/30/2020 14:42:12] INFO: Received FunctionInvocationRequest, request ID: 578e5067-b0c0-42b5-a1a4-aac858ea57c0, function ID: c8ac3c4c-fefd-4db9-921e-661b9010a4d9, invocation ID: 3da87e53-0434-4ff2-8db3-f7c051ccf9fd +[04/30/2020 14:42:12] INFO: Successfully processed FunctionInvocationRequest, request ID: 578e5067-b0c0-42b5-a1a4-aac858ea57c0, function ID: c8ac3c4c-fefd-4db9-921e-661b9010a4d9, invocation ID: 3da87e53-0434-4ff2-8db3-f7c051ccf9fd +[04/30/2020 14:42:12] {"id": "9ac108ff-c24d-481e-9c61-b8a3a0737ee8", "project_id": "c2e090fb-ae8b-4f33-a9b8-2052d67d916b", "start_date": "2020-04-28T15:20:36.006Z", "tenant_id": "cc925a5d-9644-4a4f-8d99-0bee49aadd05", "owner_id": "709715c1-6d96-4ecc-a951-b628f2e7d89c", "end_date": null, "_last_event_ctx": {"user_id": "709715c1-6d96-4ecc-a951-b628f2e7d89c", "tenant_id": "cc925a5d-9644-4a4f-8d99-0bee49aadd05", "action": "update", "description": "Restart time entry", "container_id": "time_entry", "session_id": null}, "description": "Changing my description for testing Change Feed", "_metadata": {}} +[04/30/2020 14:42:12] Executed 'Functions.handle_time_entry_events_trigger' (Succeeded, Id=3da87e53-0434-4ff2-8db3-f7c051ccf9fd) +``` + ### Security In this API we are requiring authenticated users using JWT. To do so, we are using the library [PyJWT](https://pypi.org/project/PyJWT/), so in every request to the API we expect a header `Authorization` with a format @@ -254,6 +289,8 @@ the win. - [Swagger](https://swagger.io/) for documentation and standardization, taking into account the [API import restrictions and known issues](https://docs.microsoft.com/en-us/azure/api-management/api-management-api-import-restrictions) in Azure. +- [Azure Functions bindings](https://docs.microsoft.com/en-us/azure/azure-functions/functions-bindings-cosmosdb?toc=%2Fazure%2Fcosmos-db%2Ftoc.json&bc=%2Fazure%2Fcosmos-db%2Fbreadcrumb%2Ftoc.json&tabs=csharp) +for making `time_tracker_events` to handle the triggers [generated by our Cosmos DB database throw Change Feed](https://docs.microsoft.com/bs-latn-ba/azure/cosmos-db/change-feed-functions). ## License diff --git a/commons/data_access_layer/events_model.py b/commons/data_access_layer/events_model.py index c2375c93..44abed10 100644 --- a/commons/data_access_layer/events_model.py +++ b/commons/data_access_layer/events_model.py @@ -1,6 +1,6 @@ from azure.cosmos import PartitionKey -container_definition = { +container_definition = { # pragma: no cover 'id': 'event', 'partition_key': PartitionKey(path='/tenant_id') } diff --git a/setup.cfg b/setup.cfg index b9fa455d..4c3447f8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,6 +12,9 @@ branch = True source = time_tracker_api commons + time_tracker_events +omit = + time_tracker_events/handle_*_events_trigger/* [report] exclude_lines = diff --git a/tests/time_tracker_events/__init__.py b/tests/time_tracker_events/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/time_tracker_events/test_handle_events_trigger.py b/tests/time_tracker_events/test_handle_events_trigger.py new file mode 100644 index 00000000..12db267c --- /dev/null +++ b/tests/time_tracker_events/test_handle_events_trigger.py @@ -0,0 +1,49 @@ +import azure.functions as func +from faker import Faker + +from time_tracker_events.handle_events_trigger import main as main_handler + +fake = Faker() + + +class OutImpl(func.Out): + def __init__(self): + self.val = None + + def set(self, val: func.DocumentList) -> None: + self.val = val + + def get(self) -> func.DocumentList: + return self.val + + +def generate_sample_document(has_event_ctx=True): + result = { + "id": fake.uuid4(), + "tenant_id": fake.uuid4(), + } + + if has_event_ctx: + result["_last_event_ctx"] = { + "user_id": fake.name(), + "action": "update", + "description": fake.paragraph(), + "container_id": fake.uuid4(), + "session_id": fake.uuid4(), + "tenant_id": result["tenant_id"], + } + + return result + + +def test_main_handler_should_generate_events_if_hidden_attrib_is_found(): + out = OutImpl() + documents = func.DocumentList() + for i in range(10): + documents.append(func.Document.from_dict(generate_sample_document())) + for i in range(5): + documents.append(func.Document.from_dict(generate_sample_document(False))) + + main_handler(documents, out) + + assert len(out.get()) == 10 From b6ab54d5cf3236c96de1c4e292606bcdb033aaf6 Mon Sep 17 00:00:00 2001 From: EliuX Date: Thu, 30 Apr 2020 13:07:49 -0500 Subject: [PATCH 034/361] fix: Refactor time_tracker_events for deployment #101 --- .DS_Store | Bin 6148 -> 0 bytes README.md | 15 ++++++++++++++- requirements/time_tracker_events/prod.txt | 5 ++--- .../time_tracker_events/shared_code/__init__.py | 0 .../test_handle_events_trigger.py | 2 +- .../time_entries/time_entries_namespace.py | 2 +- time_tracker_events/.funcignore | 3 ++- time_tracker_events/.gitignore | 2 +- .../handle_activity_events_trigger/__init__.py | 4 ++-- .../handle_customer_trigger/__init__.py | 2 +- .../handle_project_events_trigger/__init__.py | 2 +- .../__init__.py | 2 +- .../handle_time_entry_events_trigger/__init__.py | 2 +- time_tracker_events/host.json | 2 +- time_tracker_events/local.settings.json | 8 -------- time_tracker_events/requirements.txt | 4 ++++ .../{ => shared_code}/handle_events_trigger.py | 0 17 files changed, 32 insertions(+), 23 deletions(-) delete mode 100644 .DS_Store create mode 100644 tests/time_tracker_events/shared_code/__init__.py rename tests/time_tracker_events/{ => shared_code}/test_handle_events_trigger.py (93%) delete mode 100644 time_tracker_events/local.settings.json create mode 100644 time_tracker_events/requirements.txt rename time_tracker_events/{ => shared_code}/handle_events_trigger.py (100%) diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 921b45a43934b513b27a1e1340cf303d5ba7d8ff..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK!Ab)`41Ljp1uq3J9_JVQgQe6j@B_5A6^gZ6TJ=1i?t7Cc78j32Bq@`ZnIyBD z2c6jfu<6_K2ABXCa|lhwfM|Kpbr!+rL~DEW*y8ZCJS-mjfsOv+lHUCS3-oy5@7w$L z8~Ua_WQg?n0i%o+O3*`D(`c369{{YJH2{Sv&wJJYs|2!1?8X(`D-GLQ@;1IfTi z8PEks8DBc4P6m>JWZ;VdoezaW*acR{c63m+7JxY7aueF>C6rSFyTIy555+u{=&2Sh zhIu;IOT=}7)zQ;o(R^4u`LlVk_H^!FS~#q8Oq~oQ1IG-g_oYm9|KIX2GuY&}kW|S) zGVos+u+i*pHf42jxBjS8cWvQ#;E*V;%An9b`Uv1e=g4()y1&R~To+g!wTsqUIx#;4 M8X>8YffF$B0eieDqW}N^ diff --git a/README.md b/README.md index 28e407fc..c9da1efd 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,10 @@ automatically [pip](https://pip.pypa.io/en/stable/) as well. * `prod`: For anything deployed - Remember to do it with Python 3. +Remember to do it with Python 3. + +Bear in mind that the requirements for `time_tracker_events`, must be located on its local requirements.txt, by + [convention](https://docs.microsoft.com/en-us/azure/azure-functions/functions-reference-python#folder-structure). - Run `pre-commit install`. For more details, check out Development > Git hooks. @@ -134,6 +137,16 @@ following notes regarding to the manipulation of the data from and towards the A - The [recommended](https://docs.microsoft.com/en-us/azure/cosmos-db/working-with-dates#storing-datetimes) format for DateTime strings in Azure Cosmos DB is `YYYY-MM-DDThh:mm:ss.fffffffZ` which follows the ISO 8601 **UTC standard**. +The Azure function project `time_tracker_events` also have some constraints to have into account. It is recommended that +you read the [Azure Functions Python developer guide](https://docs.microsoft.com/en-us/azure/azure-functions/functions-reference-python#folder-structure). + +If you require to deploy `time_tracker_events` from your local machine to Azure Functions, you can execute: + +```bash +func azure functionapp publish time-tracker-events --build local +``` + + ## Development ### Git hooks diff --git a/requirements/time_tracker_events/prod.txt b/requirements/time_tracker_events/prod.txt index 56cb7a97..a42edf6d 100644 --- a/requirements/time_tracker_events/prod.txt +++ b/requirements/time_tracker_events/prod.txt @@ -1,5 +1,4 @@ # requirements/time_tracker_events/prod.txt -# Azure Functions library -azure-functions - +# Include the requirements of that folder, required there by convention +-r ../../time_tracker_events/requirements.txt diff --git a/tests/time_tracker_events/shared_code/__init__.py b/tests/time_tracker_events/shared_code/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/time_tracker_events/test_handle_events_trigger.py b/tests/time_tracker_events/shared_code/test_handle_events_trigger.py similarity index 93% rename from tests/time_tracker_events/test_handle_events_trigger.py rename to tests/time_tracker_events/shared_code/test_handle_events_trigger.py index 12db267c..4e199732 100644 --- a/tests/time_tracker_events/test_handle_events_trigger.py +++ b/tests/time_tracker_events/shared_code/test_handle_events_trigger.py @@ -1,7 +1,7 @@ import azure.functions as func from faker import Faker -from time_tracker_events.handle_events_trigger import main as main_handler +from time_tracker_events.shared_code.handle_events_trigger import main as main_handler fake = Faker() diff --git a/time_tracker_api/time_entries/time_entries_namespace.py b/time_tracker_api/time_entries/time_entries_namespace.py index 26fef7e2..9efe4463 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -163,7 +163,7 @@ def delete(self, id): @ns.route('//stop') @ns.response(HTTPStatus.NOT_FOUND, 'Running time entry not found') -@ns.response(HTTPStatus.UNPROCESSABLE_ENTITY, '"The specified time entry is already stopped') +@ns.response(HTTPStatus.UNPROCESSABLE_ENTITY, 'The specified time entry is already stopped') @ns.param('id', 'The unique identifier of a running time entry') class StopTimeEntry(Resource): @ns.doc('stop_time_entry') diff --git a/time_tracker_events/.funcignore b/time_tracker_events/.funcignore index 8b137891..38b8bc85 100644 --- a/time_tracker_events/.funcignore +++ b/time_tracker_events/.funcignore @@ -1 +1,2 @@ - +run.sh +.vscode diff --git a/time_tracker_events/.gitignore b/time_tracker_events/.gitignore index fbbe2efa..86168604 100644 --- a/time_tracker_events/.gitignore +++ b/time_tracker_events/.gitignore @@ -40,4 +40,4 @@ venv.bak/ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] -*$py.class \ No newline at end of file +*$py.class diff --git a/time_tracker_events/handle_activity_events_trigger/__init__.py b/time_tracker_events/handle_activity_events_trigger/__init__.py index bd818325..0602f8e4 100644 --- a/time_tracker_events/handle_activity_events_trigger/__init__.py +++ b/time_tracker_events/handle_activity_events_trigger/__init__.py @@ -1,3 +1,3 @@ -from ..handle_events_trigger import main as handler +from ..shared_code import handle_events_trigger -main = handler +main = handle_events_trigger.main diff --git a/time_tracker_events/handle_customer_trigger/__init__.py b/time_tracker_events/handle_customer_trigger/__init__.py index bd818325..9bc23156 100644 --- a/time_tracker_events/handle_customer_trigger/__init__.py +++ b/time_tracker_events/handle_customer_trigger/__init__.py @@ -1,3 +1,3 @@ -from ..handle_events_trigger import main as handler +from ..shared_code.handle_events_trigger import main as handler main = handler diff --git a/time_tracker_events/handle_project_events_trigger/__init__.py b/time_tracker_events/handle_project_events_trigger/__init__.py index bd818325..9bc23156 100644 --- a/time_tracker_events/handle_project_events_trigger/__init__.py +++ b/time_tracker_events/handle_project_events_trigger/__init__.py @@ -1,3 +1,3 @@ -from ..handle_events_trigger import main as handler +from ..shared_code.handle_events_trigger import main as handler main = handler diff --git a/time_tracker_events/handle_project_type_events_trigger/__init__.py b/time_tracker_events/handle_project_type_events_trigger/__init__.py index bd818325..9bc23156 100644 --- a/time_tracker_events/handle_project_type_events_trigger/__init__.py +++ b/time_tracker_events/handle_project_type_events_trigger/__init__.py @@ -1,3 +1,3 @@ -from ..handle_events_trigger import main as handler +from ..shared_code.handle_events_trigger import main as handler main = handler diff --git a/time_tracker_events/handle_time_entry_events_trigger/__init__.py b/time_tracker_events/handle_time_entry_events_trigger/__init__.py index bd818325..39a2ad25 100644 --- a/time_tracker_events/handle_time_entry_events_trigger/__init__.py +++ b/time_tracker_events/handle_time_entry_events_trigger/__init__.py @@ -1,3 +1,3 @@ -from ..handle_events_trigger import main as handler +from __app__.shared_code.handle_events_trigger import main as handler main = handler diff --git a/time_tracker_events/host.json b/time_tracker_events/host.json index 8f3cf9db..d342a8ea 100644 --- a/time_tracker_events/host.json +++ b/time_tracker_events/host.json @@ -4,4 +4,4 @@ "id": "Microsoft.Azure.Functions.ExtensionBundle", "version": "[1.*, 2.0.0)" } -} \ No newline at end of file +} diff --git a/time_tracker_events/local.settings.json b/time_tracker_events/local.settings.json deleted file mode 100644 index 89cb8861..00000000 --- a/time_tracker_events/local.settings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "FUNCTIONS_WORKER_RUNTIME": "python", - "AzureWebJobsStorage": "{AzureWebJobsStorage}", - "COSMOS_DATABASE_URI": "AccountEndpoint=https://time-tracker-db.documents.azure.com:443/;AccountKey=6lyRD8ma0VQjcMbeSMFOTDGwNDptcEGfngp3c9DStNAMCNh2MRbkNWmRinoNvuB6aH51EMEkgeP5WfW3VZiV9g==;" - } -} diff --git a/time_tracker_events/requirements.txt b/time_tracker_events/requirements.txt new file mode 100644 index 00000000..034bca5b --- /dev/null +++ b/time_tracker_events/requirements.txt @@ -0,0 +1,4 @@ +# time_tracker_events/requirements.txt + +# Azure Functions library +azure-functions \ No newline at end of file diff --git a/time_tracker_events/handle_events_trigger.py b/time_tracker_events/shared_code/handle_events_trigger.py similarity index 100% rename from time_tracker_events/handle_events_trigger.py rename to time_tracker_events/shared_code/handle_events_trigger.py From 682cd4b6d896dbfa85e2f6d72e0b772cfb855d83 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Thu, 30 Apr 2020 20:38:55 +0000 Subject: [PATCH 035/361] 0.8.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 fb9b668f..32a90a3b 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.7.2' +__version__ = '0.8.0' From f877e0ac1dc8149d622e934db2cb5a75b861e419 Mon Sep 17 00:00:00 2001 From: roberto Date: Thu, 30 Apr 2020 16:35:25 -0500 Subject: [PATCH 036/361] feat: add test find_all is called with generated dates --- commons/data_access_layer/cosmos_db.py | 56 ++++++++++++++---- .../data_access_layer/cosmos_db_test.py | 2 +- .../time_entries_namespace_test.py | 45 +++++++++++++- .../time_entries/time_entries_model.py | 59 +++++-------------- .../time_entries/time_entries_namespace.py | 4 +- 5 files changed, 105 insertions(+), 61 deletions(-) diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index 85795027..602b1b73 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -2,7 +2,7 @@ import logging import uuid from datetime import datetime -from typing import Callable, List +from typing import Callable, List, Tuple import azure.cosmos.cosmos_client as cosmos_client import azure.cosmos.exceptions as exceptions @@ -117,18 +117,15 @@ def create_sql_where_conditions(conditions: dict, container_name='c') -> str: return "" @staticmethod - def create_sql_custom_conditions(custom_conditions: List[str]) -> str: - if len(custom_conditions) > 0: - return """ - AND {custom_conditions_clause} - """.format( - custom_conditions_clause=" AND ".join(custom_conditions) - ) + def create_custom_sql_conditions(custom_sql_conditions: List[str]) -> str: + if len(custom_sql_conditions) > 0: + return "AND {custom_sql_conditions_clause}".format( + custom_sql_conditions_clause=" AND ".join(custom_sql_conditions)) else: return '' @staticmethod - def generate_condition_values(conditions: dict) -> dict: + def generate_params(conditions: dict) -> dict: result = [] for k, v in conditions.items(): result.append({ @@ -165,7 +162,7 @@ def find(self, id: str, partition_key_value, peeker: 'function' = None, function_mapper = self.get_mapper_or_dict(mapper) return function_mapper(self.check_visibility(found_item, visible_only)) - def find_all(self, partition_key_value: str, conditions: dict = {}, custom_conditions: List[str] = [], + def find_all(self, partition_key_value: str, conditions: dict = {}, custom_sql_conditions: List[str] = [], custom_params: dict = {}, max_count=None, offset=0, visible_only=True, mapper: Callable = None): # TODO Use the tenant_id param and change container alias max_count = self.get_page_size_or(max_count) @@ -174,16 +171,16 @@ def find_all(self, partition_key_value: str, conditions: dict = {}, custom_condi {"name": "@offset", "value": offset}, {"name": "@max_count", "value": max_count}, ] - params.extend(self.generate_condition_values(conditions)) + params.extend(self.generate_params(conditions)) params.extend(custom_params) result = self.container.query_items(query=""" SELECT * FROM c WHERE c.{partition_key_attribute}=@partition_key_value - {conditions_clause} {visibility_condition} {custom_conditions_clause} {order_clause} + {conditions_clause} {visibility_condition} {custom_sql_conditions_clause} {order_clause} OFFSET @offset LIMIT @max_count """.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_conditions_clause=self.create_sql_custom_conditions(custom_conditions), + custom_sql_conditions_clause=self.create_custom_sql_conditions(custom_sql_conditions), order_clause=self.create_sql_order_clause()), parameters=params, partition_key=partition_key_value, @@ -292,3 +289,36 @@ def generate_uuid4() -> str: def init_app(app: Flask) -> None: global cosmos_helper cosmos_helper = CosmosDBFacade.from_flask_config(app) + + +def get_last_day_of_month(year: int, month: int) -> int: + from calendar import monthrange + return monthrange(year=year, month=month)[1] + + +def get_current_year() -> int: + return datetime.now().year + + +def get_current_month() -> int: + return datetime.now().month + + +def get_date_range_of_month( + year: int, + month: int +) -> Tuple[datetime, datetime]: + first_day_of_month = 1 + start_date = datetime(year=year, month=month, day=first_day_of_month) + + 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 + ) + return start_date, end_date diff --git a/tests/commons/data_access_layer/cosmos_db_test.py b/tests/commons/data_access_layer/cosmos_db_test.py index 0059370d..737b1659 100644 --- a/tests/commons/data_access_layer/cosmos_db_test.py +++ b/tests/commons/data_access_layer/cosmos_db_test.py @@ -559,7 +559,7 @@ def test_repository_create_sql_where_conditions_with_no_values(cosmos_db_reposit def test_repository_append_conditions_values(cosmos_db_repository: CosmosDBRepository): - result = cosmos_db_repository.generate_condition_values({'owner_id': 'mark', 'customer_id': 'ioet'}) + result = cosmos_db_repository.generate_params({'owner_id': 'mark', 'customer_id': 'ioet'}) assert result is not None assert result == [{'name': '@owner_id', 'value': 'mark'}, 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 dc001333..09bd2bc0 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,7 +7,9 @@ from flask_restplus._http import HTTPStatus from pytest_mock import MockFixture, pytest -from commons.data_access_layer.cosmos_db import current_datetime, current_datetime_str +from commons.data_access_layer.cosmos_db import current_datetime, \ + current_datetime_str, get_date_range_of_month, get_current_month, \ + get_current_year, datetime_str fake = Faker() @@ -470,3 +472,44 @@ def test_create_with_valid_uuid_format_should_return_created(client: FlaskClient assert HTTPStatus.CREATED == response.status_code repository_container_create_item_mock.assert_called() + +@pytest.mark.parametrize( + 'url,month,year', + [ + ('/time-entries?month=4&year=2020', 4, 2020), + ('/time-entries?month=4', 4, get_current_year()), + ('/time-entries', get_current_month(), get_current_year()) + ] +) +def test_find_all_is_called_with_generated_dates(client: FlaskClient, + mocker: MockFixture, + valid_header: dict, + tenant_id: str, + owner_id: str, + url: str, + month: int, + year: int): + from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao + repository_find_all_mock = mocker.patch.object(time_entries_dao.repository, + 'find_all', + return_value=fake_time_entry) + + response = client.get(url, + headers=valid_header, + follow_redirects=True) + + start_date, end_date = get_date_range_of_month(year, month) + custom_args = { + 'start_date': datetime_str(start_date), + 'end_date': datetime_str(end_date) + } + + conditions = { + 'owner_id': owner_id + } + + assert HTTPStatus.OK == response.status_code + assert json.loads(response.data) is not None + repository_find_all_mock.assert_called_once_with(partition_key_value=tenant_id, + conditions=conditions, + custom_args=custom_args) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 73fb9e12..e3e2f573 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -1,13 +1,12 @@ import abc from dataclasses import dataclass, field -from typing import List, Callable, Tuple -from datetime import datetime +from typing import List, Callable from azure.cosmos import PartitionKey from flask_restplus._http import HTTPStatus from commons.data_access_layer.cosmos_db import CosmosDBDao, CosmosDBRepository, CustomError, current_datetime_str, \ - CosmosDBModel + CosmosDBModel, get_date_range_of_month, get_current_year, get_current_month from commons.data_access_layer.database import CRUDDao from time_tracker_api.security import current_user_id @@ -76,8 +75,8 @@ def create_sql_ignore_id_condition(id: str): return "AND c.id!=@ignore_id" @staticmethod - def create_sql_date_range_filter(custom_args: dict) -> str: - if 'start_date' and 'end_date' in custom_args: + def create_sql_date_range_filter(date_range: dict) -> str: + if 'start_date' and 'end_date' in date_range: return """ ((c.start_date BETWEEN @start_date AND @end_date) OR (c.end_date BETWEEN @start_date AND @end_date)) @@ -85,22 +84,19 @@ def create_sql_date_range_filter(custom_args: dict) -> str: else: return '' - def find_all(self, partition_key_value: str, conditions: dict, custom_args: dict): - custom_conditions = [] - custom_conditions.append( - self.create_sql_date_range_filter(custom_args) + def find_all(self, partition_key_value: str, conditions: dict, date_range: dict): + custom_sql_conditions = [] + custom_sql_conditions.append( + self.create_sql_date_range_filter(date_range) ) - custom_params = [ - {"name": "@start_date", "value": custom_args.get('start_date')}, - {"name": "@end_date", "value": custom_args.get('end_date')}, - ] + custom_params = self.generate_params(date_range) return CosmosDBRepository.find_all( self, partition_key_value=partition_key_value, conditions=conditions, - custom_conditions=custom_conditions, + custom_sql_conditions=custom_sql_conditions, custom_params=custom_params ) @@ -128,7 +124,7 @@ def find_interception_with_date_range(self, start_date, end_date, owner_id, part {"name": "@end_date", "value": end_date or current_datetime_str()}, {"name": "@ignore_id", "value": ignore_id}, ] - params.extend(self.generate_condition_values(conditions)) + params.extend(self.generate_params(conditions)) result = self.container.query_items( query=""" SELECT * FROM c WHERE ((c.start_date BETWEEN @start_date AND @end_date) @@ -159,7 +155,7 @@ def find_running(self, partition_key_value: str, owner_id: str, mapper: Callable visibility_condition=self.create_sql_condition_for_visibility(True), conditions_clause=self.create_sql_where_conditions(conditions), ), - parameters=self.generate_condition_values(conditions), + parameters=self.generate_params(conditions), partition_key=partition_key_value, max_item_count=1) @@ -187,31 +183,6 @@ def validate_data(self, data): description="There is another time entry in that date range") -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_date_range_of_month( - year: int, - month: int -) -> Tuple[datetime, datetime]: - first_day_of_month = 1 - start_date = datetime(year=year, month=month, day=first_day_of_month) - - # TODO : fix bound as this would exclude the last day - 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) - return start_date, end_date - class TimeEntriesCosmosDBDao(TimeEntriesDao, CosmosDBDao): def __init__(self, repository): @@ -242,13 +213,13 @@ def get_all(self, conditions: dict = {}) -> list: start_date, end_date = get_date_range_of_month(year, month) - custom_args = { + date_range = { 'start_date': start_date.isoformat(), - 'end_date': end_date.isoformat() + 'end_date': end_date.isoformat(), } return self.repository.find_all(partition_key_value=self.partition_key_value, conditions=conditions, - custom_args=custom_args) + date_range=date_range) 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 eab9b585..09ecf40b 100644 --- a/time_tracker_api/time_entries/time_entries_namespace.py +++ b/time_tracker_api/time_entries/time_entries_namespace.py @@ -111,11 +111,11 @@ # custom attributes filter attributes_filter.add_argument('month', required=False, store_missing=False, - help="(Filter) month to filter", + help="(Filter) Month to filter by", location='args') attributes_filter.add_argument('year', required=False, store_missing=False, - help="(Filter) year to filter", + help="(Filter) Year to filter by", location='args') @ns.route('') From ff4a4d60b14a0d819f76feb62767917b64ce8958 Mon Sep 17 00:00:00 2001 From: roberto Date: Thu, 30 Apr 2020 19:46:39 -0500 Subject: [PATCH 037/361] fix: update test of filter per month and year --- commons/data_access_layer/cosmos_db.py | 10 ++++-- .../time_entries_namespace_test.py | 20 ++++------- .../time_entries/time_entries_model.py | 36 +++++++++---------- 3 files changed, 30 insertions(+), 36 deletions(-) diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index 40416cec..10bc685d 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -2,7 +2,7 @@ import logging import uuid from datetime import datetime -from typing import Callable, List, Tuple +from typing import Callable, List, Dict import azure.cosmos.cosmos_client as cosmos_client import azure.cosmos.exceptions as exceptions @@ -326,7 +326,7 @@ def get_current_month() -> int: def get_date_range_of_month( year: int, month: int -) -> Tuple[datetime, datetime]: +) -> Dict[str, str]: first_day_of_month = 1 start_date = datetime(year=year, month=month, day=first_day_of_month) @@ -340,4 +340,8 @@ def get_date_range_of_month( second=59, microsecond=999999 ) - return start_date, end_date + + return { + 'start_date': datetime_str(start_date), + 'end_date': datetime_str(end_date) + } 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 19a7561b..35140fee 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 @@ -10,6 +10,7 @@ from commons.data_access_layer.cosmos_db import current_datetime, \ current_datetime_str, get_date_range_of_month, get_current_month, \ get_current_year, datetime_str +from commons.data_access_layer.database import EventContext from time_tracker_api.time_entries.time_entries_model import TimeEntriesCosmosDBDao fake = Faker() @@ -449,7 +450,6 @@ def test_create_with_valid_uuid_format_should_return_created(client: FlaskClient def test_find_all_is_called_with_generated_dates(client: FlaskClient, mocker: MockFixture, valid_header: dict, - tenant_id: str, owner_id: str, url: str, month: int, @@ -459,23 +459,15 @@ def test_find_all_is_called_with_generated_dates(client: FlaskClient, 'find_all', return_value=fake_time_entry) - response = client.get(url, - headers=valid_header, - follow_redirects=True) - - start_date, end_date = get_date_range_of_month(year, month) - custom_args = { - 'start_date': datetime_str(start_date), - 'end_date': datetime_str(end_date) - } + response = client.get(url, headers=valid_header, follow_redirects=True) + date_range = get_date_range_of_month(year, month) conditions = { 'owner_id': owner_id } assert HTTPStatus.OK == response.status_code assert json.loads(response.data) is not None - repository_find_all_mock.assert_called_once_with(partition_key_value=tenant_id, - conditions=conditions, - custom_args=custom_args) - + repository_find_all_mock.assert_called_once_with(ANY, + conditions=conditions, + date_range=date_range) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 32d97c89..10cd7989 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -221,25 +221,7 @@ def get_all(self, conditions: dict = {}) -> list: event_ctx = self.create_event_context("read-many") conditions.update({"owner_id": event_ctx.user_id}) - if 'month' and 'year' in conditions: - month = int(conditions.get("month")) - year = int(conditions.get("year")) - conditions.pop('month') - conditions.pop('year') - elif 'month' in conditions: - month = int(conditions.get("month")) - year = get_current_year() - conditions.pop('month') - else: - month = get_current_month() - year = get_current_year() - - start_date, end_date = get_date_range_of_month(year, month) - - date_range = { - 'start_date': start_date.isoformat(), - 'end_date': end_date.isoformat(), - } + date_range = self.handle_date_filter_args(args=conditions) return self.repository.find_all(event_ctx, conditions=conditions, date_range=date_range) @@ -277,6 +259,22 @@ def find_running(self): event_ctx = self.create_event_context("find_running") return self.repository.find_running(event_ctx.tenant_id, event_ctx.user_id) + @staticmethod + def handle_date_filter_args(args: dict) -> dict: + if 'month' and 'year' in args: + month = int(args.get("month")) + year = int(args.get("year")) + args.pop('month') + args.pop('year') + elif 'month' in args: + month = int(args.get("month")) + year = get_current_year() + args.pop('month') + else: + month = get_current_month() + year = get_current_year() + return get_date_range_of_month(year, month) + def create_dao() -> TimeEntriesDao: repository = TimeEntryCosmosDBRepository() From 8d9d9b7218673e776f3844120d84aa376ea7714f Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 1 May 2020 01:01:05 +0000 Subject: [PATCH 038/361] 0.9.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 32a90a3b..e4e49b3b 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.8.0' +__version__ = '0.9.0' From 8a777ecf7ea0a71ee9efa7c4bfcc89e3a55c5afd Mon Sep 17 00:00:00 2001 From: roberto Date: Mon, 4 May 2020 18:41:32 -0500 Subject: [PATCH 039/361] fix: update migration scripts to work with the events implementation --- commons/data_access_layer/cosmos_db.py | 3 +- commons/data_access_layer/database.py | 8 ++- migrations/__init__.py | 67 +++++++++---------- .../data_access_layer/cosmos_db_test.py | 5 +- tests/conftest.py | 2 +- .../time_entries/time_entries_model_test.py | 4 +- 6 files changed, 45 insertions(+), 44 deletions(-) diff --git a/commons/data_access_layer/cosmos_db.py b/commons/data_access_layer/cosmos_db.py index 10bc685d..8ca6d8fc 100644 --- a/commons/data_access_layer/cosmos_db.py +++ b/commons/data_access_layer/cosmos_db.py @@ -220,7 +220,8 @@ def delete(self, id: str, event_context: EventContext, 'deleted': generate_uuid4() }, event_context, peeker=peeker, visible_only=True, mapper=mapper) - def delete_permanently(self, id: str, partition_key_value: str) -> None: + def delete_permanently(self, id: str, event_context: EventContext) -> None: + partition_key_value = self.find_partition_key_value(event_context) self.container.delete_item(id, partition_key_value) def find_partition_key_value(self, event_context: EventContext): diff --git a/commons/data_access_layer/database.py b/commons/data_access_layer/database.py index 7de8cdd4..c1497b82 100644 --- a/commons/data_access_layer/database.py +++ b/commons/data_access_layer/database.py @@ -35,13 +35,15 @@ def delete(self, id): class EventContext(): def __init__(self, container_id: str, action: str, description: str = None, - user_id: str = None, tenant_id: str = None, session_id: str = None): + user_id: str = None, tenant_id: str = None, session_id: str = None, + app_id: str = None): self._container_id = container_id self._action = action self._description = description self._user_id = user_id self._tenant_id = tenant_id self._session_id = session_id + self._app_id = app_id @property def container_id(self): @@ -66,3 +68,7 @@ def tenant_id(self): @property def session_id(self): return self._session_id + + @property + def app_id(self): + return self._app_id diff --git a/migrations/__init__.py b/migrations/__init__.py index ece224aa..60334177 100644 --- a/migrations/__init__.py +++ b/migrations/__init__.py @@ -2,40 +2,14 @@ from migrate_anything import configure from migrate_anything.storage import Storage -from time_tracker_api import create_app - +from commons.data_access_layer.database import EventContext +from commons.data_access_layer.cosmos_db import cosmos_helper, init_app, \ + CosmosDBRepository -class CustomStorage(object): - def __init__(self, file): - self.file = file - - def save_migration(self, name, code): - with open(self.file, "a", encoding="utf-8") as file: - file.write("{},{}\n".format(name, code)) - - def list_migrations(self): - try: - with open(self.file, encoding="utf-8") as file: - return [ - line.split(",") - for line in file.readlines() - if line.strip() # Skip empty lines - ] - except FileNotFoundError: - return [] - - def remove_migration(self, name): - migrations = [ - migration for migration in self.list_migrations() if migration[0] != name - ] - - with open(self.file, "w", encoding="utf-8") as file: - for row in migrations: - file.write("{},{}\n".format(*row)) +from time_tracker_api import create_app app = create_app('time_tracker_api.config.CLIConfig') -from commons.data_access_layer.cosmos_db import cosmos_helper, init_app, CosmosDBRepository if cosmos_helper is None: init_app(app) @@ -59,19 +33,38 @@ def __init__(self, collection_id, app_id): self.repository = CosmosDBRepository.from_definition(migrations_definition) def save_migration(self, name, code): - self.repository.create({"id": name, - "name": name, - "code": code, - "app_id": self.app_id}) + event_ctx = self.create_event_context('create') + self.repository.create( + data={ + "id": name, + "name": name, + "code": code, + "app_id": self.app_id + }, + event_context=event_ctx, + ) def list_migrations(self): - migrations = self.repository.find_all(self.app_id) + event_ctx = self.create_event_context('read-many') + migrations = self.repository.find_all(event_context=event_ctx) return [ [item['name'], item['code']] for item in migrations ] def remove_migration(self, name): - self.repository.delete_permanently(name, self.app_id) - + event_ctx = self.create_event_context('delete-permanently') + self.repository.delete_permanently(id=name, event_context=event_ctx) + + def create_event_context( + self, + action: str = None, + description: str = None + ) -> EventContext: + return EventContext( + container_id=self.collection_id, + action=action, + description=description, + app_id=self.app_id + ) configure(storage=CosmosDBStorage("migration", "time-tracker-api")) diff --git a/tests/commons/data_access_layer/cosmos_db_test.py b/tests/commons/data_access_layer/cosmos_db_test.py index 47fdadd3..dd54038a 100644 --- a/tests/commons/data_access_layer/cosmos_db_test.py +++ b/tests/commons/data_access_layer/cosmos_db_test.py @@ -510,9 +510,10 @@ def test_partial_update_should_not_find_element_that_is_already_deleted(cosmos_d def test_delete_permanently_with_invalid_id_should_fail(cosmos_db_repository: CosmosDBRepository, + event_context: EventContext, tenant_id: str): try: - cosmos_db_repository.delete_permanently(fake.uuid4(), tenant_id) + cosmos_db_repository.delete_permanently(fake.uuid4(), event_context) fail('It should have not found the deleted item') except Exception as e: assert type(e) is CosmosResourceNotFoundError @@ -527,7 +528,7 @@ def test_delete_permanently_with_valid_id_should_succeed(cosmos_db_repository: C assert found_item is not None assert found_item['id'] == sample_item['id'] - cosmos_db_repository.delete_permanently(sample_item['id'], event_context.tenant_id) + cosmos_db_repository.delete_permanently(sample_item['id'], event_context) try: cosmos_db_repository.find(sample_item['id'], event_context) diff --git a/tests/conftest.py b/tests/conftest.py index 4d833e56..362df371 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -175,7 +175,7 @@ def running_time_entry(time_entry_repository: TimeEntryCosmosDBRepository, yield created_time_entry time_entry_repository.delete_permanently(id=created_time_entry.id, - partition_key_value=tenant_id) + event_context=event_context) @pytest.fixture(scope="session") 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 e1294852..78a463f9 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 @@ -56,7 +56,7 @@ def test_find_interception_with_date_range_should_find(start_date: datetime, assert len(result) > 0 assert any([existing_item.id == item.id for item in result]) finally: - time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id) + 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, @@ -79,7 +79,7 @@ def test_find_interception_should_ignore_id_of_existing_item(owner_id: str, tena non_colliding_result is not None assert not any([existing_item.id == item.id for item in non_colliding_result]) finally: - time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id) + time_entry_repository.delete_permanently(existing_item.id, event_ctx) def test_find_running_should_return_running_time_entry(running_time_entry, From 1caf8e695764bd404f72f6069824722e0cbb7f44 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Tue, 5 May 2020 14:39:55 +0000 Subject: [PATCH 040/361] 0.9.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 e4e49b3b..8969d496 100644 --- a/time_tracker_api/version.py +++ b/time_tracker_api/version.py @@ -1 +1 @@ -__version__ = '0.9.0' +__version__ = '0.9.1' From e1ed69331d5cde9a97475b1c0daebce4fdff7418 Mon Sep 17 00:00:00 2001 From: roberto Date: Tue, 5 May 2020 17:52:40 -0500 Subject: [PATCH 041/361] fix(time-entries): add owner_id in data when create time-entry --- .../time_entries/time_entries_model.py | 199 ++++++++++++------ 1 file changed, 136 insertions(+), 63 deletions(-) diff --git a/time_tracker_api/time_entries/time_entries_model.py b/time_tracker_api/time_entries/time_entries_model.py index 10cd7989..31044f55 100644 --- a/time_tracker_api/time_entries/time_entries_model.py +++ b/time_tracker_api/time_entries/time_entries_model.py @@ -5,8 +5,16 @@ from azure.cosmos import PartitionKey from flask_restplus._http import HTTPStatus -from commons.data_access_layer.cosmos_db import CosmosDBDao, CosmosDBRepository, CustomError, current_datetime_str, \ - CosmosDBModel, get_date_range_of_month, get_current_year, get_current_month +from commons.data_access_layer.cosmos_db import ( + CosmosDBDao, + CosmosDBRepository, + CustomError, + current_datetime_str, + CosmosDBModel, + get_date_range_of_month, + get_current_year, + get_current_month, +) from commons.data_access_layer.database import EventContext from time_tracker_api.database import CRUDDao, APICosmosDBDao from time_tracker_api.security import current_user_id @@ -34,10 +42,8 @@ def restart(self, id: str): 'id': 'time_entry', 'partition_key': PartitionKey(path='/tenant_id'), 'unique_key_policy': { - 'uniqueKeys': [ - {'paths': ['/owner_id', '/end_date', '/deleted']}, - ] - } + 'uniqueKeys': [{'paths': ['/owner_id', '/end_date', '/deleted']}] + }, } @@ -66,15 +72,20 @@ def __repr__(self): return '