Skip to content

Commit

Permalink
#80: Add workspace updating (#92)
Browse files Browse the repository at this point in the history
* Remove deprecated fields from WorkspaceResponse schema
* Remove check-pr-diff-size pre-commit hook
  • Loading branch information
nifadyev authored Oct 9, 2024
1 parent 989fd51 commit 5702052
Show file tree
Hide file tree
Showing 6 changed files with 213 additions and 11 deletions.
7 changes: 0 additions & 7 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,3 @@ repos:
stages: [pre-push]
language: python
types: [python]
- id: check-pr-size
name: check-pr-size
entry: chmod +x scripts/large-pr-checker.sh && ./large-pr-checker.sh
pass_filenames: false
stages: [pre-push]
language: system
types: [python]
71 changes: 71 additions & 0 deletions tests/factories/workspace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from __future__ import annotations

from typing import Dict, List, Optional, Set, Union

from tests.conftest import fake
from tests.factories.base import datetime_repr_factory


try:
import zoneinfo
except ImportError:
from backports import zoneinfo


def workspace_request_factory(
exclude: Optional[Set[str]] = None,
) -> Dict[str, Union[str, bool, List[int]]]:
request = {
"admins": [fake.random_int() for _ in range(fake.random_int(max=5))],
"name": fake.text(max_nb_chars=139),
"only_admins_may_create_tags": fake.boolean(),
"only_admins_see_team_dashboard": fake.boolean(),
"reports_collapse": fake.boolean(),
}

if exclude:
for excluded_field in exclude:
del request[excluded_field]

return request


def workspace_response_factory(
workspace_id: Optional[int] = None,
) -> Dict[str, Union[str, bool, int, None]]:
timezone_name = fake.timezone()
timezone = zoneinfo.ZoneInfo(timezone_name)

return {
"admin": fake.boolean(),
"at": datetime_repr_factory(timezone),
"business_ws": fake.boolean(),
"csv_upload": None,
"default_currency": fake.currency_code(),
"default_hourly_rate": str(fake.pyfloat()) if fake.boolean() else None,
"hide_start_end_times": fake.boolean(),
"ical_enabled": fake.boolean(),
"ical_url": fake.url() if fake.boolean() else None,
"id": workspace_id or fake.random_int(),
"last_modified": datetime_repr_factory(timezone) if fake.boolean() else None,
"logo_url": fake.image_url(),
"name": fake.text(max_nb_chars=139),
"only_admins_may_create_projects": fake.boolean(),
"only_admins_may_create_tags": fake.boolean(),
"only_admins_see_billable_rates": fake.boolean(),
"only_admins_see_team_dashboard": fake.boolean(),
"organization_id": 8364520,
"permissions": None,
"premium": fake.boolean(),
"projects_billable_by_default": fake.boolean(),
"projects_enforce_billable": fake.boolean(),
"projects_private_by_default": fake.boolean(),
"rate_last_updated": datetime_repr_factory(timezone) if fake.boolean() else None,
"reports_collapse": fake.boolean(),
"role": "admin",
"rounding": fake.random_element(elements=(-1, 0, 1)),
"rounding_minutes": 0,
"server_deleted_at": datetime_repr_factory(timezone) if fake.boolean() else None,
"suspended_at": datetime_repr_factory(timezone) if fake.boolean() else None,
"working_hours_in_minutes": fake.random_int(min=0, max=59) if fake.boolean() else None,
}
42 changes: 42 additions & 0 deletions tests/integration/test_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

from toggl_python.schemas.workspace import WorkspaceResponse

from tests.conftest import fake
from tests.factories.workspace import workspace_request_factory

# Necessary to mark all tests in module as integration
from tests.integration import pytestmark # noqa: F401 - imported but unused

Expand All @@ -28,3 +31,42 @@ def test_get_workspaces__without_query_params(i_authed_workspace: Workspace)-> N
result = i_authed_workspace.list()

assert result[0].model_fields_set == expected_result


def test_update(i_authed_workspace: Workspace) -> None:
workspace_id = int(os.environ["WORKSPACE_ID"])
excluded_fields = {"admins", "only_admins_may_create_tags"}
full_request_body = workspace_request_factory(exclude=excluded_fields)
random_param = fake.random_element(full_request_body.keys())
request_body = {random_param: full_request_body[random_param]}
workspace = i_authed_workspace.get(workspace_id)
old_param_value = getattr(workspace, random_param)
expected_result = set(WorkspaceResponse.model_fields.keys())

result = i_authed_workspace.update(workspace_id, **request_body)

assert result.model_fields_set == expected_result
assert getattr(result, random_param) != old_param_value

request_body[random_param] = old_param_value
_ = i_authed_workspace.update(workspace_id, **request_body)


def test_update__all_params(i_authed_workspace: Workspace) -> None:
workspace_id = int(os.environ["WORKSPACE_ID"])
# Workspace response model does not return `admins`
# `only_admins_may_create_tags` is available only for premium plan (but available in curl)
excluded_fields = {"admins", "only_admins_may_create_tags"}
request_body = workspace_request_factory(exclude=excluded_fields)
workspace = i_authed_workspace.get(workspace_id)
existing_model_fields = set(request_body.keys()) - excluded_fields
old_params = {
param_name: getattr(workspace, param_name) for param_name in existing_model_fields
}
expected_result = set(WorkspaceResponse.model_fields.keys())

result = i_authed_workspace.update(workspace_id, **request_body)

assert result.model_fields_set == expected_result

_ = i_authed_workspace.update(workspace_id, **old_params)
52 changes: 52 additions & 0 deletions tests/test_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from pydantic import ValidationError
from toggl_python.schemas.workspace import WorkspaceResponse

from tests.conftest import fake
from tests.factories.workspace import workspace_request_factory, workspace_response_factory
from tests.responses.workspace_get import WORKSPACE_RESPONSE


Expand Down Expand Up @@ -84,3 +86,53 @@ def test_get_workspaces__too_old_since_value(

with pytest.raises(ValidationError, match=error_message):
_ = authed_workspace.list(since=since)


@pytest.mark.parametrize(
argnames="workspace_name, error_message",
argvalues=(
("", "String should have at least 1 character"),
(fake.pystr(min_chars=140, max_chars=200), "String should have at most 140 character"),
),
)
def test_update__invalid_workspace_name(
workspace_name: str, error_message: str, authed_workspace: Workspace
) -> None:
workspace_id = fake.random_int()

with pytest.raises(ValidationError, match=error_message):
_ = authed_workspace.update(workspace_id, name=workspace_name)


def test_update(response_mock: MockRouter, authed_workspace: Workspace) -> None:
workspace_id = fake.random_int()
full_request_body = workspace_request_factory()
random_param = fake.random_element(full_request_body.keys())
request_body = {random_param: full_request_body[random_param]}
response = workspace_response_factory()
mocked_route = response_mock.put(f"/workspaces/{workspace_id}", json=request_body).mock(
return_value=HttpxResponse(status_code=200, json=response),
)
expected_result = WorkspaceResponse.model_validate(response)

result = authed_workspace.update(workspace_id, **request_body)

assert mocked_route.called is True
assert result == expected_result


def test_update__all_params(
response_mock: MockRouter, authed_workspace: Workspace
) -> None:
workspace_id = fake.random_int()
request_body = workspace_request_factory()
response = workspace_response_factory(workspace_id)
mocked_route = response_mock.put(f"/workspaces/{workspace_id}", json=request_body).mock(
return_value=HttpxResponse(status_code=200, json=response),
)
expected_result = WorkspaceResponse.model_validate(response)

result = authed_workspace.update(workspace_id, **request_body)

assert mocked_route.called is True
assert result == expected_result
39 changes: 38 additions & 1 deletion toggl_python/entities/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@
TimeEntryCreateRequest,
TimeEntryRequest,
)
from toggl_python.schemas.workspace import GetWorkspacesQueryParams, WorkspaceResponse
from toggl_python.schemas.workspace import (
GetWorkspacesQueryParams,
UpdateWorkspaceRequest,
WorkspaceResponse,
)


if TYPE_CHECKING:
Expand Down Expand Up @@ -45,6 +49,39 @@ def list(self, since: Union[int, datetime, None] = None) -> List[WorkspaceRespon
WorkspaceResponse.model_validate(workspace_data) for workspace_data in response_body
]

def update(
self,
workspace_id: int,
admins: Optional[List[int]] = None,
only_admins_may_create_tags: Optional[bool] = None,
only_admins_see_team_dashboard: Optional[bool] = None,
reports_collapse: Optional[bool] = None,
name: Optional[str] = None,
) -> WorkspaceResponse:
"""Allow to update Workspace instance fields which are available on free plan.
Request body parameters `default_hourly_rate`, `default_currency`, `rounding`,
`rounding_minutes`, `only_admins_see_billable_rates`, `projects_billable_by_default`,
`rate_change_mode`, `project_private_by_default`, `projects_enforce_billable` are
available only on paid plan. That is why they are not listed in method arguments.
"""
request_body_schema = UpdateWorkspaceRequest(
admins=admins,
only_admins_may_create_tags=only_admins_may_create_tags,
only_admins_see_team_dashboard=only_admins_see_team_dashboard,
reports_collapse=reports_collapse,
name=name,
)
request_body = request_body_schema.model_dump(
mode="json", exclude_none=True, exclude_unset=True
)

response = self.client.put(url=f"{self.prefix}/{workspace_id}", json=request_body)
self.raise_for_status(response)

response_body = response.json()
return WorkspaceResponse.model_validate(response_body)

def create_project( # noqa: PLR0913 - Too many arguments in function definition
self,
workspace_id: int,
Expand Down
13 changes: 10 additions & 3 deletions toggl_python/schemas/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

class WorkspaceResponseBase(BaseSchema):
admin: bool
api_token: Optional[str] = Field(default=None, deprecated=True)
at: datetime
business_ws: bool = Field(description="Is workspace on Premium subscription")
csv_upload: Optional[List]
Expand All @@ -30,7 +29,6 @@ class WorkspaceResponseBase(BaseSchema):
organization_id: int
permissions: Optional[List[str]]
premium: bool
profile: int = Field(deprecated=True)
projects_billable_by_default: bool
projects_enforce_billable: bool
projects_private_by_default: bool
Expand All @@ -40,12 +38,21 @@ class WorkspaceResponseBase(BaseSchema):
rounding: int = Field(le=1, ge=-1)
rounding_minutes: int
server_deleted_at: Optional[datetime]
subscription: Optional[List]
suspended_at: Optional[datetime]
working_hours_in_minutes: Optional[int]


class WorkspaceResponse(WorkspaceResponseBase):
pass


class GetWorkspacesQueryParams(SinceParamSchemaMixin, BaseSchema):
pass


class UpdateWorkspaceRequest(BaseSchema):
admins: Optional[List[int]] = None
only_admins_may_create_tags: Optional[bool] = None
only_admins_see_team_dashboard: Optional[bool] = None
reports_collapse: Optional[bool] = None
name: Optional[str] = Field(default=None, min_length=1, max_length=140)

0 comments on commit 5702052

Please sign in to comment.