diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9c03f31..e187218 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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] diff --git a/tests/factories/workspace.py b/tests/factories/workspace.py new file mode 100644 index 0000000..15fbfaf --- /dev/null +++ b/tests/factories/workspace.py @@ -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, + } diff --git a/tests/integration/test_workspace.py b/tests/integration/test_workspace.py index bb9df20..a68974f 100644 --- a/tests/integration/test_workspace.py +++ b/tests/integration/test_workspace.py @@ -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 @@ -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) diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 4ca3434..d189a9f 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -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 @@ -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 diff --git a/toggl_python/entities/workspace.py b/toggl_python/entities/workspace.py index 0dabd03..eae2deb 100644 --- a/toggl_python/entities/workspace.py +++ b/toggl_python/entities/workspace.py @@ -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: @@ -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, diff --git a/toggl_python/schemas/workspace.py b/toggl_python/schemas/workspace.py index 0494ba8..260eec1 100644 --- a/toggl_python/schemas/workspace.py +++ b/toggl_python/schemas/workspace.py @@ -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] @@ -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 @@ -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)