Skip to content

Commit f001ece

Browse files
authored
Merge branch 'master' into feature/get-time-entries-per-month-and-year#106
2 parents f877e0a + 682cd4b commit f001ece

File tree

48 files changed

+965
-491
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+965
-491
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,6 @@ swagger.json
3333

3434
# Local migration files
3535
migration_status.csv
36+
37+
# Mac
38+
.DS_Store

README.md

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Be sure you have installed in your system
1616
- [Python version 3](https://www.python.org/download/releases/3.0/) in your path. It will install
1717
automatically [pip](https://pip.pypa.io/en/stable/) as well.
1818
- A virtual environment, namely [venv](https://docs.python.org/3/library/venv.html).
19+
- 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).
1920

2021
### Setup
2122
- Create and activate the environment,
@@ -38,14 +39,17 @@ automatically [pip](https://pip.pypa.io/en/stable/) as well.
3839
python3 -m pip install -r requirements/<app>/<stage>.txt
3940
```
4041
41-
Where <app> is one of the executable app namespace, e.g. `time_tracker_api`.
42+
Where <app> is one of the executable app namespace, e.g. `time_tracker_api` or `time_tracker_events`.
4243
The `stage` can be
4344
4445
* `dev`: Used for working locally
4546
* `prod`: For anything deployed
4647
4748
48-
Remember to do it with Python 3.
49+
Remember to do it with Python 3.
50+
51+
Bear in mind that the requirements for `time_tracker_events`, must be located on its local requirements.txt, by
52+
[convention](https://docs.microsoft.com/en-us/azure/azure-functions/functions-reference-python#folder-structure).
4953
5054
- Run `pre-commit install`. For more details, check out Development > Git hooks.
5155
@@ -66,6 +70,40 @@ automatically [pip](https://pip.pypa.io/en/stable/) as well.
6670
- Open `http://127.0.0.1:5000/` in a browser. You will find in the presented UI
6771
a link to the swagger.json with the definition of the api.
6872
73+
#### Handling Cosmos DB triggers for creating events with time_tracker_events
74+
The project `time_tracker_events` is an Azure Function project. Its main responsibility is to respond to calls related to
75+
events, like those [triggered by Change Feed](https://docs.microsoft.com/en-us/azure/cosmos-db/change-feed-functions).
76+
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)
77+
these functions will be called. You can also run them in your local machine:
78+
79+
- You must have the [Azure CLI](https://docs.microsoft.com/en-us/cli/azure/get-started-with-azure-cli?view=azure-cli-latest)
80+
and the [Azure Functions Core Tools](https://docs.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=macos%2Ccsharp%2Cbash)
81+
installed in your local machine.
82+
- Be sure to [authenticate](https://docs.microsoft.com/en-us/cli/azure/authenticate-azure-cli?view=azure-cli-latest)
83+
with the Azure CLI if you are not.
84+
```bash
85+
az login
86+
```
87+
- Execute the project
88+
```bash
89+
cd time_tracker_events
90+
source run.sh
91+
```
92+
You will see that a large console log will appear ending with a message like
93+
```log
94+
Now listening on: http://0.0.0.0:7071
95+
Application started. Press Ctrl+C to shut down.
96+
```
97+
- Now you are ready to start generating events. Just execute any change in your API and you will see how logs are being
98+
generated by the console app you ran before. For instance, this is the log generated when I restarted a time entry:
99+
```log
100+
[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)
101+
[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
102+
[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
103+
[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": {}}
104+
[04/30/2020 14:42:12] Executed 'Functions.handle_time_entry_events_trigger' (Succeeded, Id=3da87e53-0434-4ff2-8db3-f7c051ccf9fd)
105+
```
106+
69107
### Security
70108
In this API we are requiring authenticated users using JWT. To do so, we are using the library
71109
[PyJWT](https://pypi.org/project/PyJWT/), so in every request to the API we expect a header `Authorization` with a format
@@ -99,6 +137,16 @@ following notes regarding to the manipulation of the data from and towards the A
99137
- The [recommended](https://docs.microsoft.com/en-us/azure/cosmos-db/working-with-dates#storing-datetimes) format for
100138
DateTime strings in Azure Cosmos DB is `YYYY-MM-DDThh:mm:ss.fffffffZ` which follows the ISO 8601 **UTC standard**.
101139

140+
The Azure function project `time_tracker_events` also have some constraints to have into account. It is recommended that
141+
you read the [Azure Functions Python developer guide](https://docs.microsoft.com/en-us/azure/azure-functions/functions-reference-python#folder-structure).
142+
143+
If you require to deploy `time_tracker_events` from your local machine to Azure Functions, you can execute:
144+
145+
```bash
146+
func azure functionapp publish time-tracker-events --build local
147+
```
148+
149+
102150
## Development
103151

104152
### Git hooks
@@ -254,6 +302,8 @@ the win.
254302
- [Swagger](https://swagger.io/) for documentation and standardization, taking into account the
255303
[API import restrictions and known issues](https://docs.microsoft.com/en-us/azure/api-management/api-management-api-import-restrictions)
256304
in Azure.
305+
- [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)
306+
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).
257307

258308
## License
259309

commons/data_access_layer/cosmos_db.py

Lines changed: 49 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,7 @@
1010
from flask import Flask
1111
from werkzeug.exceptions import HTTPException
1212

13-
from commons.data_access_layer.database import CRUDDao
14-
from time_tracker_api.security import current_user_tenant_id
13+
from commons.data_access_layer.database import CRUDDao, EventContext
1514

1615

1716
class CosmosDBFacade:
@@ -148,23 +147,36 @@ def replace_empty_value_per_none(item_data: dict) -> dict:
148147
if isinstance(v, str) and len(v) == 0:
149148
item_data[k] = None
150149

151-
def create(self, data: dict, mapper: Callable = None):
152-
self.on_create(data)
150+
@staticmethod
151+
def attach_context(data: dict, event_context: EventContext):
152+
data["_last_event_ctx"] = {
153+
"user_id": event_context.user_id,
154+
"tenant_id": event_context.tenant_id,
155+
"action": event_context.action,
156+
"description": event_context.description,
157+
"container_id": event_context.container_id,
158+
"session_id": event_context.session_id,
159+
}
160+
161+
def create(self, data: dict, event_context: EventContext, mapper: Callable = None):
162+
self.on_create(data, event_context)
153163
function_mapper = self.get_mapper_or_dict(mapper)
164+
self.attach_context(data, event_context)
154165
return function_mapper(self.container.create_item(body=data))
155166

156-
def find(self, id: str, partition_key_value, peeker: 'function' = None,
167+
def find(self, id: str, event_context: EventContext, peeker: 'function' = None,
157168
visible_only=True, mapper: Callable = None):
169+
partition_key_value = self.find_partition_key_value(event_context)
158170
found_item = self.container.read_item(id, partition_key_value)
159171
if peeker:
160172
peeker(found_item)
161173

162174
function_mapper = self.get_mapper_or_dict(mapper)
163175
return function_mapper(self.check_visibility(found_item, visible_only))
164176

165-
def find_all(self, partition_key_value: str, conditions: dict = {}, custom_sql_conditions: List[str] = [],
177+
def find_all(self, event_context: EventContext, conditions: dict = {}, custom_sql_conditions: List[str] = [],
166178
custom_params: dict = {}, max_count=None, offset=0, visible_only=True, mapper: Callable = None):
167-
# TODO Use the tenant_id param and change container alias
179+
partition_key_value = self.find_partition_key_value(event_context)
168180
max_count = self.get_page_size_or(max_count)
169181
params = [
170182
{"name": "@partition_key_value", "value": partition_key_value},
@@ -189,27 +201,31 @@ def find_all(self, partition_key_value: str, conditions: dict = {}, custom_sql_c
189201
function_mapper = self.get_mapper_or_dict(mapper)
190202
return list(map(function_mapper, result))
191203

192-
def partial_update(self, id: str, changes: dict, partition_key_value: str,
204+
def partial_update(self, id: str, changes: dict, event_context: EventContext,
193205
peeker: 'function' = None, visible_only=True, mapper: Callable = None):
194-
item_data = self.find(id, partition_key_value, peeker=peeker,
195-
visible_only=visible_only, mapper=dict)
206+
item_data = self.find(id, event_context, peeker=peeker, visible_only=visible_only, mapper=dict)
196207
item_data.update(changes)
197-
return self.update(id, item_data, mapper=mapper)
208+
return self.update(id, item_data, event_context, mapper=mapper)
198209

199-
def update(self, id: str, item_data: dict, mapper: Callable = None):
200-
self.on_update(item_data)
210+
def update(self, id: str, item_data: dict, event_context: EventContext,
211+
mapper: Callable = None):
212+
self.on_update(item_data, event_context)
201213
function_mapper = self.get_mapper_or_dict(mapper)
214+
self.attach_context(item_data, event_context)
202215
return function_mapper(self.container.replace_item(id, body=item_data))
203216

204-
def delete(self, id: str, partition_key_value: str,
217+
def delete(self, id: str, event_context: EventContext,
205218
peeker: 'function' = None, mapper: Callable = None):
206219
return self.partial_update(id, {
207-
'deleted': str(uuid.uuid4())
208-
}, partition_key_value, peeker=peeker, visible_only=True, mapper=mapper)
220+
'deleted': generate_uuid4()
221+
}, event_context, peeker=peeker, visible_only=True, mapper=mapper)
209222

210223
def delete_permanently(self, id: str, partition_key_value: str) -> None:
211224
self.container.delete_item(id, partition_key_value)
212225

226+
def find_partition_key_value(self, event_context: EventContext):
227+
return getattr(event_context, self.partition_key_attribute)
228+
213229
def get_mapper_or_dict(self, alternative_mapper: Callable) -> Callable:
214230
return alternative_mapper or self.mapper or dict
215231

@@ -218,13 +234,15 @@ def get_page_size_or(self, custom_page_size: int) -> int:
218234
# or any other repository for the settings
219235
return custom_page_size or 100
220236

221-
def on_create(self, new_item_data: dict):
237+
def on_create(self, new_item_data: dict, event_context: EventContext):
222238
if new_item_data.get('id') is None:
223239
new_item_data['id'] = generate_uuid4()
224240

241+
new_item_data[self.partition_key_attribute] = self.find_partition_key_value(event_context)
242+
225243
self.replace_empty_value_per_none(new_item_data)
226244

227-
def on_update(self, update_item_data: dict):
245+
def on_update(self, update_item_data: dict, event_context: EventContext):
228246
pass
229247

230248
def create_sql_order_clause(self):
@@ -238,27 +256,28 @@ def __init__(self, repository: CosmosDBRepository):
238256
self.repository = repository
239257

240258
def get_all(self, conditions: dict = {}) -> list:
241-
return self.repository.find_all(partition_key_value=self.partition_key_value,
242-
conditions=conditions)
259+
event_ctx = self.create_event_context("read-many")
260+
return self.repository.find_all(event_ctx, conditions=conditions)
243261

244262
def get(self, id):
245-
return self.repository.find(id, partition_key_value=self.partition_key_value)
263+
event_ctx = self.create_event_context("read")
264+
return self.repository.find(id, event_ctx)
246265

247266
def create(self, data: dict):
248-
data[self.repository.partition_key_attribute] = self.partition_key_value
249-
return self.repository.create(data)
267+
event_ctx = self.create_event_context("create")
268+
return self.repository.create(data, event_ctx)
250269

251270
def update(self, id, data: dict):
252-
return self.repository.partial_update(id,
253-
changes=data,
254-
partition_key_value=self.partition_key_value)
271+
event_ctx = self.create_event_context("update")
272+
return self.repository.partial_update(id, data, event_ctx)
255273

256274
def delete(self, id):
257-
self.repository.delete(id, partition_key_value=self.partition_key_value)
275+
event_ctx = self.create_event_context("delete")
276+
self.repository.delete(id, event_ctx)
258277

259-
@property
260-
def partition_key_value(self):
261-
return current_user_tenant_id()
278+
def create_event_context(self, action: str = None, description: str = None):
279+
return EventContext(self.repository.container.id, action,
280+
description=description)
262281

263282

264283
class CustomError(HTTPException):

commons/data_access_layer/database.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@
77
"""
88
import abc
99

10-
from flask import Flask
11-
1210
COMMENTS_MAX_LENGTH = 500
1311
ID_MAX_LENGTH = 64
1412

@@ -35,15 +33,36 @@ def delete(self, id):
3533
raise NotImplementedError # pragma: no cover
3634

3735

38-
def init_app(app: Flask) -> None:
39-
init_cosmos_db(app)
36+
class EventContext():
37+
def __init__(self, container_id: str, action: str, description: str = None,
38+
user_id: str = None, tenant_id: str = None, session_id: str = None):
39+
self._container_id = container_id
40+
self._action = action
41+
self._description = description
42+
self._user_id = user_id
43+
self._tenant_id = tenant_id
44+
self._session_id = session_id
45+
46+
@property
47+
def container_id(self):
48+
return self._container_id
49+
50+
@property
51+
def action(self):
52+
return self._action
4053

54+
@property
55+
def description(self):
56+
return self._description
4157

42-
def init_sql(app: Flask) -> None:
43-
from commons.data_access_layer.sql import init_app
44-
init_app(app)
58+
@property
59+
def user_id(self):
60+
return self._user_id
4561

62+
@property
63+
def tenant_id(self):
64+
return self._tenant_id
4665

47-
def init_cosmos_db(app: Flask) -> None:
48-
from commons.data_access_layer.cosmos_db import init_app
49-
init_app(app)
66+
@property
67+
def session_id(self):
68+
return self._session_id
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from azure.cosmos import PartitionKey
2+
3+
container_definition = { # pragma: no cover
4+
'id': 'event',
5+
'partition_key': PartitionKey(path='/tenant_id')
6+
}

commons/data_access_layer/sql.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
1-
from datetime import datetime
2-
31
from flask import Flask
42
from flask_sqlalchemy import SQLAlchemy
53

6-
from commons.data_access_layer.database import CRUDDao, ID_MAX_LENGTH
4+
from commons.data_access_layer.database import CRUDDao
75

86
db: SQLAlchemy = None
97

commons/git_hooks/enforce_semantic_commit_msg.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,10 @@
1515

1616
COMMIT_MSG_REGEX = r'(build|ci|docs|feat|fix|perf|refactor|style|test|chore|revert)(\([\w\-]+\))?:\s.*'
1717

18-
1918
# Get the commit message file
2019
commit_msg_file = open(sys.argv[1]) # The first argument is the file
2120
commit_msg = commit_msg_file.read()
2221

23-
2422
if re.match(COMMIT_MSG_REGEX, commit_msg) is None:
2523
print(ERROR_MSG)
2624
sys.exit(1)

migrations/02-add-events.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
def up():
2+
from commons.data_access_layer.cosmos_db import cosmos_helper
3+
import azure.cosmos.exceptions as exceptions
4+
from commons.data_access_layer.events_model import container_definition as event_definition
5+
from . import app
6+
7+
app.logger.info("Creating container events...")
8+
9+
try:
10+
app.logger.info('- Event')
11+
cosmos_helper.create_container(event_definition)
12+
except exceptions.CosmosResourceExistsError as e:
13+
app.logger.warning("Unexpected error while creating container for events: %s" % e.message)
14+
15+
app.logger.info("Done!")
16+
17+
18+
def down():
19+
print("Not implemented!")
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# requirements/time_tracker_events/dev.txt
2+
3+
4+
# Include the prod resources
5+
-r prod.txt
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# requirements/time_tracker_events/prod.txt
2+
3+
# Include the requirements of that folder, required there by convention
4+
-r ../../time_tracker_events/requirements.txt

0 commit comments

Comments
 (0)