Skip to content

Commit 8849a2d

Browse files
authored
Merge pull request #76 from ioet/feature/replace-sql-models-for-cosmosdb-ones
Create models in Cosmos DB SQL API for new DAL
2 parents 6571f32 + d23529e commit 8849a2d

33 files changed

+1173
-463
lines changed

README.md

Lines changed: 12 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
# time-tracker-api
22

3+
[![Build status](https://dev.azure.com/IOET-DevOps/TimeTracker-API/_apis/build/status/TimeTracker-API%20-%20CI)](https://dev.azure.com/IOET-DevOps/TimeTracker-API/_build/latest?definitionId=1)
4+
35
This is the mono-repository for the backend services and their common codebase
46

7+
8+
59
## Getting started
610
Follow the following instructions to get the project ready to use ASAP.
711

12+
813
### Requirements
914
Be sure you have installed in your system
1015

@@ -42,35 +47,6 @@ automatically [pip](https://pip.pypa.io/en/stable/) as well.
4247
4348
Remember to do it with Python 3.
4449
45-
46-
- 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)
47-
in your operative system. Then you have to check out what is the name of the SQL Driver installation.
48-
Check it out with:
49-
50-
```bash
51-
vim /usr/local/etc/odbcinst.ini
52-
```
53-
54-
It may display something like
55-
56-
```.ini
57-
[ODBC Driver 17 for SQL Server]
58-
Description=Microsoft ODBC Driver 17 for SQL Server
59-
Driver=/usr/local/lib/libmsodbcsql.17.dylib
60-
UsageCount=2
61-
```
62-
63-
Then specify the driver name, in this case _DBC Driver 17 for SQL Server_ in the `SQL_DATABASE_URI`, e.g.:
64-
65-
```.dotenv
66-
SQL_DATABASE_URI=mssql+pyodbc://<user>:<password>@time-tracker-srv.database.windows.net/<database>?driver\=ODBC Driver 17 for SQL Server
67-
```
68-
69-
To troubleshoot issues regarding this part please check out:
70-
- [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).
71-
- Github issue [odbcinst: SQLRemoveDriver failed with Unable to find component name](https://github.com/Microsoft/homebrew-mssql-preview/issues/2).
72-
- 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).
73-
7450
### How to use it
7551
- Set the env var `FLASK_APP` to `time_tracker_api` and start the app:
7652
@@ -89,6 +65,13 @@ To troubleshoot issues regarding this part please check out:
8965
a link to the swagger.json with the definition of the api.
9066
9167
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+
9275
## Development
9376
9477
### Test

cli.py

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,23 +46,6 @@ def gen_postman_collection(filename='timetracker-api-postman-collection.json',
4646
save_data(parsed_json, filename)
4747

4848

49-
@cli_manager.command
50-
def seed():
51-
from time_tracker_api.database import seeder as seed
52-
seed()
53-
54-
55-
@cli_manager.command
56-
def re_create_db():
57-
print('This is going to drop all tables and seed again the database')
58-
confirm_answer = input('Do you confirm (Y) you want to remove all your data?\n')
59-
if confirm_answer.upper() == 'Y':
60-
from time_tracker_api.database import seeder
61-
seeder.fresh()
62-
else:
63-
print('\nThis action was cancelled!')
64-
65-
6649
def save_data(data: str, filename: str) -> None:
6750
""" Save text content to a file """
6851
if filename:

commons/data_access_layer/cosmos_db.py

Lines changed: 100 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +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
12+
13+
from commons.data_access_layer.database import CRUDDao
14+
from time_tracker_api.security import current_user_tenant_id, current_user_id
1015

1116

1217
class CosmosDBFacade:
@@ -72,12 +77,14 @@ class CosmosDBRepository:
7277
def __init__(self, container_id: str,
7378
partition_key_attribute: str,
7479
mapper: Callable = None,
80+
order_fields: list = [],
7581
custom_cosmos_helper: CosmosDBFacade = None):
7682
global cosmos_helper
7783
self.cosmos_helper = custom_cosmos_helper or cosmos_helper
7884
if self.cosmos_helper is None: # pragma: no cover
7985
raise ValueError("The cosmos_db module has not been initialized!")
8086
self.mapper = mapper
87+
self.order_fields = order_fields
8188
self.container: ContainerProxy = self.cosmos_helper.db.get_container_client(container_id)
8289
self.partition_key_attribute: str = partition_key_attribute
8390

@@ -90,7 +97,29 @@ def from_definition(cls, container_definition: dict,
9097
mapper=mapper,
9198
custom_cosmos_helper=custom_cosmos_helper)
9299

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+
93121
def create(self, data: dict, mapper: Callable = None):
122+
self.on_create(data)
94123
function_mapper = self.get_mapper_or_dict(mapper)
95124
return function_mapper(self.container.create_item(body=data))
96125

@@ -99,20 +128,24 @@ def find(self, id: str, partition_key_value, visible_only=True, mapper: Callable
99128
function_mapper = self.get_mapper_or_dict(mapper)
100129
return function_mapper(self.check_visibility(found_item, visible_only))
101130

102-
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,
103132
visible_only=True, mapper: Callable = None):
104133
# TODO Use the tenant_id param and change container alias
105134
max_count = self.get_page_size_or(max_count)
106135
result = self.container.query_items(
107136
query="""
108-
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}
109139
OFFSET @offset LIMIT @max_count
110140
""".format(partition_key_attribute=self.partition_key_attribute,
111-
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()),
112144
parameters=[
113145
{"name": "@partition_key_value", "value": partition_key_value},
114146
{"name": "@offset", "value": offset},
115147
{"name": "@max_count", "value": max_count},
148+
{"name": "@owner_id", "value": owner_id},
116149
],
117150
partition_key=partition_key_value,
118151
max_item_count=max_count)
@@ -122,11 +155,12 @@ def find_all(self, partition_key_value: str, max_count=None, offset=0,
122155

123156
def partial_update(self, id: str, changes: dict, partition_key_value: str,
124157
visible_only=True, mapper: Callable = None):
125-
item_data = self.find(id, partition_key_value, visible_only=visible_only)
158+
item_data = self.find(id, partition_key_value, visible_only=visible_only, mapper=dict)
126159
item_data.update(changes)
127160
return self.update(id, item_data, mapper=mapper)
128161

129162
def update(self, id: str, item_data: dict, mapper: Callable = None):
163+
self.on_update(item_data)
130164
function_mapper = self.get_mapper_or_dict(mapper)
131165
return function_mapper(self.container.replace_item(id, body=item_data))
132166

@@ -138,19 +172,6 @@ def delete(self, id: str, partition_key_value: str, mapper: Callable = None):
138172
def delete_permanently(self, id: str, partition_key_value: str) -> None:
139173
self.container.delete_item(id, partition_key_value)
140174

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

@@ -159,6 +180,68 @@ def get_page_size_or(self, custom_page_size: int) -> int:
159180
# or any other repository for the settings
160181
return custom_page_size or 100
161182

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+
196+
197+
class CosmosDBDao(CRUDDao):
198+
def __init__(self, repository: CosmosDBRepository):
199+
self.repository = repository
200+
201+
@property
202+
def partition_key_value(self):
203+
return current_user_tenant_id()
204+
205+
def get_all(self) -> list:
206+
tenant_id: str = self.partition_key_value
207+
owner_id = current_user_id()
208+
return self.repository.find_all(partition_key_value=tenant_id, owner_id=owner_id)
209+
210+
def get(self, id):
211+
tenant_id: str = self.partition_key_value
212+
return self.repository.find(id, partition_key_value=tenant_id)
213+
214+
def create(self, data: dict):
215+
data[self.repository.partition_key_attribute] = self.partition_key_value
216+
data['owner_id'] = current_user_id()
217+
return self.repository.create(data)
218+
219+
def update(self, id, data: dict):
220+
return self.repository.partial_update(id,
221+
changes=data,
222+
partition_key_value=self.partition_key_value)
223+
224+
def delete(self, id):
225+
tenant_id: str = current_user_tenant_id()
226+
self.repository.delete(id, partition_key_value=tenant_id)
227+
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
244+
162245

163246
def init_app(app: Flask) -> None:
164247
global cosmos_helper

time_tracker_api/database.py renamed to commons/data_access_layer/database.py

Lines changed: 5 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -35,45 +35,16 @@ def delete(self, id):
3535
raise NotImplementedError # pragma: no cover
3636

3737

38-
class Seeder(abc.ABC):
39-
@abc.abstractmethod
40-
def run(self):
41-
raise NotImplementedError # pragma: no cover
42-
43-
@abc.abstractmethod
44-
def fresh(self):
45-
raise NotImplementedError # pragma: no cover
46-
47-
def __call__(self, *args, **kwargs):
48-
self.run() # pragma: no cover
49-
50-
51-
seeder: Seeder = None
52-
53-
5438
def init_app(app: Flask) -> None:
55-
init_sql(app)
39+
init_sql(app) # TODO Delete after the migration to Cosmos DB has finished.
40+
init_cosmos_db(app)
5641

5742

5843
def init_sql(app: Flask) -> None:
59-
from commons.data_access_layer.sql import init_app, SQLSeeder
44+
from commons.data_access_layer.sql import init_app
6045
init_app(app)
61-
global seeder
62-
seeder = SQLSeeder()
6346

6447

6548
def init_cosmos_db(app: Flask) -> None:
66-
# from commons.data_access_layer.azure.cosmos_db import cosmos_helper
67-
class CosmosSeeder(Seeder):
68-
def run(self):
69-
print("Provisioning namespace(database)...")
70-
# cosmos_helper.create_container()
71-
print("Database seeded!")
72-
73-
def fresh(self):
74-
print("Removing namespace(database)...")
75-
# cosmos_helper.remove_container()
76-
self.run()
77-
78-
global seeder
79-
seeder = CosmosSeeder()
49+
from commons.data_access_layer.cosmos_db import init_app
50+
init_app(app)

commons/data_access_layer/sql.py

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from flask import Flask
44
from flask_sqlalchemy import SQLAlchemy
55

6-
from time_tracker_api.database import CRUDDao, Seeder, ID_MAX_LENGTH
6+
from commons.data_access_layer.database import CRUDDao, ID_MAX_LENGTH
77
from time_tracker_api.security import current_user_id
88

99
db: SQLAlchemy = None
@@ -14,7 +14,7 @@ def handle_commit_issues(f):
1414
def rollback_if_necessary(*args, **kw):
1515
try:
1616
return f(*args, **kw)
17-
except: # pragma: no cover
17+
except: # pragma: no cover
1818
db.session.rollback()
1919
raise
2020

@@ -90,16 +90,3 @@ def update(self, id, data: dict):
9090

9191
def delete(self, id):
9292
self.repository.remove(id)
93-
94-
95-
class SQLSeeder(Seeder): # pragma: no cover
96-
def run(self):
97-
print("Provisioning database...")
98-
db.create_all()
99-
print("Database seeded!")
100-
101-
def fresh(self):
102-
print("Removing all existing data...")
103-
db.drop_all()
104-
105-
self.run()

migrations/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,4 @@ def remove_migration(self, name):
7474
self.repository.delete_permanently(name, self.app_id)
7575

7676

77-
configure(storage=CosmosDBStorage("migrations", "time-tracker-api"))
77+
configure(storage=CosmosDBStorage("migration", "time-tracker-api"))

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

requirements/migrations.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
# For running any kind of data migration
44

55
# Migration tool
6-
migrate-anything==0.1.6
6+
migrate-anything==0.1.6

0 commit comments

Comments
 (0)