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
Next Next commit
Close #52 Implement project model in Cosmos DB
  • Loading branch information
EliuX committed Apr 14, 2020
commit 8e287d87706c2496be1effe387fafb107a2ebc42
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# time-tracker-api

[![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)

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.

Expand Down
17 changes: 0 additions & 17 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,23 +46,6 @@ def gen_postman_collection(filename='timetracker-api-postman-collection.json',
save_data(parsed_json, filename)


@cli_manager.command
def seed():
from time_tracker_api.database import seeder as seed
seed()


@cli_manager.command
def re_create_db():
print('This is going to drop all tables and seed again the database')
confirm_answer = input('Do you confirm (Y) you want to remove all your data?\n')
if confirm_answer.upper() == 'Y':
from time_tracker_api.database import seeder
seeder.fresh()
else:
print('\nThis action was cancelled!')


def save_data(data: str, filename: str) -> None:
""" Save text content to a file """
if filename:
Expand Down
33 changes: 32 additions & 1 deletion commons/data_access_layer/cosmos_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
from azure.cosmos import ContainerProxy, PartitionKey
from flask import Flask

from commons.data_access_layer.database import CRUDDao
from time_tracker_api.security import current_user_tenant_id


class CosmosDBFacade:
def __init__(self, client, db_id: str, logger=None): # pragma: no cover
Expand Down Expand Up @@ -122,7 +125,7 @@ def find_all(self, partition_key_value: str, max_count=None, offset=0,

def partial_update(self, id: str, changes: dict, partition_key_value: str,
visible_only=True, mapper: Callable = None):
item_data = self.find(id, partition_key_value, visible_only=visible_only)
item_data = self.find(id, partition_key_value, visible_only=visible_only, mapper=dict)
item_data.update(changes)
return self.update(id, item_data, mapper=mapper)

Expand Down Expand Up @@ -160,6 +163,34 @@ def get_page_size_or(self, custom_page_size: int) -> int:
return custom_page_size or 100


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

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

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

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

def update(self, id, data: dict):
tenant_id: str = current_user_tenant_id()
return self.repository.partial_update(id,
changes=data,
partition_key_value=tenant_id)

def delete(self, id):
tenant_id: str = current_user_tenant_id()
self.repository.delete(id, partition_key_value=tenant_id)


def init_app(app: Flask) -> None:
global cosmos_helper
cosmos_helper = CosmosDBFacade.from_flask_config(app)
Original file line number Diff line number Diff line change
Expand Up @@ -35,45 +35,16 @@ def delete(self, id):
raise NotImplementedError # pragma: no cover


class Seeder(abc.ABC):
@abc.abstractmethod
def run(self):
raise NotImplementedError # pragma: no cover

@abc.abstractmethod
def fresh(self):
raise NotImplementedError # pragma: no cover

def __call__(self, *args, **kwargs):
self.run() # pragma: no cover


seeder: Seeder = None


def init_app(app: Flask) -> None:
init_sql(app)
init_sql(app) # TODO Delete after the migration to Cosmos DB has finished.
init_cosmos_db(app)


def init_sql(app: Flask) -> None:
from commons.data_access_layer.sql import init_app, SQLSeeder
from commons.data_access_layer.sql import init_app
init_app(app)
global seeder
seeder = SQLSeeder()


def init_cosmos_db(app: Flask) -> None:
# from commons.data_access_layer.azure.cosmos_db import cosmos_helper
class CosmosSeeder(Seeder):
def run(self):
print("Provisioning namespace(database)...")
# cosmos_helper.create_container()
print("Database seeded!")

def fresh(self):
print("Removing namespace(database)...")
# cosmos_helper.remove_container()
self.run()

global seeder
seeder = CosmosSeeder()
from commons.data_access_layer.cosmos_db import init_app
init_app(app)
17 changes: 2 additions & 15 deletions commons/data_access_layer/sql.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from flask import Flask
from flask_sqlalchemy import SQLAlchemy

from time_tracker_api.database import CRUDDao, Seeder, ID_MAX_LENGTH
from commons.data_access_layer.database import CRUDDao, ID_MAX_LENGTH
from time_tracker_api.security import current_user_id

db: SQLAlchemy = None
Expand All @@ -14,7 +14,7 @@ def handle_commit_issues(f):
def rollback_if_necessary(*args, **kw):
try:
return f(*args, **kw)
except: # pragma: no cover
except: # pragma: no cover
db.session.rollback()
raise

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

def delete(self, id):
self.repository.remove(id)


class SQLSeeder(Seeder): # pragma: no cover
def run(self):
print("Provisioning database...")
db.create_all()
print("Database seeded!")

def fresh(self):
print("Removing all existing data...")
db.drop_all()

self.run()
2 changes: 1 addition & 1 deletion migrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,4 @@ def remove_migration(self, name):
self.repository.delete_permanently(name, self.app_id)


configure(storage=CosmosDBStorage("migrations", "time-tracker-api"))
configure(storage=CosmosDBStorage("migration", "time-tracker-api"))
2 changes: 1 addition & 1 deletion requirements/migrations.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
# For running any kind of data migration

# Migration tool
migrate-anything==0.1.6
migrate-anything==0.1.6
2 changes: 1 addition & 1 deletion requirements/time_tracker_api/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@ pytest==5.2.0
pytest-mock==2.0.0

# Coverage
coverage==4.5.1
coverage==4.5.1
41 changes: 25 additions & 16 deletions tests/time_tracker_api/projects/projects_namespace_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,14 @@
from flask_restplus._http import HTTPStatus
from pytest_mock import MockFixture

from time_tracker_api.projects.projects_model import PROJECT_TYPE
from time_tracker_api.security import current_user_tenant_id

fake = Faker()

valid_project_data = {
"name": fake.company(),
"description": fake.paragraph(),
'customer_id': fake.uuid4(),
'tenant_id': fake.uuid4(),
'project_type_id': fake.uuid4()
}

Expand All @@ -30,7 +29,7 @@ def test_create_project_should_succeed_with_valid_request(client: FlaskClient, m
response = client.post("/projects", json=valid_project_data, follow_redirects=True)

assert HTTPStatus.CREATED == response.status_code
repository_create_mock.assert_called_once_with(valid_project_data)
repository_create_mock.assert_called_once()


def test_create_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture):
Expand Down Expand Up @@ -73,7 +72,8 @@ def test_get_project_should_succeed_with_valid_id(client: FlaskClient, mocker: M

assert HTTPStatus.OK == response.status_code
fake_project == json.loads(response.data)
repository_find_mock.assert_called_once_with(str(valid_id))
repository_find_mock.assert_called_once_with(str(valid_id),
partition_key_value=current_user_tenant_id())


def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture):
Expand All @@ -89,7 +89,8 @@ def test_get_project_should_return_not_found_with_invalid_id(client: FlaskClient
response = client.get("/projects/%s" % invalid_id, follow_redirects=True)

assert HTTPStatus.NOT_FOUND == response.status_code
repository_find_mock.assert_called_once_with(str(invalid_id))
repository_find_mock.assert_called_once_with(str(invalid_id),
partition_key_value=current_user_tenant_id())


def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_format(client: FlaskClient,
Expand All @@ -106,22 +107,25 @@ def test_get_project_should_response_with_unprocessable_entity_for_invalid_id_fo
response = client.get("/projects/%s" % invalid_id, follow_redirects=True)

assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code
repository_find_mock.assert_called_once_with(str(invalid_id))
repository_find_mock.assert_called_once_with(str(invalid_id),
partition_key_value=current_user_tenant_id())


def test_update_project_should_succeed_with_valid_data(client: FlaskClient, mocker: MockFixture):
from time_tracker_api.projects.projects_namespace import project_dao

repository_update_mock = mocker.patch.object(project_dao.repository,
'update',
'partial_update',
return_value=fake_project)

valid_id = fake.random_int(1, 9999)
response = client.put("/projects/%s" % valid_id, json=valid_project_data, follow_redirects=True)

assert HTTPStatus.OK == response.status_code
fake_project == json.loads(response.data)
repository_update_mock.assert_called_once_with(str(valid_id), valid_project_data)
repository_update_mock.assert_called_once_with(str(valid_id),
changes=valid_project_data,
partition_key_value=current_user_tenant_id())


def test_update_project_should_reject_bad_request(client: FlaskClient, mocker: MockFixture):
Expand All @@ -148,15 +152,17 @@ def test_update_project_should_return_not_found_with_invalid_id(client: FlaskCli
invalid_id = fake.random_int(1, 9999)

repository_update_mock = mocker.patch.object(project_dao.repository,
'update',
'partial_update',
side_effect=NotFound)

response = client.put("/projects/%s" % invalid_id,
json=valid_project_data,
follow_redirects=True)

assert HTTPStatus.NOT_FOUND == response.status_code
repository_update_mock.assert_called_once_with(str(invalid_id), valid_project_data)
repository_update_mock.assert_called_once_with(str(invalid_id),
changes=valid_project_data,
partition_key_value=current_user_tenant_id())


def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker: MockFixture):
Expand All @@ -165,14 +171,15 @@ def test_delete_project_should_succeed_with_valid_id(client: FlaskClient, mocker
valid_id = fake.random_int(1, 9999)

repository_remove_mock = mocker.patch.object(project_dao.repository,
'remove',
'delete',
return_value=None)

response = client.delete("/projects/%s" % valid_id, follow_redirects=True)

assert HTTPStatus.NO_CONTENT == response.status_code
assert b'' == response.data
repository_remove_mock.assert_called_once_with(str(valid_id))
repository_remove_mock.assert_called_once_with(str(valid_id),
partition_key_value=current_user_tenant_id())


def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskClient, mocker: MockFixture):
Expand All @@ -182,13 +189,14 @@ def test_delete_project_should_return_not_found_with_invalid_id(client: FlaskCli
invalid_id = fake.random_int(1, 9999)

repository_remove_mock = mocker.patch.object(project_dao.repository,
'remove',
'delete',
side_effect=NotFound)

response = client.delete("/projects/%s" % invalid_id, follow_redirects=True)

assert HTTPStatus.NOT_FOUND == response.status_code
repository_remove_mock.assert_called_once_with(str(invalid_id))
repository_remove_mock.assert_called_once_with(str(invalid_id),
partition_key_value=current_user_tenant_id())


def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format(client: FlaskClient,
Expand All @@ -199,10 +207,11 @@ def test_delete_project_should_return_unprocessable_entity_for_invalid_id_format
invalid_id = fake.company()

repository_remove_mock = mocker.patch.object(project_dao.repository,
'remove',
'delete',
side_effect=UnprocessableEntity)

response = client.delete("/projects/%s" % invalid_id, follow_redirects=True)

assert HTTPStatus.UNPROCESSABLE_ENTITY == response.status_code
repository_remove_mock.assert_called_once_with(str(invalid_id))
repository_remove_mock.assert_called_once_with(str(invalid_id),
partition_key_value=current_user_tenant_id())
5 changes: 2 additions & 3 deletions tests/time_tracker_api/smoke_test.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import pyodbc

import pytest
from azure.cosmos.exceptions import CosmosHttpResponseError, CosmosResourceExistsError, CosmosResourceNotFoundError
from flask.testing import FlaskClient
from flask_restplus._http import HTTPStatus
from pytest_mock import MockFixture

unexpected_errors_to_be_handled = [pyodbc.OperationalError]
unexpected_errors_to_be_handled = [CosmosHttpResponseError, CosmosResourceNotFoundError, CosmosResourceExistsError]


def test_app_exists(app):
Expand Down
2 changes: 1 addition & 1 deletion time_tracker_api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def init_app_config(app: Flask, config_path: str, config_data: dict = None):


def init_app(app: Flask):
from time_tracker_api.database import init_app as init_database
from commons.data_access_layer.database import init_app as init_database
init_database(app)

from time_tracker_api.api import api
Expand Down
3 changes: 1 addition & 2 deletions time_tracker_api/activities/activities_model.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from azure.cosmos import PartitionKey

from time_tracker_api.database import CRUDDao
from commons.data_access_layer.database import CRUDDao


class ActivityDao(CRUDDao):
Expand Down Expand Up @@ -40,7 +40,6 @@ def __init__(self):
'unique_key_policy': {
'uniqueKeys': [
{'paths': ['/name']},
{'paths': ['/deleted']},
]
}
}
Loading