Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Close #58 Create time entry model for Cosmos DB
  • Loading branch information
EliuX committed Apr 16, 2020
commit d23529e2805f23cf039b7e80a78c5248a9168bef
37 changes: 8 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ This is the mono-repository for the backend services and their common codebase
## Getting started
Follow the following instructions to get the project ready to use ASAP.


### Requirements
Be sure you have installed in your system

Expand Down Expand Up @@ -46,35 +47,6 @@ automatically [pip](https://pip.pypa.io/en/stable/) as well.

Remember to do it with Python 3.


- 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)
in your operative system. Then you have to check out what is the name of the SQL Driver installation.
Check it out with:

```bash
vim /usr/local/etc/odbcinst.ini
```

It may display something like

```.ini
[ODBC Driver 17 for SQL Server]
Description=Microsoft ODBC Driver 17 for SQL Server
Driver=/usr/local/lib/libmsodbcsql.17.dylib
UsageCount=2
```

Then specify the driver name, in this case _DBC Driver 17 for SQL Server_ in the `SQL_DATABASE_URI`, e.g.:

```.dotenv
SQL_DATABASE_URI=mssql+pyodbc://<user>:<password>@time-tracker-srv.database.windows.net/<database>?driver\=ODBC Driver 17 for SQL Server
```

To troubleshoot issues regarding this part please check out:
- [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).
- Github issue [odbcinst: SQLRemoveDriver failed with Unable to find component name](https://github.com/Microsoft/homebrew-mssql-preview/issues/2).
- 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).

### How to use it
- Set the env var `FLASK_APP` to `time_tracker_api` and start the app:

Expand All @@ -93,6 +65,13 @@ To troubleshoot issues regarding this part please check out:
a link to the swagger.json with the definition of the api.


### Important notes
Due to the used technology and particularities on the implementation of this API, it is important that you respect the
following notes regarding to the manipulation of the data from and towards the API:

- 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**.

## Development

### Test
Expand Down
95 changes: 72 additions & 23 deletions commons/data_access_layer/cosmos_db.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import dataclasses
import logging
import uuid
from datetime import datetime
from typing import Callable

import azure.cosmos.cosmos_client as cosmos_client
import azure.cosmos.exceptions as exceptions
from azure.cosmos import ContainerProxy, PartitionKey
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 time_tracker_api.security import current_user_tenant_id, current_user_id


class CosmosDBFacade:
Expand Down Expand Up @@ -75,12 +77,14 @@ class CosmosDBRepository:
def __init__(self, container_id: str,
partition_key_attribute: str,
mapper: Callable = None,
order_fields: list = [],
custom_cosmos_helper: CosmosDBFacade = None):
global cosmos_helper
self.cosmos_helper = custom_cosmos_helper or cosmos_helper
if self.cosmos_helper is None: # pragma: no cover
raise ValueError("The cosmos_db module has not been initialized!")
self.mapper = mapper
self.order_fields = order_fields
self.container: ContainerProxy = self.cosmos_helper.db.get_container_client(container_id)
self.partition_key_attribute: str = partition_key_attribute

Expand All @@ -93,7 +97,29 @@ def from_definition(cls, container_definition: dict,
mapper=mapper,
custom_cosmos_helper=custom_cosmos_helper)

@staticmethod
def create_sql_condition_for_visibility(visible_only: bool, container_name='c') -> str:
if visible_only:
# We are considering that `deleted == null` is not a choice
return 'AND NOT IS_DEFINED(%s.deleted)' % container_name
return ''

@staticmethod
def create_sql_condition_for_owner_id(owner_id: str, container_name='c') -> str:
if owner_id:
return 'AND %s.owner_id=@owner_id' % container_name
return ''

@staticmethod
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

def create(self, data: dict, mapper: Callable = None):
self.on_create(data)
function_mapper = self.get_mapper_or_dict(mapper)
return function_mapper(self.container.create_item(body=data))

Expand All @@ -102,20 +128,24 @@ def find(self, id: str, partition_key_value, visible_only=True, mapper: Callable
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, max_count=None, offset=0,
def find_all(self, partition_key_value: str, owner_id=None, 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)
result = self.container.query_items(
query="""
SELECT * FROM c WHERE c.{partition_key_attribute}=@partition_key_value AND {visibility_condition}
SELECT * FROM c WHERE c.{partition_key_attribute}=@partition_key_value
{owner_condition} {visibility_condition} {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)),
visibility_condition=self.create_sql_condition_for_visibility(visible_only),
owner_condition=self.create_sql_condition_for_owner_id(owner_id),
order_clause=self.create_sql_order_clause()),
parameters=[
{"name": "@partition_key_value", "value": partition_key_value},
{"name": "@offset", "value": offset},
{"name": "@max_count", "value": max_count},
{"name": "@owner_id", "value": owner_id},
],
partition_key=partition_key_value,
max_item_count=max_count)
Expand All @@ -130,6 +160,7 @@ def partial_update(self, id: str, changes: dict, partition_key_value: str,
return self.update(id, item_data, mapper=mapper)

def update(self, id: str, item_data: dict, mapper: Callable = None):
self.on_update(item_data)
function_mapper = self.get_mapper_or_dict(mapper)
return function_mapper(self.container.replace_item(id, body=item_data))

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

def check_visibility(self, 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

def create_sql_condition_for_visibility(self, visible_only: bool, container_name='c') -> str:
if visible_only:
# We are considering that `deleted == null` is not a choice
return 'NOT IS_DEFINED(%s.deleted)' % container_name
return 'true'

def get_mapper_or_dict(self, alternative_mapper: Callable) -> Callable:
return alternative_mapper or self.mapper or dict

Expand All @@ -162,22 +180,40 @@ 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):
if new_item_data.get('id') is None:
new_item_data['id'] = str(uuid.uuid4())

def on_update(self, update_item_data: dict):
pass

def create_sql_order_clause(self):
if len(self.order_fields) > 0:
return "ORDER BY c.{}".format(", c.".join(self.order_fields))
else:
return ""


class CosmosDBDao(CRUDDao):
def __init__(self, repository: CosmosDBRepository):
self.repository = repository

@property
def partition_key_value(self):
return current_user_tenant_id()

def get_all(self) -> list:
tenant_id: str = self.partition_key_value
return self.repository.find_all(partition_key_value=tenant_id)
owner_id = current_user_id()
return self.repository.find_all(partition_key_value=tenant_id, owner_id=owner_id)

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

def create(self, data: dict):
data['id'] = str(uuid.uuid4())
data['tenant_id'] = self.partition_key_value
data[self.repository.partition_key_attribute] = self.partition_key_value
data['owner_id'] = current_user_id()
return self.repository.create(data)

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

@property
def partition_key_value(self):
return current_user_tenant_id()

class CustomError(HTTPException):
def __init__(self, status_code: int, description: str = None):
self.code = status_code
self.description = description


def current_datetime():
return datetime.utcnow()


def datetime_str(value: datetime):
if value is not None:
return value.isoformat()
else:
return None


def init_app(app: Flask) -> None:
Expand Down
5 changes: 4 additions & 1 deletion requirements/azure_cosmos.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,7 @@ idna==2.8
six==1.13.0
urllib3==1.25.7
virtualenv==16.7.9
virtualenv-clone==0.5.3
virtualenv-clone==0.5.3

# Dataclasses
dataclasses==0.6
13 changes: 12 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from commons.data_access_layer.cosmos_db import CosmosDBRepository
from time_tracker_api import create_app
from time_tracker_api.time_entries.time_entries_model import TimeEntryCosmosDBRepository

fake = Faker()
Faker.seed()
Expand Down Expand Up @@ -96,7 +97,12 @@ def tenant_id() -> str:


@pytest.fixture(scope="session")
def another_tenant_id() -> str:
def another_tenant_id(tenant_id) -> str:
return tenant_id[:-5] + 'fffff'


@pytest.fixture(scope="session")
def owner_id() -> str:
return fake.uuid4()


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

return cosmos_db_repository.create(sample_item_data)


@pytest.yield_fixture(scope="module")
def time_entry_repository(cosmos_db_repository: CosmosDBRepository) -> TimeEntryCosmosDBRepository:
return TimeEntryCosmosDBRepository()
6 changes: 5 additions & 1 deletion tests/time_tracker_api/smoke_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@
from flask_restplus._http import HTTPStatus
from pytest_mock import MockFixture

unexpected_errors_to_be_handled = [CosmosHttpResponseError, CosmosResourceNotFoundError, CosmosResourceExistsError]
from commons.data_access_layer.cosmos_db import CustomError

unexpected_errors_to_be_handled = [CustomError(HTTPStatus.BAD_REQUEST, "Anything"),
CosmosHttpResponseError, CosmosResourceNotFoundError,
CosmosResourceExistsError, AttributeError]


def test_app_exists(app):
Expand Down
80 changes: 80 additions & 0 deletions tests/time_tracker_api/time_entries/time_entries_model_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from datetime import datetime, timedelta

import pytest
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

fake = Faker()

now = current_datetime()
yesterday = current_datetime() - timedelta(days=1)
two_days_ago = current_datetime() - timedelta(days=2)


def create_time_entry(start_date: datetime,
end_date: datetime,
owner_id: str,
tenant_id: str,
time_entry_repository: TimeEntryCosmosDBRepository) -> TimeEntryCosmosDBModel:
data = {
"project_id": fake.uuid4(),
"activity_id": fake.uuid4(),
"description": fake.paragraph(nb_sentences=2),
"start_date": datetime_str(start_date),
"end_date": datetime_str(end_date),
"owner_id": owner_id,
"tenant_id": tenant_id
}

created_item = time_entry_repository.create(data, mapper=TimeEntryCosmosDBModel)
return created_item


@pytest.mark.parametrize(
'start_date,end_date', [(two_days_ago, yesterday), (now, None)]
)
def test_find_interception_with_date_range_should_find(start_date: datetime,
end_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)

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)

assert result is not None
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)

def test_find_interception_should_ignore_id_of_existing_item(owner_id: str,
tenant_id: str,
time_entry_repository: TimeEntryCosmosDBRepository):
start_date = datetime_str(yesterday)
end_date = datetime_str(now)
existing_item = create_time_entry(yesterday, now, owner_id, tenant_id, 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)

non_colliding_result = time_entry_repository.find_interception_with_date_range(start_date, end_date,
owner_id=owner_id,
partition_key_value=tenant_id,
ignore_id=existing_item.id)

colliding_result is not None
assert any([existing_item.id == item.id for item in colliding_result])

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)

Loading