Skip to content

Commit 7090337

Browse files
authored
#77: Add projects bulk editing (#89)
* Move Bulk Edit schemas and validation to base schemas * Fix invalid query param type for bulk edit operations
1 parent dbd7e42 commit 7090337

File tree

9 files changed

+277
-106
lines changed

9 files changed

+277
-106
lines changed

tests/factories/base.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import TYPE_CHECKING, Union
3+
from typing import TYPE_CHECKING, Dict, List, Union
44

55
from tests.conftest import fake
66

@@ -22,3 +22,13 @@ def datetime_repr_factory(timezone: Union[ZoneInfo, TzInfo, None] = None) -> str
2222
timezone = zoneinfo.ZoneInfo(timezone_name)
2323

2424
return fake.date_time_this_decade(tzinfo=timezone).isoformat(timespec="seconds")
25+
26+
27+
def bulk_edit_response_factory() -> Dict[str, List[Union[int, Dict[str, Union[int, str]]]]]:
28+
return {
29+
"success": [fake.random_int() for _ in range(fake.random_int(10))],
30+
"failure": [
31+
{"id": fake.random_int(), "message": fake.text(max_nb_chars=64)}
32+
for _ in range(fake.random_int(4))
33+
],
34+
}

tests/integration/test_project.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66

77
import pytest
88
from toggl_python.exceptions import BadRequest
9-
from toggl_python.schemas.project import ProjectResponse
9+
from toggl_python.schemas.base import BulkEditOperation, BulkEditOperations, BulkEditResponse
10+
from toggl_python.schemas.project import BulkEditProjectsFieldNames, ProjectResponse
1011

1112
from tests.conftest import fake
1213
from tests.factories.project import project_request_factory
@@ -442,6 +443,52 @@ def test_update_project__all_params(i_authed_workspace: Workspace) -> None:
442443
_ = i_authed_workspace.delete_project(workspace_id, project.id)
443444

444445

446+
def test_bulk_edit_projects(i_authed_workspace: Workspace) -> None:
447+
workspace_id = int(os.environ["WORKSPACE_ID"])
448+
project = i_authed_workspace.create_project(workspace_id, name=fake.uuid4(), active=True)
449+
another_project = i_authed_workspace.create_project(
450+
workspace_id, name=fake.uuid4(), active=True
451+
)
452+
full_request_body = project_request_factory()
453+
allowed_fields = {item.value for item in BulkEditProjectsFieldNames}
454+
random_param = fake.random_element(allowed_fields)
455+
edit_operation = BulkEditOperation(
456+
operation=BulkEditOperations.change,
457+
field_name=random_param,
458+
field_value=full_request_body[random_param],
459+
)
460+
expected_result = set(BulkEditResponse.model_fields.keys())
461+
462+
result = i_authed_workspace.bulk_edit_projects(
463+
workspace_id, project_ids=[project.id, another_project.id], operations=[edit_operation]
464+
)
465+
466+
assert result.model_fields_set == expected_result
467+
assert project.id in result.success
468+
assert another_project.id in result.success
469+
470+
_ = i_authed_workspace.delete_project(workspace_id, project.id)
471+
_ = i_authed_workspace.delete_project(workspace_id, another_project.id)
472+
473+
474+
def test_bulk_edit_projects__forbid_to_edit_currency(i_authed_workspace: Workspace) -> None:
475+
workspace_id = int(os.environ["WORKSPACE_ID"])
476+
project = i_authed_workspace.create_project(workspace_id, name=fake.uuid4(), active=True)
477+
edit_operation = BulkEditOperation(
478+
operation=BulkEditOperations.change,
479+
field_name="currency",
480+
field_value=fake.currency_code(),
481+
)
482+
error_message = "User can not edit project billing info"
483+
484+
with pytest.raises(BadRequest, match=error_message):
485+
_ = i_authed_workspace.bulk_edit_projects(
486+
workspace_id, project_ids=[project.id], operations=[edit_operation]
487+
)
488+
489+
_ = i_authed_workspace.delete_project(workspace_id, project.id)
490+
491+
445492
def test_delete_project(i_authed_workspace: Workspace) -> None:
446493
workspace_id = int(os.environ["WORKSPACE_ID"])
447494
project = i_authed_workspace.create_project(workspace_id, name=fake.uuid4())

tests/responses/time_entry_put_and_patch.py

Lines changed: 0 additions & 14 deletions
This file was deleted.

tests/test_project.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77
import pytest
88
from httpx import Response as HttpxResponse
99
from pydantic import ValidationError
10-
from toggl_python.schemas.project import ProjectResponse
10+
from toggl_python.schemas.base import BulkEditOperation, BulkEditOperations, BulkEditResponse
11+
from toggl_python.schemas.project import BulkEditProjectsFieldNames, ProjectResponse
1112

1213
from tests.conftest import fake
14+
from tests.factories.base import bulk_edit_response_factory, datetime_repr_factory
1315
from tests.factories.project import project_request_factory, project_response_factory
1416
from tests.responses.project_get import PROJECT_RESPONSE
1517

@@ -335,6 +337,76 @@ def test_update_project__invalid_timeframe(authed_workspace: Workspace) -> None:
335337
)
336338

337339

340+
def test_bulk_edit_projects__too_much_ids(authed_workspace: Workspace) -> None:
341+
workspace_id = fake.random_int()
342+
project_ids = [fake.random_int() for _ in range(101)]
343+
error_message = "List should have at most 100 items after validation"
344+
345+
with pytest.raises(ValueError, match=error_message):
346+
_ = authed_workspace.bulk_edit_projects(workspace_id, project_ids, operations=[])
347+
348+
349+
def test_bulk_edit_projects__empty_projects_ids(authed_workspace: Workspace) -> None:
350+
workspace_id = fake.random_int()
351+
error_message = "List should have at least 1 item after validation"
352+
353+
with pytest.raises(ValueError, match=error_message):
354+
_ = authed_workspace.bulk_edit_projects(workspace_id, project_ids=[], operations=[])
355+
356+
357+
def test_bulk_edit_projects__empty_operations(authed_workspace: Workspace) -> None:
358+
workspace_id = fake.random_int()
359+
project_ids = [fake.random_int()]
360+
error_message = "List should have at least 1 item after validation"
361+
362+
with pytest.raises(ValueError, match=error_message):
363+
_ = authed_workspace.bulk_edit_projects(workspace_id, project_ids, operations=[])
364+
365+
366+
@pytest.mark.parametrize(
367+
argnames=("operation"), argvalues=[item.value for item in BulkEditOperations]
368+
)
369+
@pytest.mark.parametrize(
370+
argnames=("field_name", "field_value"),
371+
argvalues=[
372+
(BulkEditProjectsFieldNames.auto_estimates.value, fake.boolean()),
373+
(BulkEditProjectsFieldNames.end_date.value, datetime_repr_factory()),
374+
(BulkEditProjectsFieldNames.estimated_hours.value, fake.random_int()),
375+
(BulkEditProjectsFieldNames.is_private.value, fake.boolean()),
376+
(BulkEditProjectsFieldNames.project_name.value, fake.uuid4()),
377+
(BulkEditProjectsFieldNames.start_date.value, fake.date()),
378+
(BulkEditProjectsFieldNames.template.value, fake.boolean()),
379+
],
380+
)
381+
def test_bulk_edit_time_entries__ok(
382+
field_name: BulkEditProjectsFieldNames,
383+
field_value: Union[str, int],
384+
operation: BulkEditOperations,
385+
response_mock: MockRouter,
386+
authed_workspace: Workspace,
387+
) -> None:
388+
workspace_id = fake.random_int()
389+
project_ids = [fake.random_int(), fake.random_int()]
390+
project_ids_repr = ",".join(str(item) for item in project_ids)
391+
edit_operation = BulkEditOperation(
392+
operation=operation, field_name=field_name, field_value=field_value
393+
)
394+
response = bulk_edit_response_factory()
395+
mocked_route = response_mock.patch(
396+
f"/workspaces/{workspace_id}/projects/{project_ids_repr}"
397+
).mock(
398+
return_value=HttpxResponse(status_code=200, json=response),
399+
)
400+
expected_result = BulkEditResponse.model_validate(response)
401+
402+
result = authed_workspace.bulk_edit_projects(
403+
workspace_id, project_ids, operations=[edit_operation]
404+
)
405+
406+
assert mocked_route.called is True
407+
assert result == expected_result
408+
409+
338410
def test_delete_project(response_mock: MockRouter, authed_workspace: Workspace) -> None:
339411
workspace_id = fake.random_int()
340412
project_id = fake.random_int()

tests/test_time_entry.py

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,30 @@
11
from __future__ import annotations
22

33
from datetime import date, datetime, timezone
4-
from random import randint
54
from typing import TYPE_CHECKING, Dict, List, Union
65
from unittest.mock import Mock, patch
76

87
import pytest
98
from httpx import Response
109
from pydantic import ValidationError
1110
from toggl_python.exceptions import BadRequest
11+
from toggl_python.schemas.base import BulkEditOperation, BulkEditOperations, BulkEditResponse
1212
from toggl_python.schemas.time_entry import (
1313
BulkEditTimeEntriesFieldNames,
14-
BulkEditTimeEntriesOperation,
15-
BulkEditTimeEntriesOperations,
16-
BulkEditTimeEntriesResponse,
1714
MeTimeEntryResponse,
1815
MeTimeEntryWithMetaResponse,
1916
MeWebTimerResponse,
2017
)
2118

2219
from tests.conftest import fake
20+
from tests.factories.base import bulk_edit_response_factory, datetime_repr_factory
2321
from tests.factories.time_entry import (
2422
time_entry_extended_request_factory,
2523
time_entry_request_factory,
2624
time_entry_response_factory,
2725
)
2826
from tests.responses.me_get import ME_WEB_TIMER_RESPONSE
2927
from tests.responses.time_entry_get import ME_TIME_ENTRY_RESPONSE, ME_TIME_ENTRY_WITH_META_RESPONSE
30-
from tests.responses.time_entry_put_and_patch import BULK_EDIT_TIME_ENTRIES_RESPONSE
3128

3229

3330
if TYPE_CHECKING:
@@ -119,6 +116,7 @@ def test_create_time_entry__invalid_start_stop_and_duration(authed_workspace: Wo
119116
stop=request_body["stop"],
120117
)
121118

119+
122120
def test_get_time_entry__without_query_params(
123121
response_mock: MockRouter, authed_current_user: CurrentUser
124122
) -> None:
@@ -419,68 +417,79 @@ def test_delete_time_entry__ok(response_mock: MockRouter, authed_workspace: Work
419417

420418

421419
def test_bulk_edit_time_entries__too_much_ids(authed_workspace: Workspace) -> None:
422-
workspace_id = 123
423-
time_entry_ids = [randint(100000, 999999) for _ in range(101)] # noqa: S311
424-
error_message = "Limit to max TimeEntry IDs exceeded. "
420+
workspace_id = fake.random_int()
421+
time_entry_ids = [fake.random_int() for _ in range(101)]
422+
error_message = "List should have at most 100 items after validation"
425423

426424
with pytest.raises(ValueError, match=error_message):
427425
_ = authed_workspace.bulk_edit_time_entries(workspace_id, time_entry_ids, operations=[])
428426

429427

430428
def test_bulk_edit_time_entries__empty_time_entry_ids(authed_workspace: Workspace) -> None:
431-
workspace_id = 123
432-
error_message = "Specify at least one TimeEntry ID"
429+
workspace_id = fake.random_int()
430+
error_message = "List should have at least 1 item after validation"
433431

434432
with pytest.raises(ValueError, match=error_message):
435433
_ = authed_workspace.bulk_edit_time_entries(workspace_id, time_entry_ids=[], operations=[])
436434

437435

438436
def test_bulk_edit_time_entries__empty_operations(authed_workspace: Workspace) -> None:
439-
workspace_id = 123
440-
time_entry_ids = [12345677]
441-
error_message = "Specify at least one edit operation"
437+
workspace_id = fake.random_int()
438+
time_entry_ids = [fake.random_int()]
439+
error_message = "List should have at least 1 item after validation"
442440

443441
with pytest.raises(ValueError, match=error_message):
444442
_ = authed_workspace.bulk_edit_time_entries(workspace_id, time_entry_ids, operations=[])
445443

446444

447445
@pytest.mark.parametrize(
448-
argnames=("operation"), argvalues=[item.value for item in BulkEditTimeEntriesOperations]
446+
argnames=("operation"), argvalues=[item.value for item in BulkEditOperations]
449447
)
450448
@pytest.mark.parametrize(
451449
argnames=("field_name", "field_value"),
452450
argvalues=[
453-
(BulkEditTimeEntriesFieldNames.billable.value, True),
454-
(BulkEditTimeEntriesFieldNames.description.value, "updated description"),
455-
(BulkEditTimeEntriesFieldNames.duration.value, -1),
456-
(BulkEditTimeEntriesFieldNames.project_id.value, 757542305),
457-
(BulkEditTimeEntriesFieldNames.shared_with_user_ids.value, [1243543643, 676586868]),
458-
(BulkEditTimeEntriesFieldNames.start.value, datetime(2024, 5, 10, tzinfo=timezone.utc)),
459-
(BulkEditTimeEntriesFieldNames.stop.value, datetime(2022, 4, 15, tzinfo=timezone.utc)),
460-
(BulkEditTimeEntriesFieldNames.tag_ids.value, [24032, 354742502]),
461-
(BulkEditTimeEntriesFieldNames.tags.value, ["new tag"]),
462-
(BulkEditTimeEntriesFieldNames.task_id.value, 1593268409),
463-
(BulkEditTimeEntriesFieldNames.user_id.value, 573250897),
451+
(BulkEditTimeEntriesFieldNames.billable.value, fake.boolean()),
452+
(BulkEditTimeEntriesFieldNames.description.value, fake.text(max_nb_chars=32)),
453+
(BulkEditTimeEntriesFieldNames.duration.value, fake.random_int(min=-1, max=128)),
454+
(BulkEditTimeEntriesFieldNames.project_id.value, fake.random_int()),
455+
(
456+
BulkEditTimeEntriesFieldNames.shared_with_user_ids.value,
457+
[fake.random_int() for _ in range(fake.random_int(max=10))],
458+
),
459+
(BulkEditTimeEntriesFieldNames.start.value, datetime_repr_factory()),
460+
(BulkEditTimeEntriesFieldNames.stop.value, datetime_repr_factory()),
461+
(
462+
BulkEditTimeEntriesFieldNames.tag_ids.value,
463+
[fake.random_int() for _ in range(fake.random_int(max=10))],
464+
),
465+
(
466+
BulkEditTimeEntriesFieldNames.tags.value,
467+
[fake.word() for _ in range(fake.random_int(max=10))],
468+
),
469+
(BulkEditTimeEntriesFieldNames.task_id.value, fake.random_int()),
470+
(BulkEditTimeEntriesFieldNames.user_id.value, fake.random_int()),
464471
],
465472
)
466473
def test_bulk_edit_time_entries__ok(
467474
field_name: BulkEditTimeEntriesFieldNames,
468475
field_value: Union[str, int],
469-
operation: BulkEditTimeEntriesOperations,
476+
operation: BulkEditOperations,
470477
response_mock: MockRouter,
471478
authed_workspace: Workspace,
472479
) -> None:
473-
workspace_id = 123
474-
time_entry_ids = [98765, 43210]
475-
edit_operation = BulkEditTimeEntriesOperation(
480+
workspace_id = fake.random_int()
481+
time_entry_ids = [fake.random_int() for _ in range(fake.random_int(min=1, max=8))]
482+
time_entry_ids_repr = ",".join(str(item) for item in time_entry_ids)
483+
edit_operation = BulkEditOperation(
476484
operation=operation, field_name=field_name, field_value=field_value
477485
)
486+
response = bulk_edit_response_factory()
478487
mocked_route = response_mock.patch(
479-
f"/workspaces/{workspace_id}/time_entries/{time_entry_ids}"
488+
f"/workspaces/{workspace_id}/time_entries/{time_entry_ids_repr}"
480489
).mock(
481-
return_value=Response(status_code=200, json=BULK_EDIT_TIME_ENTRIES_RESPONSE),
490+
return_value=Response(status_code=200, json=response),
482491
)
483-
expected_result = BulkEditTimeEntriesResponse.model_validate(BULK_EDIT_TIME_ENTRIES_RESPONSE)
492+
expected_result = BulkEditResponse.model_validate(response)
484493

485494
result = authed_workspace.bulk_edit_time_entries(
486495
workspace_id, time_entry_ids, operations=[edit_operation]

0 commit comments

Comments
 (0)