Skip to content

Commit 6ecac00

Browse files
authored
Merge pull request #109 from ioet/feature/allow-null-id-fields#107
Close #107 Allow removing id by specifying null
2 parents 9d9083e + 7dc262f commit 6ecac00

File tree

11 files changed

+131
-54
lines changed

11 files changed

+131
-54
lines changed

commons/data_access_layer/cosmos_db.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,14 @@ def check_visibility(item, throw_not_found_if_deleted):
132132
if throw_not_found_if_deleted and item.get('deleted') is not None:
133133
raise exceptions.CosmosResourceNotFoundError(message='Deleted item',
134134
status_code=404)
135-
136135
return item
137136

137+
@staticmethod
138+
def replace_empty_value_per_none(item_data: dict) -> dict:
139+
for k, v in item_data.items():
140+
if isinstance(v, str) and len(v) == 0:
141+
item_data[k] = None
142+
138143
def create(self, data: dict, mapper: Callable = None):
139144
self.on_create(data)
140145
function_mapper = self.get_mapper_or_dict(mapper)
@@ -207,6 +212,8 @@ def on_create(self, new_item_data: dict):
207212
if new_item_data.get('id') is None:
208213
new_item_data['id'] = generate_uuid4()
209214

215+
self.replace_empty_value_per_none(new_item_data)
216+
210217
def on_update(self, update_item_data: dict):
211218
pass
212219

tests/commons/data_access_layer/cosmos_db_test.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,3 +602,28 @@ def test_datetime_str_comparison():
602602

603603
assert now_str > datetime_str(now - timedelta(days=1))
604604
assert now_str < datetime_str(now + timedelta(days=1))
605+
606+
607+
def test_replace_empty_value_per_none(tenant_id: str):
608+
initial_value = dict(id=fake.uuid4(),
609+
name=fake.name(),
610+
empty_str_attrib="",
611+
array_attrib=[1, 2, 3],
612+
empty_array_attrib=[],
613+
description=" ",
614+
age=fake.pyint(min_value=10, max_value=80),
615+
size=0,
616+
tenant_id=tenant_id)
617+
618+
input = initial_value.copy()
619+
620+
CosmosDBRepository.replace_empty_value_per_none(input)
621+
622+
assert input["name"] == initial_value["name"]
623+
assert input["empty_str_attrib"] is None
624+
assert input["array_attrib"] == initial_value["array_attrib"]
625+
assert input["empty_array_attrib"] == initial_value["empty_array_attrib"]
626+
assert input["description"] == initial_value["description"]
627+
assert input["age"] == initial_value["age"]
628+
assert input["size"] == initial_value["size"]
629+
assert input["tenant_id"] == initial_value["tenant_id"]

tests/time_tracker_api/project_types/project_types_namespace_test.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def test_create_project_type_should_reject_bad_request(client: FlaskClient,
4242
from time_tracker_api.project_types.project_types_namespace import project_type_dao
4343
invalid_project_type_data = valid_project_type_data.copy()
4444
invalid_project_type_data.update({
45-
"parent_id": None,
45+
"name": None,
4646
})
4747
repository_create_mock = mocker.patch.object(project_type_dao.repository,
4848
'create',
@@ -166,7 +166,7 @@ def test_update_project_should_reject_bad_request(client: FlaskClient,
166166
from time_tracker_api.project_types.project_types_namespace import project_type_dao
167167
invalid_project_type_data = valid_project_type_data.copy()
168168
invalid_project_type_data.update({
169-
"parent_id": None,
169+
"name": None,
170170
})
171171
repository_update_mock = mocker.patch.object(project_type_dao.repository,
172172
'partial_update',

tests/time_tracker_api/time_entries/time_entries_namespace_test.py

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55
from flask import json
66
from flask.testing import FlaskClient
77
from flask_restplus._http import HTTPStatus
8-
from pytest_mock import MockFixture
8+
from pytest_mock import MockFixture, pytest
99

10-
from commons.data_access_layer.cosmos_db import current_datetime
10+
from commons.data_access_layer.cosmos_db import current_datetime, current_datetime_str
1111

1212
fake = Faker()
1313

@@ -16,14 +16,14 @@
1616
"project_id": fake.uuid4(),
1717
"activity_id": fake.uuid4(),
1818
"description": fake.paragraph(nb_sentences=2),
19-
"start_date": str(yesterday.isoformat()),
20-
"owner_id": fake.uuid4(),
21-
"tenant_id": fake.uuid4()
19+
"start_date": current_datetime_str(),
2220
}
2321

2422
fake_time_entry = ({
2523
"id": fake.random_int(1, 9999),
2624
"running": True,
25+
"owner_id": fake.uuid4(),
26+
"tenant_id": fake.uuid4(),
2727
})
2828
fake_time_entry.update(valid_time_entry_input)
2929

@@ -86,21 +86,20 @@ def test_create_time_entry_should_succeed_with_valid_request(client: FlaskClient
8686
repository_create_mock.assert_called_once()
8787

8888

89-
def test_create_time_entry_should_reject_bad_request(client: FlaskClient,
90-
mocker: MockFixture,
91-
valid_header: dict):
89+
def test_create_time_entry_with_missing_req_field_should_return_bad_request(client: FlaskClient,
90+
mocker: MockFixture,
91+
valid_header: dict):
9292
from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao
93-
invalid_time_entry_input = valid_time_entry_input.copy()
94-
invalid_time_entry_input.update({
95-
"project_id": None,
96-
})
9793
repository_create_mock = mocker.patch.object(time_entries_dao.repository,
9894
'create',
9995
return_value=fake_time_entry)
10096

10197
response = client.post("/time-entries",
10298
headers=valid_header,
103-
json=invalid_time_entry_input,
99+
json={
100+
"activity_id": fake.uuid4(),
101+
"start_date": current_datetime_str(),
102+
},
104103
follow_redirects=True)
105104

106105
assert HTTPStatus.BAD_REQUEST == response.status_code
@@ -424,3 +423,50 @@ def test_get_running_should_return_not_found_if_find_running_throws_StopIteratio
424423
assert HTTPStatus.NOT_FOUND == response.status_code
425424
repository_update_mock.assert_called_once_with(partition_key_value=tenant_id,
426425
owner_id=owner_id)
426+
427+
@pytest.mark.parametrize(
428+
'invalid_uuid', ["zxy", "zxy%s" % fake.uuid4(), "%szxy" % fake.uuid4(), " "]
429+
)
430+
def test_create_with_invalid_uuid_format_should_return_bad_request(client: FlaskClient,
431+
mocker: MockFixture,
432+
valid_header: dict,
433+
invalid_uuid: str):
434+
from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao
435+
repository_container_create_item_mock = mocker.patch.object(time_entries_dao.repository.container,
436+
'create_item',
437+
return_value=fake_time_entry)
438+
invalid_time_entry_input = {
439+
"project_id": fake.uuid4(),
440+
"activity_id": invalid_uuid,
441+
}
442+
response = client.post("/time-entries",
443+
headers=valid_header,
444+
json=invalid_time_entry_input,
445+
follow_redirects=True)
446+
447+
assert HTTPStatus.BAD_REQUEST == response.status_code
448+
repository_container_create_item_mock.assert_not_called()
449+
450+
@pytest.mark.parametrize(
451+
'valid_uuid', ["", fake.uuid4()]
452+
)
453+
def test_create_with_valid_uuid_format_should_return_created(client: FlaskClient,
454+
mocker: MockFixture,
455+
valid_header: dict,
456+
valid_uuid: str):
457+
from time_tracker_api.time_entries.time_entries_namespace import time_entries_dao
458+
repository_container_create_item_mock = mocker.patch.object(time_entries_dao.repository.container,
459+
'create_item',
460+
return_value=fake_time_entry)
461+
invalid_time_entry_input = {
462+
"project_id": fake.uuid4(),
463+
"activity_id": valid_uuid,
464+
}
465+
response = client.post("/time-entries",
466+
headers=valid_header,
467+
json=invalid_time_entry_input,
468+
follow_redirects=True)
469+
470+
assert HTTPStatus.CREATED == response.status_code
471+
repository_container_create_item_mock.assert_called()
472+

time_tracker_api/activities/activities_namespace.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
from faker import Faker
2-
from flask_restplus import fields, Resource, Namespace
2+
from flask_restplus import fields, Resource
33
from flask_restplus._http import HTTPStatus
44

55
from time_tracker_api.activities.activities_model import create_dao
6-
from time_tracker_api.api import common_fields
6+
from time_tracker_api.api import common_fields, api
77

88
faker = Faker()
99

10-
ns = Namespace('activities', description='API for activities')
10+
ns = api.namespace('activities', description='Namespace of the API for activities')
1111

1212
# Activity Model
1313
activity_input = ns.model('ActivityInput', {

time_tracker_api/api.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,30 +38,37 @@ def create_attributes_filter(ns: namespace, model: Model, filter_attrib_names: l
3838
return attribs_parser
3939

4040

41-
# Common models structure
41+
# Custom fields
42+
class NullableString(fields.String):
43+
__schema_type__ = ['string', 'null']
44+
45+
46+
class UUID(NullableString):
47+
def __init__(self, *args, **kwargs):
48+
super(UUID, self).__init__(*args, **kwargs)
49+
self.pattern = r"^(|%s)$" % UUID_REGEX
50+
51+
4252
common_fields = {
43-
'id': fields.String(
53+
'id': UUID(
4454
title='Identifier',
4555
readOnly=True,
4656
required=True,
4757
description='The unique identifier',
48-
pattern=UUID_REGEX,
4958
example=faker.uuid4(),
5059
),
51-
'tenant_id': fields.String(
60+
'tenant_id': UUID(
5261
title='Identifier of Tenant',
5362
readOnly=True,
5463
required=True,
5564
description='Tenant it belongs to',
56-
# pattern=UUID_REGEX, This must be confirmed
5765
example=faker.uuid4(),
5866
),
59-
'deleted': fields.String(
67+
'deleted': UUID(
6068
readOnly=True,
6169
required=True,
6270
title='Last event Identifier',
6371
description='Last event over this resource',
64-
pattern=UUID_REGEX,
6572
),
6673
}
6774

time_tracker_api/customers/customers_namespace.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
from faker import Faker
2-
from flask_restplus import Namespace, Resource, fields
2+
from flask_restplus import Resource, fields
33
from flask_restplus._http import HTTPStatus
44

5-
from time_tracker_api.api import common_fields
5+
from time_tracker_api.api import common_fields, api
66
from time_tracker_api.customers.customers_model import create_dao
77

88
faker = Faker()
99

10-
ns = Namespace('customers', description='API for customers')
10+
ns = api.namespace('customers', description='Namespace of the API for customers')
1111

1212
# Customer Model
1313
customer_input = ns.model('CustomerInput', {

time_tracker_api/project_types/project_types_namespace.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
from faker import Faker
2-
from flask_restplus import Namespace, Resource, fields
2+
from flask_restplus import Resource, fields
33
from flask_restplus._http import HTTPStatus
44

5-
from time_tracker_api.api import common_fields, create_attributes_filter
5+
from time_tracker_api.api import common_fields, create_attributes_filter, api, UUID
66
from time_tracker_api.project_types.project_types_model import create_dao
7-
from time_tracker_api.security import UUID_REGEX
87

98
faker = Faker()
109

11-
ns = Namespace('project-types', description='API for project types')
10+
ns = api.namespace('project-types', description='Namespace of the API for project types')
1211

1312
# ProjectType Model
1413
project_type_input = ns.model('ProjectTypeInput', {
@@ -26,19 +25,17 @@
2625
description='Comments about the project type',
2726
example=faker.paragraph(),
2827
),
29-
'customer_id': fields.String(
28+
'customer_id': UUID(
3029
title='Identifier of the Customer',
3130
required=False,
3231
description='Customer this project type belongs to. '
3332
'If not specified, it will be considered an internal project of the tenant.',
34-
pattern=UUID_REGEX,
3533
example=faker.uuid4(),
3634
),
37-
'parent_id': fields.String(
35+
'parent_id': UUID(
3836
title='Identifier of the parent project type',
3937
required=False,
4038
description='This parent node allows to created a tree-like structure for project types',
41-
pattern=UUID_REGEX,
4239
example=faker.uuid4(),
4340
),
4441
})

time_tracker_api/projects/projects_namespace.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
from faker import Faker
2-
from flask_restplus import Namespace, Resource, fields
2+
from flask_restplus import Resource, fields
33
from flask_restplus._http import HTTPStatus
44

5-
from time_tracker_api.api import common_fields, create_attributes_filter
5+
from time_tracker_api.api import common_fields, create_attributes_filter, UUID, api
66
from time_tracker_api.projects.projects_model import create_dao
7-
from time_tracker_api.security import UUID_REGEX
87

98
faker = Faker()
109

11-
ns = Namespace('projects', description='API for projects (clients)')
10+
ns = api.namespace('projects', description='Namespace of the API for projects')
1211

1312
# Project Model
1413
project_input = ns.model('ProjectInput', {
@@ -26,19 +25,17 @@
2625
description='Description about the project',
2726
example=faker.paragraph(),
2827
),
29-
'customer_id': fields.String(
28+
'customer_id': UUID(
3029
title='Identifier of the Customer',
3130
required=False,
3231
description='Customer this project type belongs to. '
3332
'If not specified, it will be considered an internal project of the tenant.',
34-
pattern=UUID_REGEX,
3533
example=faker.uuid4(),
3634
),
37-
'project_type_id': fields.String(
35+
'project_type_id': UUID(
3836
title='Identifier of the project type',
3937
required=False,
4038
description='Id of the project type it belongs. This allows grouping the projects.',
41-
pattern=UUID_REGEX,
4239
example=faker.uuid4(),
4340
),
4441
})

time_tracker_api/time_entries/time_entries_model.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ def on_create(self, new_item_data: dict):
8585
def on_update(self, updated_item_data: dict):
8686
CosmosDBRepository.on_update(self, updated_item_data)
8787
self.validate_data(updated_item_data)
88+
self.replace_empty_value_per_none(updated_item_data)
8889

8990
def find_interception_with_date_range(self, start_date, end_date, owner_id, partition_key_value,
9091
ignore_id=None, visible_only=True, mapper: Callable = None):

0 commit comments

Comments
 (0)