Skip to content

Commit c4042ae

Browse files
authored
#8: Implement TimeEntry entities fetching (#52)
1 parent 0b4e8a3 commit c4042ae

File tree

5 files changed

+446
-1
lines changed

5 files changed

+446
-1
lines changed

tests/responses/me_get.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,3 +105,28 @@
105105
"send_weekly_report": False,
106106
"timeofday_format": "H:mm",
107107
}
108+
109+
ME_WEB_TIMER_RESPONSE: Dict[str, Union[None, List[Dict]]] = {
110+
"clients": None,
111+
"projects": [],
112+
"tags": [],
113+
"tasks": None,
114+
"time_entries": [
115+
{
116+
"billable": False,
117+
"deleted": None,
118+
"description": "test timer",
119+
"duration_in_seconds": 52,
120+
"ignore_start_and_stop": True,
121+
"planned_task_id": None,
122+
"project_id": 202793182,
123+
"tag_ids": [16501871],
124+
"task_id": 3545645770,
125+
"updated_at": "2024-07-30T08:14:38+00:00",
126+
"user_id": 30809356,
127+
"utc_start": "2024-07-30T08:13:46+00:00",
128+
"utc_stop": "2024-07-30T08:14:38+00:00",
129+
"workspace_id": 43644207,
130+
},
131+
],
132+
}

tests/responses/time_entry_get.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from __future__ import annotations
2+
3+
from typing import Dict, Union
4+
5+
6+
ME_TIME_ENTRY_RESPONSE: Dict[str, Union[str, int, bool, list, None]] = {
7+
"at": "2024-07-29T12:28:56+00:00",
8+
"billable": False,
9+
"description": "test timer",
10+
"duration": 22,
11+
"duronly": True,
12+
"id": 3544298808,
13+
"permissions": None,
14+
"project_id": None,
15+
"server_deleted_at": None,
16+
"start": "2024-07-29T12:28:33+00:00",
17+
"stop": "2024-07-29T12:28:55+00:00",
18+
"tag_ids": [],
19+
"tags": [],
20+
"task_id": None,
21+
"user_id": 30809356,
22+
"workspace_id": 43644207,
23+
}
24+
25+
ME_TIME_ENTRY_WITH_META_RESPONSE: Dict[str, Union[str, int, bool, list, None]] = {
26+
"at": "2024-07-29T12:28:56+00:00",
27+
"billable": False,
28+
"description": "test timer",
29+
"duration": 22,
30+
"duronly": True,
31+
"id": 3544298808,
32+
"permissions": None,
33+
"project_id": None,
34+
"server_deleted_at": None,
35+
"start": "2024-07-29T12:28:33+00:00",
36+
"stop": "2024-07-29T12:28:55+00:00",
37+
"tag_ids": [],
38+
"tags": [],
39+
"task_id": None,
40+
"user_avatar_url": "",
41+
"user_id": 30809356,
42+
"user_name": "Test User",
43+
"workspace_id": 43644207,
44+
}

tests/test_time_entry.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
from __future__ import annotations
2+
3+
from datetime import datetime, timezone
4+
from typing import TYPE_CHECKING, Dict, Union
5+
from unittest.mock import Mock, patch
6+
7+
import pytest
8+
from httpx import Response
9+
from pydantic import ValidationError
10+
from toggl_python.exceptions import BadRequest
11+
from toggl_python.schemas.time_entry import (
12+
MeTimeEntryResponse,
13+
MeTimeEntryWithMetaResponse,
14+
MeWebTimerResponse,
15+
)
16+
17+
from tests.responses.me_get import ME_WEB_TIMER_RESPONSE
18+
from tests.responses.time_entry_get import ME_TIME_ENTRY_RESPONSE, ME_TIME_ENTRY_WITH_META_RESPONSE
19+
20+
21+
if TYPE_CHECKING:
22+
from respx import MockRouter
23+
from toggl_python.entities.user import CurrentUser
24+
25+
26+
def test_get_time_entry__without_query_params(
27+
response_mock: MockRouter, authed_current_user: CurrentUser
28+
) -> None:
29+
fake_time_entry_id = 123
30+
mocked_route = response_mock.get(f"/me/time_entries/{fake_time_entry_id}").mock(
31+
return_value=Response(status_code=200, json=ME_TIME_ENTRY_RESPONSE),
32+
)
33+
expected_result = MeTimeEntryResponse.model_validate(ME_TIME_ENTRY_RESPONSE)
34+
35+
result = authed_current_user.get_time_entry(fake_time_entry_id)
36+
37+
assert mocked_route.called is True
38+
assert result == expected_result
39+
40+
41+
def test_get_time_entry__with_meta_query_param(
42+
response_mock: MockRouter, authed_current_user: CurrentUser
43+
) -> None:
44+
fake_time_entry_id = 123
45+
mocked_route = response_mock.get(f"/me/time_entries/{fake_time_entry_id}?meta=true").mock(
46+
return_value=Response(status_code=200, json=ME_TIME_ENTRY_WITH_META_RESPONSE),
47+
)
48+
expected_result = MeTimeEntryWithMetaResponse.model_validate(ME_TIME_ENTRY_WITH_META_RESPONSE)
49+
50+
result = authed_current_user.get_time_entry(fake_time_entry_id, meta=True)
51+
52+
assert mocked_route.called is True
53+
assert result == expected_result
54+
55+
56+
def test_get_current_time_entry__ok(
57+
response_mock: MockRouter, authed_current_user: CurrentUser
58+
) -> None:
59+
mocked_route = response_mock.get("/me/time_entries/current").mock(
60+
return_value=Response(status_code=200, json=ME_TIME_ENTRY_RESPONSE),
61+
)
62+
expected_result = MeTimeEntryResponse.model_validate(ME_TIME_ENTRY_RESPONSE)
63+
64+
result = authed_current_user.get_current_time_entry()
65+
66+
assert mocked_route.called is True
67+
assert result == expected_result
68+
69+
70+
def test_get_current_time_entry__no_current_entry(
71+
response_mock: MockRouter, authed_current_user: CurrentUser
72+
) -> None:
73+
mocked_route = response_mock.get("/me/time_entries/current").mock(
74+
return_value=Response(status_code=200, json={}),
75+
)
76+
77+
result = authed_current_user.get_current_time_entry()
78+
79+
assert mocked_route.called is True
80+
assert result is None
81+
82+
83+
def test_get_time_entries__without_query_params(
84+
response_mock: MockRouter, authed_current_user: CurrentUser
85+
) -> None:
86+
fake_response = [ME_TIME_ENTRY_RESPONSE]
87+
mocked_route = response_mock.get("/me/time_entries").mock(
88+
return_value=Response(status_code=200, json=fake_response),
89+
)
90+
expected_result = [MeTimeEntryResponse.model_validate(ME_TIME_ENTRY_RESPONSE)]
91+
92+
result = authed_current_user.get_time_entries()
93+
94+
assert mocked_route.called is True
95+
assert result == expected_result
96+
97+
98+
def test_get_time_entries__with_meta_query_param(
99+
response_mock: MockRouter, authed_current_user: CurrentUser
100+
) -> None:
101+
mocked_route = response_mock.get("/me/time_entries", params={"meta": True}).mock(
102+
return_value=Response(status_code=200, json=[ME_TIME_ENTRY_WITH_META_RESPONSE]),
103+
)
104+
expected_result = [
105+
MeTimeEntryWithMetaResponse.model_validate(ME_TIME_ENTRY_WITH_META_RESPONSE)
106+
]
107+
108+
result = authed_current_user.get_time_entries(meta=True)
109+
110+
assert mocked_route.called is True
111+
assert result == expected_result
112+
113+
114+
@patch("toggl_python.schemas.time_entry.datetime")
115+
@pytest.mark.parametrize(
116+
argnames="query_params, method_kwargs",
117+
argvalues=(
118+
(
119+
{"since": 1715299200},
120+
{"since": int(datetime(2024, 5, 10, tzinfo=timezone.utc).timestamp())},
121+
),
122+
({"since": 1718755200}, {"since": 1718755200}),
123+
({"before": "2024-07-28T12:30:43+00:00"}, {"before": "2024-07-28T12:30:43+00:00"}),
124+
(
125+
{"before": "2023-01-01T00:00:00+00:00"},
126+
{"before": datetime(2023, 1, 1, tzinfo=timezone.utc)},
127+
),
128+
(
129+
{"start_date": "2023-09-12T00:00:00-03:00", "end_date": "2023-10-12T00:00:00-01:00"},
130+
{"start_date": "2023-09-12T00:00:00-03:00", "end_date": "2023-10-12T00:00:00-01:00"},
131+
),
132+
),
133+
)
134+
def test_get_time_entries__with_datetime_query_params(
135+
mocked_datetime: Mock,
136+
query_params: Dict[str, Union[int, str]],
137+
method_kwargs: Dict[str, Union[datetime, str]],
138+
response_mock: MockRouter,
139+
authed_current_user: CurrentUser,
140+
) -> None:
141+
query_params["meta"] = False
142+
# Required to pass `since` query param validation
143+
mocked_datetime.now.return_value = datetime(2024, 6, 20, tzinfo=timezone.utc)
144+
mocked_route = response_mock.get("/me/time_entries", params=query_params).mock(
145+
return_value=Response(status_code=200, json=[ME_TIME_ENTRY_RESPONSE]),
146+
)
147+
expected_result = [MeTimeEntryResponse.model_validate(ME_TIME_ENTRY_RESPONSE)]
148+
149+
result = authed_current_user.get_time_entries(**method_kwargs)
150+
151+
assert mocked_route.called is True
152+
assert result == expected_result
153+
154+
155+
@pytest.mark.parametrize(
156+
argnames="query_params",
157+
argvalues=(
158+
{"start_date": "2010-01-01T00:00:00+08:00"},
159+
{"end_date": "2010-02-01T00:00:00+03:00"},
160+
{"since": 17223107204, "before": "2024-07-28T00:00:00+10:00"},
161+
{
162+
"since": 17223107204,
163+
"start_date": "2020-11-11T09:30:00-04:00",
164+
"end_date": "2021-01-11T09:30:00-04:00",
165+
},
166+
{
167+
"before": "2020-12-15T09:30:00-04:00",
168+
"start_date": "2020-11-11T09:30:00-04:00",
169+
"end_date": "2021-01-11T09:30:00-04:00",
170+
},
171+
{
172+
"since": 17223107204,
173+
"before": "2020-12-15T09:30:00-04:00",
174+
"start_date": "2020-11-11T09:30:00-04:00",
175+
"end_date": "2021-01-11T09:30:00-04:00",
176+
},
177+
),
178+
)
179+
def test_get_time_entries__invalid_query_params(
180+
query_params: Dict[str, Union[int, str]],
181+
response_mock: MockRouter,
182+
authed_current_user: CurrentUser,
183+
) -> None:
184+
error_message = "can not be present simultaneously"
185+
_ = response_mock.get("/me/time_entries", params=query_params).mock(
186+
return_value=Response(status_code=400, json=error_message),
187+
)
188+
189+
with pytest.raises(BadRequest, match=error_message):
190+
_ = authed_current_user.get_time_entries(**query_params)
191+
192+
193+
@patch("toggl_python.schemas.time_entry.datetime")
194+
def test_get_time_entries__too_old_since_value(
195+
mocked_datetime: Mock, authed_current_user: CurrentUser
196+
) -> None:
197+
error_message = "Since cannot be older than 3 months"
198+
since = datetime(2020, 1, 1, tzinfo=timezone.utc)
199+
mocked_datetime.now.return_value = datetime(2020, 4, 1, tzinfo=timezone.utc)
200+
201+
with pytest.raises(ValidationError, match=error_message):
202+
_ = authed_current_user.get_time_entries(since=since)
203+
204+
205+
def test_get_web_timer__ok(response_mock: MockRouter, authed_current_user: CurrentUser) -> None:
206+
mocked_route = response_mock.get("/me/web-timer").mock(
207+
return_value=Response(status_code=200, json=ME_WEB_TIMER_RESPONSE),
208+
)
209+
expected_result = MeWebTimerResponse.model_validate(ME_WEB_TIMER_RESPONSE)
210+
211+
result = authed_current_user.get_web_timer()
212+
213+
assert mocked_route.called is True
214+
assert result == expected_result

toggl_python/entities/user.py

Lines changed: 73 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, List, Optional
3+
from typing import TYPE_CHECKING, List, Optional, Union
44

55
from toggl_python.api import ApiWrapper
66
from toggl_python.schemas.current_user import (
@@ -16,9 +16,17 @@
1616
UpdateMeRequest,
1717
UpdateMeResponse,
1818
)
19+
from toggl_python.schemas.time_entry import (
20+
MeTimeEntryQueryParams,
21+
MeTimeEntryResponse,
22+
MeTimeEntryWithMetaResponse,
23+
MeWebTimerResponse,
24+
)
1925

2026

2127
if TYPE_CHECKING:
28+
from datetime import datetime
29+
2230
from pydantic import EmailStr
2331

2432

@@ -128,3 +136,67 @@ def update_preferences(
128136

129137
response_body = response.json()
130138
return MePreferencesResponse.model_validate(response_body)
139+
140+
def get_time_entry(
141+
self, time_entry_id: int, meta: bool = False
142+
) -> Union[MeTimeEntryResponse, MeTimeEntryWithMetaResponse]:
143+
"""Intentionally use the same schema for requests with `include_sharing=true`.
144+
145+
Tested responses do not differ from requests with `include_sharing=false`
146+
that is why there is no `include_sharing` method argument.
147+
"""
148+
response = self.client.get(
149+
url=f"{self.prefix}/time_entries/{time_entry_id}",
150+
params={"meta": meta},
151+
)
152+
self.raise_for_status(response)
153+
154+
response_schema = MeTimeEntryWithMetaResponse if meta else MeTimeEntryResponse
155+
156+
response_body = response.json()
157+
return response_schema.model_validate(response_body)
158+
159+
def get_current_time_entry(self) -> Optional[MeTimeEntryResponse]:
160+
"""Return empty response if there is no running TimeEntry."""
161+
response = self.client.get(url=f"{self.prefix}/time_entries/current")
162+
self.raise_for_status(response)
163+
164+
response_body = response.json()
165+
return MeTimeEntryResponse.model_validate(response_body) if response_body else None
166+
167+
def get_time_entries(
168+
self,
169+
meta: bool = False,
170+
since: Union[int, datetime, None] = None,
171+
before: Union[str, datetime, None] = None,
172+
start_date: Union[str, datetime, None] = None,
173+
end_date: Union[str, datetime, None] = None,
174+
) -> List[Union[MeTimeEntryResponse, MeTimeEntryWithMetaResponse]]:
175+
"""Intentionally use the same schema for requests with `include_sharing=true`.
176+
177+
Tested responses do not differ from requests with `include_sharing=false`
178+
that is why there is no `include_sharing` method argument.
179+
"""
180+
payload_schema = MeTimeEntryQueryParams(
181+
meta=meta,
182+
since=since,
183+
before=before,
184+
start_date=start_date,
185+
end_date=end_date,
186+
)
187+
payload = payload_schema.model_dump(mode="json", exclude_none=True)
188+
189+
response = self.client.get(url=f"{self.prefix}/time_entries", params=payload)
190+
self.raise_for_status(response)
191+
192+
response_schema = MeTimeEntryWithMetaResponse if meta else MeTimeEntryResponse
193+
194+
response_body = response.json()
195+
return [response_schema.model_validate(time_entry) for time_entry in response_body]
196+
197+
def get_web_timer(self) -> MeWebTimerResponse:
198+
response = self.client.get(url=f"{self.prefix}/web-timer")
199+
self.raise_for_status(response)
200+
201+
response_body = response.json()
202+
return MeWebTimerResponse.model_validate(response_body)

0 commit comments

Comments
 (0)