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

#80: Add workspace updating #92

Merged
merged 4 commits into from
Oct 9, 2024
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
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)