Skip to content

Commit c4c8058

Browse files
author
EliuX
committed
Close #58 Create time entry model for Cosmos DB
1 parent 3a3ae47 commit c4c8058

File tree

10 files changed

+412
-134
lines changed

10 files changed

+412
-134
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This is the mono-repository for the backend services and their common codebase
99
## Getting started
1010
Follow the following instructions to get the project ready to use ASAP.
1111

12+
1213
### Requirements
1314
Be sure you have installed in your system
1415

@@ -93,6 +94,18 @@ To troubleshoot issues regarding this part please check out:
9394
a link to the swagger.json with the definition of the api.
9495
9596
97+
### Important notes
98+
Due to the used technology and particularities on the implementation of this API, it is important that you respect the
99+
following notes regarding to the manipulation of the data from and towards the API:
100+
101+
- The [recommended](https://docs.microsoft.com/en-us/azure/cosmos-db/working-with-dates#storing-datetimes) format for
102+
DateTime strings in Azure Cosmos DB is `YYYY-MM-DDThh:mm:ss.fffffffZ` which follows the ISO 8601 **UTC standard**.
103+
- You may want to delete permanently the time entries, because they may interfere with the constraints of its container.
104+
For instance, if a user starts a new time entry the `end_date` will be `null`. If they remove that active time entry
105+
without closing it and starts all over again this new time entry will also have `end_date` with `null`. In scenarios
106+
like this one the user will get from the server a Conflict error (409). Therefore, it was enable the possibility to
107+
remove a time entry permanently by specifying `delete_permanently` to `true` as a url parameter (query string).
108+
96109
## Development
97110
98111
### Test

commons/data_access_layer/cosmos_db.py

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import dataclasses
22
import logging
33
import uuid
4+
from datetime import datetime
45
from typing import Callable
56

67
import azure.cosmos.cosmos_client as cosmos_client
78
import azure.cosmos.exceptions as exceptions
89
from azure.cosmos import ContainerProxy, PartitionKey
910
from flask import Flask
11+
from werkzeug.exceptions import HTTPException
1012

1113
from commons.data_access_layer.database import CRUDDao
12-
from time_tracker_api.security import current_user_tenant_id
14+
from time_tracker_api.security import current_user_tenant_id, current_user_id
1315

1416

1517
class CosmosDBFacade:
@@ -75,12 +77,14 @@ class CosmosDBRepository:
7577
def __init__(self, container_id: str,
7678
partition_key_attribute: str,
7779
mapper: Callable = None,
80+
order_fields: list = [],
7881
custom_cosmos_helper: CosmosDBFacade = None):
7982
global cosmos_helper
8083
self.cosmos_helper = custom_cosmos_helper or cosmos_helper
8184
if self.cosmos_helper is None: # pragma: no cover
8285
raise ValueError("The cosmos_db module has not been initialized!")
8386
self.mapper = mapper
87+
self.order_fields = order_fields
8488
self.container: ContainerProxy = self.cosmos_helper.db.get_container_client(container_id)
8589
self.partition_key_attribute: str = partition_key_attribute
8690

@@ -93,7 +97,23 @@ def from_definition(cls, container_definition: dict,
9397
mapper=mapper,
9498
custom_cosmos_helper=custom_cosmos_helper)
9599

100+
@staticmethod
101+
def create_sql_condition_for_visibility(visible_only: bool, container_name='c') -> str:
102+
if visible_only:
103+
# We are considering that `deleted == null` is not a choice
104+
return 'NOT IS_DEFINED(%s.deleted)' % container_name
105+
return 'true'
106+
107+
@staticmethod
108+
def check_visibility(item, throw_not_found_if_deleted):
109+
if throw_not_found_if_deleted and item.get('deleted') is not None:
110+
raise exceptions.CosmosResourceNotFoundError(message='Deleted item',
111+
status_code=404)
112+
113+
return item
114+
96115
def create(self, data: dict, mapper: Callable = None):
116+
self.on_create(data)
97117
function_mapper = self.get_mapper_or_dict(mapper)
98118
return function_mapper(self.container.create_item(body=data))
99119

@@ -108,10 +128,12 @@ def find_all(self, partition_key_value: str, max_count=None, offset=0,
108128
max_count = self.get_page_size_or(max_count)
109129
result = self.container.query_items(
110130
query="""
111-
SELECT * FROM c WHERE c.{partition_key_attribute}=@partition_key_value AND {visibility_condition}
131+
SELECT * FROM c WHERE c.{partition_key_attribute}=@partition_key_value
132+
AND {visibility_condition} {order_clause}
112133
OFFSET @offset LIMIT @max_count
113134
""".format(partition_key_attribute=self.partition_key_attribute,
114-
visibility_condition=self.create_sql_condition_for_visibility(visible_only)),
135+
visibility_condition=self.create_sql_condition_for_visibility(visible_only),
136+
order_clause=self.create_sql_order_clause()),
115137
parameters=[
116138
{"name": "@partition_key_value", "value": partition_key_value},
117139
{"name": "@offset", "value": offset},
@@ -130,6 +152,7 @@ def partial_update(self, id: str, changes: dict, partition_key_value: str,
130152
return self.update(id, item_data, mapper=mapper)
131153

132154
def update(self, id: str, item_data: dict, mapper: Callable = None):
155+
self.on_update(item_data)
133156
function_mapper = self.get_mapper_or_dict(mapper)
134157
return function_mapper(self.container.replace_item(id, body=item_data))
135158

@@ -141,19 +164,6 @@ def delete(self, id: str, partition_key_value: str, mapper: Callable = None):
141164
def delete_permanently(self, id: str, partition_key_value: str) -> None:
142165
self.container.delete_item(id, partition_key_value)
143166

144-
def check_visibility(self, item, throw_not_found_if_deleted):
145-
if throw_not_found_if_deleted and item.get('deleted') is not None:
146-
raise exceptions.CosmosResourceNotFoundError(message='Deleted item',
147-
status_code=404)
148-
149-
return item
150-
151-
def create_sql_condition_for_visibility(self, visible_only: bool, container_name='c') -> str:
152-
if visible_only:
153-
# We are considering that `deleted == null` is not a choice
154-
return 'NOT IS_DEFINED(%s.deleted)' % container_name
155-
return 'true'
156-
157167
def get_mapper_or_dict(self, alternative_mapper: Callable) -> Callable:
158168
return alternative_mapper or self.mapper or dict
159169

@@ -162,11 +172,28 @@ def get_page_size_or(self, custom_page_size: int) -> int:
162172
# or any other repository for the settings
163173
return custom_page_size or 100
164174

175+
def on_create(self, new_item_data: dict):
176+
if new_item_data.get('id') is None:
177+
new_item_data['id'] = str(uuid.uuid4())
178+
179+
def on_update(self, update_item_data: dict):
180+
pass
181+
182+
def create_sql_order_clause(self):
183+
if len(self.order_fields) > 0:
184+
return "ORDER BY c.{}".format(", c.".join(self.order_fields))
185+
else:
186+
return ""
187+
165188

166189
class CosmosDBDao(CRUDDao):
167190
def __init__(self, repository: CosmosDBRepository):
168191
self.repository = repository
169192

193+
@property
194+
def partition_key_value(self):
195+
return current_user_tenant_id()
196+
170197
def get_all(self) -> list:
171198
tenant_id: str = self.partition_key_value
172199
return self.repository.find_all(partition_key_value=tenant_id)
@@ -176,8 +203,8 @@ def get(self, id):
176203
return self.repository.find(id, partition_key_value=tenant_id)
177204

178205
def create(self, data: dict):
179-
data['id'] = str(uuid.uuid4())
180-
data['tenant_id'] = self.partition_key_value
206+
data[self.repository.partition_key_attribute] = self.partition_key_value
207+
data['owner_id'] = current_user_id()
181208
return self.repository.create(data)
182209

183210
def update(self, id, data: dict):
@@ -189,9 +216,22 @@ def delete(self, id):
189216
tenant_id: str = current_user_tenant_id()
190217
self.repository.delete(id, partition_key_value=tenant_id)
191218

192-
@property
193-
def partition_key_value(self):
194-
return current_user_tenant_id()
219+
220+
class CustomError(HTTPException):
221+
def __init__(self, status_code: int, description: str = None):
222+
self.code = status_code
223+
self.description = description
224+
225+
226+
def current_datetime():
227+
return datetime.utcnow()
228+
229+
230+
def datetime_str(value: datetime):
231+
if value is not None:
232+
return value.isoformat()
233+
else:
234+
return None
195235

196236

197237
def init_app(app: Flask) -> None:

requirements/azure_cosmos.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,7 @@ idna==2.8
1111
six==1.13.0
1212
urllib3==1.25.7
1313
virtualenv==16.7.9
14-
virtualenv-clone==0.5.3
14+
virtualenv-clone==0.5.3
15+
16+
# Dataclasses
17+
dataclasses==0.6

tests/conftest.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
from commons.data_access_layer.cosmos_db import CosmosDBRepository
77
from time_tracker_api import create_app
8+
from time_tracker_api.security import current_user_tenant_id, current_user_id
9+
from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository
810

911
fake = Faker()
1012
Faker.seed()
@@ -92,11 +94,16 @@ def cosmos_db_repository(app: Flask, cosmos_db_model) -> CosmosDBRepository:
9294

9395
@pytest.fixture(scope="session")
9496
def tenant_id() -> str:
95-
return fake.uuid4()
97+
return current_user_tenant_id()
98+
99+
100+
@pytest.fixture(scope="session")
101+
def another_tenant_id(tenant_id) -> str:
102+
return str(tenant_id) + "2"
96103

97104

98105
@pytest.fixture(scope="session")
99-
def another_tenant_id() -> str:
106+
def owner_id() -> str:
100107
return fake.uuid4()
101108

102109

@@ -109,3 +116,8 @@ def sample_item(cosmos_db_repository: CosmosDBRepository, tenant_id: str) -> dic
109116
tenant_id=tenant_id)
110117

111118
return cosmos_db_repository.create(sample_item_data)
119+
120+
121+
@pytest.yield_fixture(scope="module")
122+
def time_entry_repository(cosmos_db_repository: CosmosDBRepository) -> TimeEntryCosmosDBRepository:
123+
return TimeEntryCosmosDBRepository()

tests/time_tracker_api/smoke_test.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44
from flask_restplus._http import HTTPStatus
55
from pytest_mock import MockFixture
66

7-
unexpected_errors_to_be_handled = [CosmosHttpResponseError, CosmosResourceNotFoundError, CosmosResourceExistsError]
7+
from commons.data_access_layer.cosmos_db import CustomError
8+
9+
unexpected_errors_to_be_handled = [CustomError(HTTPStatus.BAD_REQUEST, "Anything"),
10+
CosmosHttpResponseError, CosmosResourceNotFoundError,
11+
CosmosResourceExistsError, AttributeError]
812

913

1014
def test_app_exists(app):
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from datetime import datetime, timedelta
2+
3+
import pytest
4+
from faker import Faker
5+
6+
from commons.data_access_layer.cosmos_db import current_datetime, datetime_str
7+
from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository, TimeEntryCosmosDBModel
8+
9+
fake = Faker()
10+
11+
now = current_datetime()
12+
yesterday = current_datetime() - timedelta(days=1)
13+
two_days_ago = current_datetime() - timedelta(days=2)
14+
15+
16+
def create_time_entry(start_date: datetime,
17+
end_date: datetime,
18+
owner_id: str,
19+
tenant_id: str,
20+
time_entry_repository: TimeEntryCosmosDBRepository) -> TimeEntryCosmosDBModel:
21+
data = {
22+
"project_id": fake.uuid4(),
23+
"activity_id": fake.uuid4(),
24+
"description": fake.paragraph(nb_sentences=2),
25+
"start_date": datetime_str(start_date),
26+
"end_date": datetime_str(end_date),
27+
"owner_id": owner_id,
28+
"tenant_id": tenant_id
29+
}
30+
31+
created_item = time_entry_repository.create(data, mapper=TimeEntryCosmosDBModel)
32+
return created_item
33+
34+
35+
@pytest.mark.parametrize(
36+
'start_date,end_date', [(two_days_ago, yesterday), (now, None)]
37+
)
38+
def test_find_interception_with_date_range_should_find(start_date: datetime,
39+
end_date: datetime,
40+
owner_id: str,
41+
tenant_id: str,
42+
time_entry_repository: TimeEntryCosmosDBRepository):
43+
existing_item = create_time_entry(start_date, end_date, owner_id, tenant_id, time_entry_repository)
44+
45+
result = time_entry_repository.find_interception_with_date_range(datetime_str(yesterday), datetime_str(now),
46+
owner_id=owner_id,
47+
partition_key_value=tenant_id)
48+
49+
time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id)
50+
51+
assert result is not None
52+
assert len(result) >= 0
53+
assert any([existing_item.id == item.id for item in result])

0 commit comments

Comments
 (0)