Skip to content

Commit d23529e

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

File tree

10 files changed

+443
-163
lines changed

10 files changed

+443
-163
lines changed

README.md

Lines changed: 8 additions & 29 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

@@ -46,35 +47,6 @@ automatically [pip](https://pip.pypa.io/en/stable/) as well.
4647
4748
Remember to do it with Python 3.
4849
49-
50-
- Install the [Microsoft ODBC Driver for SQL Server](https://docs.microsoft.com/en-us/sql/connect/odbc/microsoft-odbc-driver-for-sql-server?view=sql-server-ver15)
51-
in your operative system. Then you have to check out what is the name of the SQL Driver installation.
52-
Check it out with:
53-
54-
```bash
55-
vim /usr/local/etc/odbcinst.ini
56-
```
57-
58-
It may display something like
59-
60-
```.ini
61-
[ODBC Driver 17 for SQL Server]
62-
Description=Microsoft ODBC Driver 17 for SQL Server
63-
Driver=/usr/local/lib/libmsodbcsql.17.dylib
64-
UsageCount=2
65-
```
66-
67-
Then specify the driver name, in this case _DBC Driver 17 for SQL Server_ in the `SQL_DATABASE_URI`, e.g.:
68-
69-
```.dotenv
70-
SQL_DATABASE_URI=mssql+pyodbc://<user>:<password>@time-tracker-srv.database.windows.net/<database>?driver\=ODBC Driver 17 for SQL Server
71-
```
72-
73-
To troubleshoot issues regarding this part please check out:
74-
- [Install the Microsoft ODBC driver for SQL Server (macOS)](https://docs.microsoft.com/en-us/sql/connect/odbc/linux-mac/install-microsoft-odbc-driver-sql-server-macos?view=sql-server-ver15).
75-
- Github issue [odbcinst: SQLRemoveDriver failed with Unable to find component name](https://github.com/Microsoft/homebrew-mssql-preview/issues/2).
76-
- Stack overflow solution to [Can't open lib 'ODBC Driver 13 for SQL Server'? Sym linking issue?](https://stackoverflow.com/questions/44527452/cant-open-lib-odbc-driver-13-for-sql-server-sym-linking-issue).
77-
7850
### How to use it
7951
- Set the env var `FLASK_APP` to `time_tracker_api` and start the app:
8052
@@ -93,6 +65,13 @@ To troubleshoot issues regarding this part please check out:
9365
a link to the swagger.json with the definition of the api.
9466
9567
68+
### Important notes
69+
Due to the used technology and particularities on the implementation of this API, it is important that you respect the
70+
following notes regarding to the manipulation of the data from and towards the API:
71+
72+
- The [recommended](https://docs.microsoft.com/en-us/azure/cosmos-db/working-with-dates#storing-datetimes) format for
73+
DateTime strings in Azure Cosmos DB is `YYYY-MM-DDThh:mm:ss.fffffffZ` which follows the ISO 8601 **UTC standard**.
74+
9675
## Development
9776
9877
### Test

commons/data_access_layer/cosmos_db.py

Lines changed: 72 additions & 23 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,29 @@ 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 'AND NOT IS_DEFINED(%s.deleted)' % container_name
105+
return ''
106+
107+
@staticmethod
108+
def create_sql_condition_for_owner_id(owner_id: str, container_name='c') -> str:
109+
if owner_id:
110+
return 'AND %s.owner_id=@owner_id' % container_name
111+
return ''
112+
113+
@staticmethod
114+
def check_visibility(item, throw_not_found_if_deleted):
115+
if throw_not_found_if_deleted and item.get('deleted') is not None:
116+
raise exceptions.CosmosResourceNotFoundError(message='Deleted item',
117+
status_code=404)
118+
119+
return item
120+
96121
def create(self, data: dict, mapper: Callable = None):
122+
self.on_create(data)
97123
function_mapper = self.get_mapper_or_dict(mapper)
98124
return function_mapper(self.container.create_item(body=data))
99125

@@ -102,20 +128,24 @@ def find(self, id: str, partition_key_value, visible_only=True, mapper: Callable
102128
function_mapper = self.get_mapper_or_dict(mapper)
103129
return function_mapper(self.check_visibility(found_item, visible_only))
104130

105-
def find_all(self, partition_key_value: str, max_count=None, offset=0,
131+
def find_all(self, partition_key_value: str, owner_id=None, max_count=None, offset=0,
106132
visible_only=True, mapper: Callable = None):
107133
# TODO Use the tenant_id param and change container alias
108134
max_count = self.get_page_size_or(max_count)
109135
result = self.container.query_items(
110136
query="""
111-
SELECT * FROM c WHERE c.{partition_key_attribute}=@partition_key_value AND {visibility_condition}
137+
SELECT * FROM c WHERE c.{partition_key_attribute}=@partition_key_value
138+
{owner_condition} {visibility_condition} {order_clause}
112139
OFFSET @offset LIMIT @max_count
113140
""".format(partition_key_attribute=self.partition_key_attribute,
114-
visibility_condition=self.create_sql_condition_for_visibility(visible_only)),
141+
visibility_condition=self.create_sql_condition_for_visibility(visible_only),
142+
owner_condition=self.create_sql_condition_for_owner_id(owner_id),
143+
order_clause=self.create_sql_order_clause()),
115144
parameters=[
116145
{"name": "@partition_key_value", "value": partition_key_value},
117146
{"name": "@offset", "value": offset},
118147
{"name": "@max_count", "value": max_count},
148+
{"name": "@owner_id", "value": owner_id},
119149
],
120150
partition_key=partition_key_value,
121151
max_item_count=max_count)
@@ -130,6 +160,7 @@ def partial_update(self, id: str, changes: dict, partition_key_value: str,
130160
return self.update(id, item_data, mapper=mapper)
131161

132162
def update(self, id: str, item_data: dict, mapper: Callable = None):
163+
self.on_update(item_data)
133164
function_mapper = self.get_mapper_or_dict(mapper)
134165
return function_mapper(self.container.replace_item(id, body=item_data))
135166

@@ -141,19 +172,6 @@ def delete(self, id: str, partition_key_value: str, mapper: Callable = None):
141172
def delete_permanently(self, id: str, partition_key_value: str) -> None:
142173
self.container.delete_item(id, partition_key_value)
143174

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-
157175
def get_mapper_or_dict(self, alternative_mapper: Callable) -> Callable:
158176
return alternative_mapper or self.mapper or dict
159177

@@ -162,22 +180,40 @@ def get_page_size_or(self, custom_page_size: int) -> int:
162180
# or any other repository for the settings
163181
return custom_page_size or 100
164182

183+
def on_create(self, new_item_data: dict):
184+
if new_item_data.get('id') is None:
185+
new_item_data['id'] = str(uuid.uuid4())
186+
187+
def on_update(self, update_item_data: dict):
188+
pass
189+
190+
def create_sql_order_clause(self):
191+
if len(self.order_fields) > 0:
192+
return "ORDER BY c.{}".format(", c.".join(self.order_fields))
193+
else:
194+
return ""
195+
165196

166197
class CosmosDBDao(CRUDDao):
167198
def __init__(self, repository: CosmosDBRepository):
168199
self.repository = repository
169200

201+
@property
202+
def partition_key_value(self):
203+
return current_user_tenant_id()
204+
170205
def get_all(self) -> list:
171206
tenant_id: str = self.partition_key_value
172-
return self.repository.find_all(partition_key_value=tenant_id)
207+
owner_id = current_user_id()
208+
return self.repository.find_all(partition_key_value=tenant_id, owner_id=owner_id)
173209

174210
def get(self, id):
175211
tenant_id: str = self.partition_key_value
176212
return self.repository.find(id, partition_key_value=tenant_id)
177213

178214
def create(self, data: dict):
179-
data['id'] = str(uuid.uuid4())
180-
data['tenant_id'] = self.partition_key_value
215+
data[self.repository.partition_key_attribute] = self.partition_key_value
216+
data['owner_id'] = current_user_id()
181217
return self.repository.create(data)
182218

183219
def update(self, id, data: dict):
@@ -189,9 +225,22 @@ def delete(self, id):
189225
tenant_id: str = current_user_tenant_id()
190226
self.repository.delete(id, partition_key_value=tenant_id)
191227

192-
@property
193-
def partition_key_value(self):
194-
return current_user_tenant_id()
228+
229+
class CustomError(HTTPException):
230+
def __init__(self, status_code: int, description: str = None):
231+
self.code = status_code
232+
self.description = description
233+
234+
235+
def current_datetime():
236+
return datetime.utcnow()
237+
238+
239+
def datetime_str(value: datetime):
240+
if value is not None:
241+
return value.isoformat()
242+
else:
243+
return None
195244

196245

197246
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: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from commons.data_access_layer.cosmos_db import CosmosDBRepository
77
from time_tracker_api import create_app
8+
from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository
89

910
fake = Faker()
1011
Faker.seed()
@@ -96,7 +97,12 @@ def tenant_id() -> str:
9697

9798

9899
@pytest.fixture(scope="session")
99-
def another_tenant_id() -> str:
100+
def another_tenant_id(tenant_id) -> str:
101+
return tenant_id[:-5] + 'fffff'
102+
103+
104+
@pytest.fixture(scope="session")
105+
def owner_id() -> str:
100106
return fake.uuid4()
101107

102108

@@ -109,3 +115,8 @@ def sample_item(cosmos_db_repository: CosmosDBRepository, tenant_id: str) -> dic
109115
tenant_id=tenant_id)
110116

111117
return cosmos_db_repository.create(sample_item_data)
118+
119+
120+
@pytest.yield_fixture(scope="module")
121+
def time_entry_repository(cosmos_db_repository: CosmosDBRepository) -> TimeEntryCosmosDBRepository:
122+
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: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
try:
46+
result = time_entry_repository.find_interception_with_date_range(datetime_str(yesterday), datetime_str(now),
47+
owner_id=owner_id,
48+
partition_key_value=tenant_id)
49+
50+
assert result is not None
51+
assert len(result) >= 0
52+
assert any([existing_item.id == item.id for item in result])
53+
finally:
54+
time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id)
55+
56+
def test_find_interception_should_ignore_id_of_existing_item(owner_id: str,
57+
tenant_id: str,
58+
time_entry_repository: TimeEntryCosmosDBRepository):
59+
start_date = datetime_str(yesterday)
60+
end_date = datetime_str(now)
61+
existing_item = create_time_entry(yesterday, now, owner_id, tenant_id, time_entry_repository)
62+
try:
63+
64+
colliding_result = time_entry_repository.find_interception_with_date_range(start_date, end_date,
65+
owner_id=owner_id,
66+
partition_key_value=tenant_id)
67+
68+
non_colliding_result = time_entry_repository.find_interception_with_date_range(start_date, end_date,
69+
owner_id=owner_id,
70+
partition_key_value=tenant_id,
71+
ignore_id=existing_item.id)
72+
73+
colliding_result is not None
74+
assert any([existing_item.id == item.id for item in colliding_result])
75+
76+
non_colliding_result is not None
77+
assert not any([existing_item.id == item.id for item in non_colliding_result])
78+
finally:
79+
time_entry_repository.delete_permanently(existing_item.id, partition_key_value=existing_item.tenant_id)
80+

0 commit comments

Comments
 (0)