Skip to content

Commit 3dcee3f

Browse files
authored
Allow to update, bulk edit, delete and stop TimeEntries (#62)
1 parent 80e38c1 commit 3dcee3f

File tree

4 files changed

+374
-32
lines changed

4 files changed

+374
-32
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from __future__ import annotations
2+
3+
from typing import Dict, List, Union
4+
5+
6+
BULK_EDIT_TIME_ENTRIES_RESPONSE: Dict[str, List[Union[int, Dict[str, Union[int, str]]]]] = {
7+
"success": [3544298808],
8+
"failure": [
9+
{
10+
"id": 202793182,
11+
"message": "Time entry with ID: 202793182 was not found/is not accessible",
12+
}
13+
],
14+
}

tests/test_time_entry.py

+189-3
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,33 @@
11
from __future__ import annotations
22

33
from datetime import datetime, timezone
4-
from typing import TYPE_CHECKING, Dict, Union
4+
from random import randint
5+
from typing import TYPE_CHECKING, Dict, List, Union
56
from unittest.mock import Mock, patch
67

78
import pytest
89
from httpx import Response
910
from pydantic import ValidationError
1011
from toggl_python.exceptions import BadRequest
1112
from toggl_python.schemas.time_entry import (
13+
BulkEditTimeEntriesFieldNames,
14+
BulkEditTimeEntriesOperation,
15+
BulkEditTimeEntriesOperations,
16+
BulkEditTimeEntriesResponse,
1217
MeTimeEntryResponse,
1318
MeTimeEntryWithMetaResponse,
1419
MeWebTimerResponse,
1520
)
1621

1722
from tests.responses.me_get import ME_WEB_TIMER_RESPONSE
1823
from tests.responses.time_entry_get import ME_TIME_ENTRY_RESPONSE, ME_TIME_ENTRY_WITH_META_RESPONSE
24+
from tests.responses.time_entry_put_and_patch import BULK_EDIT_TIME_ENTRIES_RESPONSE
1925

2026

2127
if TYPE_CHECKING:
2228
from respx import MockRouter
2329
from toggl_python.entities.user import CurrentUser
30+
from toggl_python.entities.workspace import Workspace
2431

2532

2633
def test_get_time_entry__without_query_params(
@@ -111,7 +118,7 @@ def test_get_time_entries__with_meta_query_param(
111118
assert result == expected_result
112119

113120

114-
@patch("toggl_python.schemas.time_entry.datetime")
121+
@patch("toggl_python.schemas.base.datetime")
115122
@pytest.mark.parametrize(
116123
argnames="query_params, method_kwargs",
117124
argvalues=(
@@ -190,7 +197,7 @@ def test_get_time_entries__invalid_query_params(
190197
_ = authed_current_user.get_time_entries(**query_params)
191198

192199

193-
@patch("toggl_python.schemas.time_entry.datetime")
200+
@patch("toggl_python.schemas.base.datetime")
194201
def test_get_time_entries__too_old_since_value(
195202
mocked_datetime: Mock, authed_current_user: CurrentUser
196203
) -> None:
@@ -212,3 +219,182 @@ def test_get_web_timer__ok(response_mock: MockRouter, authed_current_user: Curre
212219

213220
assert mocked_route.called is True
214221
assert result == expected_result
222+
223+
224+
@pytest.mark.parametrize(
225+
argnames=("field_name", "field_value"),
226+
argvalues=[
227+
("billable", True),
228+
("description", "updated description"),
229+
("duration", -1),
230+
("project_id", 757542305),
231+
("shared_with_user_ids", [1243543643, 676586868]),
232+
("start", "2020-11-11T09:30:00-04:00"),
233+
("stop", "2010-01-29T19:50:00+02:00"),
234+
("tag_ids", [24032, 354742502]),
235+
("tags", ["new tag"]),
236+
("task_id", 1593268409),
237+
("user_id", 573250897),
238+
],
239+
)
240+
def test_workspace_update_time_entry__ok(
241+
field_name: str,
242+
field_value: Union[bool, str, int, List[int]],
243+
response_mock: MockRouter,
244+
authed_workspace: Workspace,
245+
) -> None:
246+
workspace_id = 123
247+
time_entry_id = 98765
248+
payload = {field_name: field_value}
249+
fake_response = ME_TIME_ENTRY_RESPONSE.copy()
250+
fake_response.update(**payload)
251+
mocked_route = response_mock.put(
252+
f"/workspaces/{workspace_id}/time_entries/{time_entry_id}"
253+
).mock(
254+
return_value=Response(status_code=200, json=fake_response),
255+
)
256+
expected_result = MeTimeEntryResponse.model_validate(fake_response)
257+
258+
result = authed_workspace.update_time_entry(workspace_id, time_entry_id, **payload)
259+
260+
assert mocked_route.called is True
261+
assert result == expected_result
262+
263+
264+
def test_update_time_entry__user_cannot_access_project(
265+
response_mock: MockRouter, authed_workspace: Workspace
266+
) -> None:
267+
workspace_id = 123
268+
time_entry_id = 98765
269+
error_message = "User cannot access the selected project"
270+
mocked_route = response_mock.put(
271+
f"/workspaces/{workspace_id}/time_entries/{time_entry_id}"
272+
).mock(
273+
return_value=Response(status_code=400, text=error_message),
274+
)
275+
276+
with pytest.raises(BadRequest, match=error_message):
277+
_ = authed_workspace.update_time_entry(workspace_id, time_entry_id, project_id=125872350)
278+
279+
assert mocked_route.called is True
280+
281+
282+
def test_delete_time_entry__ok(response_mock: MockRouter, authed_workspace: Workspace) -> None:
283+
workspace_id = 123
284+
time_entry_id = 98765
285+
mocked_route = response_mock.delete(
286+
f"/workspaces/{workspace_id}/time_entries/{time_entry_id}"
287+
).mock(
288+
return_value=Response(status_code=200),
289+
)
290+
291+
result = authed_workspace.delete_time_entry(workspace_id, time_entry_id)
292+
293+
assert mocked_route.called is True
294+
assert result is True
295+
296+
297+
def test_bulk_edit_time_entries__too_much_ids(authed_workspace: Workspace) -> None:
298+
workspace_id = 123
299+
time_entry_ids = [randint(100000, 999999) for _ in range(101)] # noqa: S311
300+
error_message = "Limit to max TimeEntry IDs exceeded. "
301+
302+
with pytest.raises(ValueError, match=error_message):
303+
_ = authed_workspace.bulk_edit_time_entries(workspace_id, time_entry_ids, operations=[])
304+
305+
306+
def test_bulk_edit_time_entries__empty_time_entry_ids(authed_workspace: Workspace) -> None:
307+
workspace_id = 123
308+
error_message = "Specify at least one TimeEntry ID"
309+
310+
with pytest.raises(ValueError, match=error_message):
311+
_ = authed_workspace.bulk_edit_time_entries(workspace_id, time_entry_ids=[], operations=[])
312+
313+
314+
def test_bulk_edit_time_entries__empty_operations(authed_workspace: Workspace) -> None:
315+
workspace_id = 123
316+
time_entry_ids = [12345677]
317+
error_message = "Specify at least one edit operation"
318+
319+
with pytest.raises(ValueError, match=error_message):
320+
_ = authed_workspace.bulk_edit_time_entries(workspace_id, time_entry_ids, operations=[])
321+
322+
323+
@pytest.mark.parametrize(
324+
argnames=("operation"), argvalues=[item.value for item in BulkEditTimeEntriesOperations]
325+
)
326+
@pytest.mark.parametrize(
327+
argnames=("field_name", "field_value"),
328+
argvalues=[
329+
(BulkEditTimeEntriesFieldNames.billable.value, True),
330+
(BulkEditTimeEntriesFieldNames.description.value, "updated description"),
331+
(BulkEditTimeEntriesFieldNames.duration.value, -1),
332+
(BulkEditTimeEntriesFieldNames.project_id.value, 757542305),
333+
(BulkEditTimeEntriesFieldNames.shared_with_user_ids.value, [1243543643, 676586868]),
334+
(BulkEditTimeEntriesFieldNames.start.value, datetime(2024, 5, 10, tzinfo=timezone.utc)),
335+
(BulkEditTimeEntriesFieldNames.stop.value, datetime(2022, 4, 15, tzinfo=timezone.utc)),
336+
(BulkEditTimeEntriesFieldNames.tag_ids.value, [24032, 354742502]),
337+
(BulkEditTimeEntriesFieldNames.tags.value, ["new tag"]),
338+
(BulkEditTimeEntriesFieldNames.task_id.value, 1593268409),
339+
(BulkEditTimeEntriesFieldNames.user_id.value, 573250897),
340+
],
341+
)
342+
def test_bulk_edit_time_entries__ok(
343+
field_name: BulkEditTimeEntriesFieldNames,
344+
field_value: Union[str, int],
345+
operation: BulkEditTimeEntriesOperations,
346+
response_mock: MockRouter,
347+
authed_workspace: Workspace,
348+
) -> None:
349+
workspace_id = 123
350+
time_entry_ids = [98765, 43210]
351+
edit_operation = BulkEditTimeEntriesOperation(
352+
operation=operation, field_name=field_name, field_value=field_value
353+
)
354+
mocked_route = response_mock.patch(
355+
f"/workspaces/{workspace_id}/time_entries/{time_entry_ids}"
356+
).mock(
357+
return_value=Response(status_code=200, json=BULK_EDIT_TIME_ENTRIES_RESPONSE),
358+
)
359+
expected_result = BulkEditTimeEntriesResponse.model_validate(BULK_EDIT_TIME_ENTRIES_RESPONSE)
360+
361+
result = authed_workspace.bulk_edit_time_entries(
362+
workspace_id, time_entry_ids, operations=[edit_operation]
363+
)
364+
365+
assert mocked_route.called is True
366+
assert result == expected_result
367+
368+
369+
def test_stop_time_entry__ok(response_mock: MockRouter, authed_workspace: Workspace) -> None:
370+
workspace_id = 123
371+
time_entry_id = 98765
372+
mocked_route = response_mock.patch(
373+
f"/workspaces/{workspace_id}/time_entries/{time_entry_id}/stop"
374+
).mock(
375+
return_value=Response(status_code=200, json=ME_TIME_ENTRY_RESPONSE),
376+
)
377+
expected_result = MeTimeEntryResponse.model_validate(ME_TIME_ENTRY_RESPONSE)
378+
379+
result = authed_workspace.stop_time_entry(workspace_id, time_entry_id)
380+
381+
assert mocked_route.called is True
382+
assert result == expected_result
383+
384+
385+
def test_stop_time_entry__already_stopped(
386+
response_mock: MockRouter, authed_workspace: Workspace
387+
) -> None:
388+
workspace_id = 123
389+
time_entry_id = 98765
390+
error_message = "Time entry already stopped"
391+
mocked_route = response_mock.patch(
392+
f"/workspaces/{workspace_id}/time_entries/{time_entry_id}/stop"
393+
).mock(
394+
return_value=Response(status_code=409, text=error_message),
395+
)
396+
397+
with pytest.raises(BadRequest, match=error_message):
398+
_ = authed_workspace.stop_time_entry(workspace_id, time_entry_id)
399+
400+
assert mocked_route.called is True

toggl_python/entities/workspace.py

+99
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
from toggl_python.api import ApiWrapper
66
from toggl_python.schemas.project import ProjectQueryParams, ProjectResponse
7+
from toggl_python.schemas.time_entry import (
8+
BulkEditTimeEntriesOperation,
9+
BulkEditTimeEntriesResponse,
10+
MeTimeEntryResponse,
11+
TimeEntryRequest,
12+
)
713
from toggl_python.schemas.workspace import GetWorkspacesQueryParams, WorkspaceResponse
814

915

@@ -85,3 +91,96 @@ def get_projects( # noqa: PLR0913 - Too many arguments in function definition (
8591
response_body = response.json()
8692

8793
return [ProjectResponse.model_validate(project_data) for project_data in response_body]
94+
95+
def update_time_entry( # noqa: PLR0913 - Too many arguments in function definition (13 > 12)
96+
self,
97+
workspace_id: int,
98+
time_entry_id: int,
99+
billable: Optional[bool] = None,
100+
description: Optional[str] = None,
101+
duration: Optional[int] = None,
102+
project_id: Optional[int] = None,
103+
shared_with_user_ids: Optional[List[int]] = None,
104+
start: Optional[datetime] = None,
105+
stop: Optional[datetime] = None,
106+
tag_ids: Optional[List[int]] = None,
107+
tags: Optional[List[str]] = None,
108+
task_id: Optional[int] = None,
109+
user_id: Optional[int] = None,
110+
) -> MeTimeEntryResponse:
111+
"""Some params from docs are not listed because API don't use them to change object."""
112+
request_body_schema = TimeEntryRequest(
113+
billable=billable,
114+
description=description,
115+
duration=duration,
116+
project_id=project_id,
117+
shared_with_user_ids=shared_with_user_ids,
118+
start=start,
119+
stop=stop,
120+
tag_ids=tag_ids,
121+
tags=tags,
122+
task_id=task_id,
123+
user_id=user_id,
124+
)
125+
request_body = request_body_schema.model_dump(mode="json", exclude_none=True)
126+
127+
response = self.client.put(
128+
url=f"{self.prefix}/{workspace_id}/time_entries/{time_entry_id}", json=request_body
129+
)
130+
self.raise_for_status(response)
131+
132+
response_body = response.json()
133+
134+
return MeTimeEntryResponse.model_validate(response_body)
135+
136+
def delete_time_entry(self, workspace_id: int, time_entry_id: int) -> bool:
137+
response = self.client.delete(
138+
url=f"{self.prefix}/{workspace_id}/time_entries/{time_entry_id}"
139+
)
140+
self.raise_for_status(response)
141+
142+
return response.is_success
143+
144+
def bulk_edit_time_entries(
145+
self,
146+
workspace_id: int,
147+
time_entry_ids: List[int],
148+
operations: List[BulkEditTimeEntriesOperation],
149+
) -> MeTimeEntryResponse:
150+
if not time_entry_ids:
151+
error_message = "Specify at least one TimeEntry ID"
152+
raise ValueError(error_message)
153+
154+
max_time_entries_ids = 100
155+
if len(time_entry_ids) > max_time_entries_ids:
156+
error_message = (
157+
f"Limit to max TimeEntry IDs exceeded. "
158+
f"Max {max_time_entries_ids} ids per request are allowed"
159+
)
160+
raise ValueError(error_message)
161+
if not operations:
162+
error_message = "Specify at least one edit operation"
163+
raise ValueError(error_message)
164+
165+
request_body = [
166+
operation.model_dump(mode="json", exclude_none=True) for operation in operations
167+
]
168+
169+
response = self.client.patch(
170+
url=f"{self.prefix}/{workspace_id}/time_entries/{time_entry_ids}", json=request_body
171+
)
172+
self.raise_for_status(response)
173+
174+
response_body = response.json()
175+
176+
return BulkEditTimeEntriesResponse.model_validate(response_body)
177+
178+
def stop_time_entry(self, workspace_id: int, time_entry_id: int) -> MeTimeEntryResponse:
179+
response = self.client.patch(
180+
url=f"{self.prefix}/{workspace_id}/time_entries/{time_entry_id}/stop"
181+
)
182+
self.raise_for_status(response)
183+
184+
response_body = response.json()
185+
186+
return MeTimeEntryResponse.model_validate(response_body)

0 commit comments

Comments
 (0)