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

#58: Allow fetching and listing Projects #61

Merged
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
38 changes: 38 additions & 0 deletions tests/responses/project_get.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from __future__ import annotations

from typing import Dict, List, Union


PROJECT_RESPONSE: Dict[str, Union[str, bool, int, None, List]] = {
"active": True,
"actual_hours": 0,
"actual_seconds": 83,
"at": "2024-05-16T12:40:29+00:00",
"auto_estimates": None,
"billable": False,
"can_track_time": True,
"cid": None,
"client_id": None,
"color": "#c9806b",
"created_at": "2024-05-16T12:40:29+00:00",
"currency": None,
"estimated_hours": None,
"estimated_seconds": None,
"fixed_fee": None,
"id": 202793181,
"is_private": True,
"is_shared": False,
"name": "test project",
"permissions": None,
"rate": None,
"rate_last_updated": None,
"recurring": False,
"recurring_parameters": None,
"server_deleted_at": None,
"start_date": "2024-05-16",
"status": "active",
"template": None,
"template_id": None,
"wid": 43644207,
"workspace_id": 43644207
}
99 changes: 99 additions & 0 deletions tests/test_project.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
from unittest.mock import Mock, patch

import pytest
from httpx import Response as HttpxResponse
from pydantic import ValidationError
from toggl_python.schemas.project import ProjectResponse

from tests.responses.project_get import PROJECT_RESPONSE


if TYPE_CHECKING:
from respx import MockRouter
from toggl_python.entities.workspace import Workspace


def test_get_project_by_id(response_mock: MockRouter, authed_workspace: Workspace) -> None:
workspace_id = 123
project_id = 123
mocked_route = response_mock.get(f"/workspaces/{workspace_id}/projects/{project_id}").mock(
return_value=HttpxResponse(status_code=200, json=PROJECT_RESPONSE),
)
expected_result = ProjectResponse.model_validate(PROJECT_RESPONSE)

result = authed_workspace.get_project(workspace_id=workspace_id, project_id=project_id)

assert mocked_route.called is True
assert result == expected_result


def test_get_projects__without_query_params(
response_mock: MockRouter, authed_workspace: Workspace
) -> None:
workspace_id = 123
mocked_route = response_mock.get(f"/workspaces/{workspace_id}/projects").mock(
return_value=HttpxResponse(status_code=200, json=[PROJECT_RESPONSE]),
)
expected_result = [ProjectResponse.model_validate(PROJECT_RESPONSE)]

result = authed_workspace.get_projects(workspace_id=workspace_id)

assert mocked_route.called is True
assert result == expected_result


@patch("toggl_python.schemas.base.datetime")
def test_get_projects__too_old_since_value(
mocked_datetime: Mock, authed_workspace: Workspace
) -> None:
error_message = "Since cannot be older than 3 months"
since = datetime(2020, 1, 1, tzinfo=timezone.utc)
mocked_datetime.now.return_value = datetime(2020, 4, 1, tzinfo=timezone.utc)

with pytest.raises(ValidationError, match=error_message):
_ = authed_workspace.get_projects(workspace_id=123, since=since)


@patch("toggl_python.schemas.base.datetime")
@pytest.mark.parametrize(
argnames="query_params",
argvalues=(
{"active": False},
{"since": int(datetime(2024, 5, 10, tzinfo=timezone.utc).timestamp())},
{"billable": True},
{"user_ids": [1234567]},
{"client_ids": [209327532]},
{"group_ids": [214327]},
{"statuses": "active"},
{"name": "random project name"},
{"page": 1},
{"per_page": 10},
{"sort_field": "billable"},
{"sort_order": "DESC"},
{"only_templates": True},
{"only_me": True},
),
)
def test_get_projects__with_query_params(
mocked_datetime: Mock,
query_params: Dict[str, Union[str, int]],
response_mock: MockRouter,
authed_workspace: Workspace,
) -> None:
mocked_datetime.now.return_value = datetime(2024, 7, 20, tzinfo=timezone.utc)
workspace_id = 123
mocked_route = response_mock.get(
f"/workspaces/{workspace_id}/projects", params=query_params
).mock(
return_value=HttpxResponse(status_code=200, json=[PROJECT_RESPONSE]),
)
expected_result = [ProjectResponse.model_validate(PROJECT_RESPONSE)]

result = authed_workspace.get_projects(workspace_id=workspace_id, **query_params)

assert mocked_route.called is True
assert result == expected_result
4 changes: 2 additions & 2 deletions tests/test_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def test_get_workspaces__without_query_params(
assert result == expected_result


@patch("toggl_python.schemas.workspace.datetime")
@patch("toggl_python.schemas.base.datetime")
@pytest.mark.parametrize(
argnames="query_params, method_kwargs",
argvalues=(
Expand Down Expand Up @@ -74,7 +74,7 @@ def test_get_workspaces__with_query_param_since(
assert result == expected_result


@patch("toggl_python.schemas.workspace.datetime")
@patch("toggl_python.schemas.base.datetime")
def test_get_workspaces__too_old_since_value(
mocked_datetime: Mock, authed_workspace: Workspace
) -> None:
Expand Down
54 changes: 53 additions & 1 deletion toggl_python/entities/workspace.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations

from typing import TYPE_CHECKING, List, Union
from typing import TYPE_CHECKING, List, Optional, Union

from toggl_python.api import ApiWrapper
from toggl_python.schemas.project import ProjectQueryParams, ProjectResponse
from toggl_python.schemas.workspace import GetWorkspacesQueryParams, WorkspaceResponse


Expand Down Expand Up @@ -33,3 +34,54 @@ def list(self, since: Union[int, datetime, None] = None) -> List[WorkspaceRespon
return [
WorkspaceResponse.model_validate(workspace_data) for workspace_data in response_body
]

def get_project(self, workspace_id: int, project_id: int) -> ProjectResponse:
response = self.client.get(url=f"{self.prefix}/{workspace_id}/projects/{project_id}")
self.raise_for_status(response)

response_body = response.json()

return ProjectResponse.model_validate(response_body)

def get_projects( # noqa: PLR0913 - Too many arguments in function definition (15 > 12)
self,
workspace_id: int,
active: Optional[bool] = None,
billable: Optional[bool] = None,
user_ids: Optional[List[int]] = None,
client_ids: Optional[List[int]] = None,
group_ids: Optional[List[int]] = None,
statuses: Optional[str] = None,
since: Union[int, datetime, None] = None,
name: Optional[str] = None,
page: Optional[int] = None,
per_page: Optional[int] = None,
sort_field: Optional[str] = None,
sort_order: Optional[str] = None,
only_templates: Optional[bool] = None,
only_me: Optional[bool] = None,
) -> List[ProjectResponse]:
payload_schema = ProjectQueryParams(
active=active,
billable=billable,
user_ids=user_ids,
client_ids=client_ids,
group_ids=group_ids,
statuses=statuses,
since=since,
name=name,
page=page,
per_page=per_page,
sort_field=sort_field,
sort_order=sort_order,
only_templates=only_templates,
only_me=only_me,
)
payload = payload_schema.model_dump(mode="json", exclude_none=True)

response = self.client.get(url=f"{self.prefix}/{workspace_id}/projects", params=payload)
self.raise_for_status(response)

response_body = response.json()

return [ProjectResponse.model_validate(project_data) for project_data in response_body]
34 changes: 33 additions & 1 deletion toggl_python/schemas/base.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
from pydantic import BaseModel
from __future__ import annotations

from datetime import datetime, timedelta, timezone
from typing import Optional

from pydantic import AwareDatetime, BaseModel, field_serializer, field_validator


class BaseSchema(BaseModel):
pass


class SinceParamSchemaMixin(BaseSchema):
since: Optional[AwareDatetime]

@field_validator("since")
@classmethod
def check_if_since_is_too_old(cls, value: Optional[datetime]) -> Optional[datetime]:
if not value:
return value

now = datetime.now(tz=timezone.utc)
three_months = timedelta(days=90)
utc_value = value.astimezone(tz=timezone.utc)

if now - three_months > utc_value:
error_message = "Since cannot be older than 3 months"
raise ValueError(error_message)

return value

@field_serializer("since", when_used="json")
def serialize_since(self, value: Optional[datetime]) -> Optional[int]:
if not value:
return value

return int(value.timestamp())
54 changes: 54 additions & 0 deletions toggl_python/schemas/project.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from __future__ import annotations

from datetime import datetime
from typing import List, Optional

from toggl_python.schemas.base import BaseSchema, SinceParamSchemaMixin


class ProjectResponse(BaseSchema):
active: bool
actual_hours: int
actual_seconds: int
at: datetime
auto_estimates: Optional[bool]
billable: bool
can_track_time: bool
client_id: Optional[int]
color: str
created_at: datetime
currency: Optional[str]
estimated_hours: Optional[int]
estimated_seconds: Optional[int]
fixed_fee: Optional[int]
id: int
is_private: bool
is_shared: bool
name: str
permissions: Optional[str]
rate: Optional[int]
rate_last_updated: Optional[datetime]
recurring: bool
recurring_parameters: Optional[List]
server_deleted_at: Optional[datetime]
start_date: datetime
status: str
template: Optional[bool]
template_id: Optional[int]
workspace_id: int


class ProjectQueryParams(SinceParamSchemaMixin, BaseSchema):
active: Optional[bool]
billable: Optional[bool]
user_ids: Optional[List[int]]
client_ids: Optional[List[int]]
group_ids: Optional[List[int]]
statuses: Optional[str]
name: Optional[str]
page: Optional[int]
per_page: Optional[int]
sort_field: Optional[str]
sort_order: Optional[str]
only_templates: Optional[bool]
only_me: Optional[bool]
32 changes: 4 additions & 28 deletions toggl_python/schemas/workspace.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
from __future__ import annotations

from datetime import datetime, timedelta, timezone
from datetime import datetime
from typing import List, Optional

from pydantic import AwareDatetime, field_serializer, field_validator
from pydantic.fields import Field

from toggl_python.schemas.base import BaseSchema
from toggl_python.schemas.base import BaseSchema, SinceParamSchemaMixin


class WorkspaceResponseBase(BaseSchema):
Expand Down Expand Up @@ -48,28 +47,5 @@ class WorkspaceResponseBase(BaseSchema):
class WorkspaceResponse(WorkspaceResponseBase):
pass

class GetWorkspacesQueryParams(BaseSchema):
since: Optional[AwareDatetime] = None

@field_validator("since")
@classmethod
def check_if_since_is_too_old(cls, value: Optional[datetime]) -> Optional[datetime]:
if not value:
return value

now = datetime.now(tz=timezone.utc)
three_months = timedelta(days=90)
utc_value = value.astimezone(tz=timezone.utc)

if now - three_months > utc_value:
error_message = "Since cannot be older than 3 months"
raise ValueError(error_message)

return value

@field_serializer("since", when_used="json")
def serialize_since(self, value: Optional[datetime]) -> Optional[int]:
if not value:
return value

return int(value.timestamp())
class GetWorkspacesQueryParams(SinceParamSchemaMixin, BaseSchema):
pass