diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 2649efa..0a0f7c9 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -15,6 +15,7 @@ concurrency: env: PYTHON_VERSION: "3.8" POETRY_VERSION: "1.8.3" + RUFF_VERSION: "0.5.4" jobs: lint: @@ -27,30 +28,14 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} - # Cache Poetry dependencies - cache: 'pip' - - - name: Install Poetry - # pipx and pip with venv do not work - run: pip install poetry==${{ env.POETRY_VERSION }} - - - name: Restore dependencies from cache - uses: actions/cache@v4 - with: - path: ~/.cache/pypoetry - key: dependencies-cache-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ env.POETRY_VERSION }} - restore-keys: | - dependencies-cache-${{ runner.os }}-${{ env.PYTHON_VERSION }}- - name: Install dependencies - if: steps.setup-python.outputs.cache-hit != 'true' - # Disable venv to enable use cached dependencies run: | - poetry config virtualenvs.create false - poetry install --no-root --no-interaction + pip install --upgrade pip + pip install ruff==${{ env.RUFF_VERSION }} - name: Run Ruff - run: poetry run ruff check --output-format=github . + run: ruff check --output-format=github . test: runs-on: ubuntu-latest diff --git a/tests/conftest.py b/tests/conftest.py index 99ae936..75d7db2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,7 @@ from respx import mock as respx_mock from toggl_python.api import ROOT_URL from toggl_python.auth import TokenAuth +from toggl_python.entities.report_time_entry import REPORT_ROOT_URL, ReportTimeEntry from toggl_python.entities.user import CurrentUser from toggl_python.entities.workspace import Workspace @@ -17,6 +18,12 @@ def response_mock() -> Generator[MockRouter, None, None]: yield mock_with_base_url +@pytest.fixture() +def response_report_mock() -> Generator[MockRouter, None, None]: + with respx_mock(base_url=REPORT_ROOT_URL) as mock_with_base_url: + yield mock_with_base_url + + @pytest.fixture() def authed_current_user() -> CurrentUser: auth = TokenAuth(token=FAKE_TOKEN) @@ -29,3 +36,10 @@ def authed_workspace() -> Workspace: auth = TokenAuth(token=FAKE_TOKEN) return Workspace(auth=auth) + + +@pytest.fixture() +def authed_report_time_entry() -> ReportTimeEntry: + auth = TokenAuth(token=FAKE_TOKEN) + + return ReportTimeEntry(auth=auth) diff --git a/tests/responses/report_time_entry_post.py b/tests/responses/report_time_entry_post.py new file mode 100644 index 0000000..16d7c80 --- /dev/null +++ b/tests/responses/report_time_entry_post.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Dict, List, Union + + +SEARCH_REPORT_TIME_ENTRY_RESPONSE: Dict[str, Union[bool, None, str, int, List]] = { + "billable": False, + "billable_amount_in_cents": None, + "currency": "USD", + "description": "sample description", + "hourly_rate_in_cents": None, + "project_id": 202793182, + "row_number": 1, + "tag_ids": [16501871], + "task_id": None, + "time_entries": [ + { + "at": "2024-07-30T08:14:38+00:00", + "at_tz": "2024-07-30T11:14:38+03:00", + "id": 3545645770, + "seconds": 52, + "start": "2024-07-30T11:13:46+03:00", + "stop": "2024-07-30T11:14:38+03:00", + } + ], + "user_id": 30809356, + "username": "test user", +} diff --git a/tests/test_report_time_entry.py b/tests/test_report_time_entry.py new file mode 100644 index 0000000..01fe0e1 --- /dev/null +++ b/tests/test_report_time_entry.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Dict, Union + +import pytest +from httpx import Response +from pydantic import ValidationError +from toggl_python.schemas.report_time_entry import SearchReportTimeEntriesResponse + +from tests.responses.report_time_entry_post import SEARCH_REPORT_TIME_ENTRY_RESPONSE + + +if TYPE_CHECKING: + from respx import MockRouter + from toggl_python.entities.report_time_entry import ReportTimeEntry + + +def test_search_report_time_entries__without_params( + authed_report_time_entry: ReportTimeEntry, +) -> None: + error_message = "At least one parameter must be set" + + with pytest.raises(ValidationError, match=error_message): + _ = authed_report_time_entry.search(workspace_id=123) + + +@pytest.mark.parametrize( + argnames="request_body, start_date, end_date", + argvalues=( + ( + {"start_date": "2020-06-10T00:00:00+00:00", "end_date": "2020-10-01T00:00:00+00:00"}, + datetime(2020, 6, 10, tzinfo=timezone.utc), + datetime(2020, 10, 1, tzinfo=timezone.utc), + ), + ( + {"start_date": "2023-09-12T00:00:00-03:00", "end_date": "2023-10-12T00:00:00-01:00"}, + "2023-09-12T00:00:00-03:00", + "2023-10-12T00:00:00-01:00", + ), + ), +) +def test_search_report_time_entries__with_start_and_end_date( + request_body: Dict[str, str], + start_date: Union[datetime, str], + end_date: Union[datetime, str], + response_report_mock: MockRouter, + authed_report_time_entry: ReportTimeEntry, +) -> None: + fake_workspace_id = 123 + uri = f"/{fake_workspace_id}/search/time_entries" + mocked_route = response_report_mock.post(uri, json=request_body).mock( + return_value=Response(status_code=200, json=[SEARCH_REPORT_TIME_ENTRY_RESPONSE]), + ) + expected_result = [ + SearchReportTimeEntriesResponse.model_validate(SEARCH_REPORT_TIME_ENTRY_RESPONSE) + ] + + result = authed_report_time_entry.search( + workspace_id=fake_workspace_id, start_date=start_date, end_date=end_date + ) + + assert mocked_route.called is True + assert result == expected_result + + +def test_search_report_time_entries__with_all_params( + response_report_mock: MockRouter, + authed_report_time_entry: ReportTimeEntry, +) -> None: + fake_workspace_id = 123 + request_body = { + "start_date": "2021-12-20T00:00:00+00:00", + "end_date": "2021-12-30T00:00:00+00:00", + "user_ids": [30809356], + "project_ids": [202793182], + "page_size": 10, + "first_row_number": 11, + } + uri = f"/{fake_workspace_id}/search/time_entries" + mocked_route = response_report_mock.post(uri, json=request_body).mock( + return_value=Response(status_code=200, json=[SEARCH_REPORT_TIME_ENTRY_RESPONSE]), + ) + expected_result = [ + SearchReportTimeEntriesResponse.model_validate(SEARCH_REPORT_TIME_ENTRY_RESPONSE) + ] + + result = authed_report_time_entry.search( + workspace_id=fake_workspace_id, + start_date=request_body["start_date"], + end_date=request_body["end_date"], + user_ids=request_body["user_ids"], + project_ids=request_body["project_ids"], + page_size=request_body["page_size"], + page_number=1, + ) + + assert mocked_route.called is True + assert result == expected_result diff --git a/toggl_python/api.py b/toggl_python/api.py index b11dfa2..feed985 100644 --- a/toggl_python/api.py +++ b/toggl_python/api.py @@ -15,9 +15,9 @@ class ApiWrapper: - def __init__(self, auth: BasicAuth | TokenAuth) -> None: + def __init__(self, auth: BasicAuth | TokenAuth, base_url: str = ROOT_URL) -> None: self.client = Client( - base_url=ROOT_URL, + base_url=base_url, auth=auth, headers=COMMON_HEADERS, http2=True, diff --git a/toggl_python/entities/report_time_entry.py b/toggl_python/entities/report_time_entry.py new file mode 100644 index 0000000..6d6af92 --- /dev/null +++ b/toggl_python/entities/report_time_entry.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, List, Optional, Union + +from toggl_python.api import ApiWrapper +from toggl_python.schemas.report_time_entry import ( + SearchReportTimeEntriesRequest, + SearchReportTimeEntriesResponse, +) + + +if TYPE_CHECKING: + from datetime import datetime + + from toggl_python.auth import BasicAuth, TokenAuth + +REPORT_ROOT_URL: str = "https://api.track.toggl.com/reports/api/v3/workspace" +DEFAULT_PAGE_SIZE: int = 50 + + +class ReportTimeEntry(ApiWrapper): + def __init__(self, auth: Union[BasicAuth, TokenAuth]) -> None: + super().__init__(auth, base_url=REPORT_ROOT_URL) + + def search( + self, + workspace_id: int, + start_date: Union[datetime, str, None] = None, + end_date: Union[datetime, str, None] = None, + user_ids: Optional[List[int]] = None, + project_ids: Optional[List[int]] = None, + page_size: Optional[int] = None, + page_number: Optional[int] = None, + ) -> List[SearchReportTimeEntriesResponse]: + """Return TimeEntries grouped by common values.""" + # API does not support page number but allows to specify first row number on current page + # So pagination is achieved by changing its value + if page_number: + current_page_size = page_size or DEFAULT_PAGE_SIZE + first_row_number = page_number * current_page_size + 1 + else: + first_row_number = None + + payload_schema = SearchReportTimeEntriesRequest( + start_date=start_date, + end_date=end_date, + user_ids=user_ids, + project_ids=project_ids, + page_size=page_size, + first_row_number=first_row_number, + ) + payload = payload_schema.model_dump(mode="json", exclude_none=True, exclude_unset=True) + + response = self.client.post(url=f"/{workspace_id}/search/time_entries", json=payload) + self.raise_for_status(response) + + response_body = response.json() + return [ + SearchReportTimeEntriesResponse.model_validate(report_time_entry_data) + for report_time_entry_data in response_body + ] diff --git a/toggl_python/schemas/report_time_entry.py b/toggl_python/schemas/report_time_entry.py new file mode 100644 index 0000000..e26352d --- /dev/null +++ b/toggl_python/schemas/report_time_entry.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Dict, List, Optional + +from pydantic import AwareDatetime, field_serializer, model_validator + +from toggl_python.schemas.base import BaseSchema + + +class SearchReportTimeEntriesResponse(BaseSchema): + billable: bool + billable_amount_in_cents: Optional[int] + currency: str + description: Optional[str] + hourly_rate_in_cents: Optional[int] + project_id: Optional[int] + row_number: int + tag_ids: List[int] + task_id: Optional[int] + time_entries: List[ReportTimeEntryItem] + user_id: int + username: str + + +class ReportTimeEntryItem(BaseSchema): + at: AwareDatetime + at_tz: AwareDatetime + id: int + seconds: int + start: AwareDatetime + stop: AwareDatetime + + +class SearchReportTimeEntriesRequest(BaseSchema): + start_date: Optional[AwareDatetime] = None + end_date: Optional[AwareDatetime] = None + project_ids: Optional[List[int]] = None + user_ids: Optional[List[int]] = None + page_size: Optional[int] = None + first_row_number: Optional[int] = None + + @model_validator(mode="before") + @classmethod + def check_if_at_least_one_param_is_set(cls, data: Dict[str, str]) -> Dict[str, str]: + if any(data.values()): + return data + + error_message = "At least one parameter must be set" + raise ValueError(error_message) + + @field_serializer("start_date", "end_date", when_used="json") + def serialize_datetimes(self, value: Optional[datetime]) -> Optional[str]: + if not value: + return value + + return value.isoformat()