Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#55: Implement searching for ReportTImeEntries #57

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 4 additions & 19 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ concurrency:
env:
PYTHON_VERSION: "3.8"
POETRY_VERSION: "1.8.3"
RUFF_VERSION: "0.5.4"

jobs:
lint:
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -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)
28 changes: 28 additions & 0 deletions tests/responses/report_time_entry_post.py
Original file line number Diff line number Diff line change
@@ -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",
}
99 changes: 99 additions & 0 deletions tests/test_report_time_entry.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions toggl_python/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
61 changes: 61 additions & 0 deletions toggl_python/entities/report_time_entry.py
Original file line number Diff line number Diff line change
@@ -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
]
57 changes: 57 additions & 0 deletions toggl_python/schemas/report_time_entry.py
Original file line number Diff line number Diff line change
@@ -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()