From 6ee75775f5729dab04004f6fb469df653dea4dd7 Mon Sep 17 00:00:00 2001 From: Saurabhkmr98 Date: Fri, 14 Nov 2025 19:13:48 +0530 Subject: [PATCH 1/2] add clients for project ws features + stickies + initiatives + teamspaces --- .gitignore | 1 + README.md | 3 + plane/__init__.py | 8 + plane/api/__init__.py | 6 + plane/api/base_resource.py | 6 +- plane/api/initiatives/__init__.py | 4 + plane/api/initiatives/base.py | 98 ++++++++++ plane/api/initiatives/epics.py | 60 ++++++ plane/api/initiatives/labels.py | 139 ++++++++++++++ plane/api/initiatives/projects.py | 63 +++++++ plane/api/projects.py | 26 +++ plane/api/stickies.py | 83 +++++++++ plane/api/teamspaces/__init__.py | 4 + plane/api/teamspaces/base.py | 96 ++++++++++ plane/api/teamspaces/members.py | 63 +++++++ plane/api/teamspaces/projects.py | 63 +++++++ plane/api/workspaces.py | 20 ++ plane/client/plane_client.py | 6 + plane/models/enums.py | 9 + plane/models/initiatives.py | 107 +++++++++++ plane/models/projects.py | 13 ++ plane/models/stickies.py | 62 ++++++ plane/models/teamspaces.py | 50 +++++ plane/models/users.py | 9 + plane/models/workspaces.py | 13 ++ tests/unit/test_initiatives.py | 300 ++++++++++++++++++++++++++++++ tests/unit/test_projects.py | 35 +++- tests/unit/test_stickies.py | 94 ++++++++++ tests/unit/test_teamspaces.py | 205 ++++++++++++++++++++ tests/unit/test_workspaces.py | 23 +++ 30 files changed, 1663 insertions(+), 6 deletions(-) create mode 100644 plane/api/initiatives/__init__.py create mode 100644 plane/api/initiatives/base.py create mode 100644 plane/api/initiatives/epics.py create mode 100644 plane/api/initiatives/labels.py create mode 100644 plane/api/initiatives/projects.py create mode 100644 plane/api/stickies.py create mode 100644 plane/api/teamspaces/__init__.py create mode 100644 plane/api/teamspaces/base.py create mode 100644 plane/api/teamspaces/members.py create mode 100644 plane/api/teamspaces/projects.py create mode 100644 plane/models/initiatives.py create mode 100644 plane/models/stickies.py create mode 100644 plane/models/teamspaces.py create mode 100644 plane/models/workspaces.py create mode 100644 tests/unit/test_initiatives.py create mode 100644 tests/unit/test_stickies.py create mode 100644 tests/unit/test_teamspaces.py diff --git a/.gitignore b/.gitignore index 174595c..150cf00 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ coverage.xml .hypothesis/ venv/ .venv/ +.env .python-version .pytest_cache diff --git a/README.md b/README.md index 7759fc6..d5aacf5 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,9 @@ client.epics # Epic management client.intake # Intake management client.pages # Page management client.customers # Customer management +client.teamspaces # Teamspace management +client.stickies # Sticky management +client.initiatives # Initiative management ``` ### Resource Organization diff --git a/plane/__init__.py b/plane/__init__.py index 9a7d330..f0123ae 100644 --- a/plane/__init__.py +++ b/plane/__init__.py @@ -1,13 +1,17 @@ from .api.cycles import Cycles +from .api.initiatives import Initiatives from .api.labels import Labels from .api.modules import Modules from .api.pages import Pages from .api.projects import Projects from .api.states import States +from .api.stickies import Stickies +from .api.teamspaces import Teamspaces from .api.users import Users from .api.work_item_properties import WorkItemProperties from .api.work_item_types import WorkItemTypes from .api.work_items import WorkItems +from .api.workspaces import Workspaces from .client import ( OAuthAuthorizationParams, OAuthClient, @@ -30,10 +34,14 @@ "Projects", "Labels", "States", + "Stickies", + "Initiatives", + "Teamspaces", "Users", "Modules", "Cycles", "Pages", + "Workspaces", "PlaneError", "ConfigurationError", "HttpError", diff --git a/plane/api/__init__.py b/plane/api/__init__.py index ec7c9f6..4bdac1d 100644 --- a/plane/api/__init__.py +++ b/plane/api/__init__.py @@ -1,5 +1,8 @@ from .base_resource import BaseResource from .customers import Customers +from .initiatives import Initiatives +from .stickies import Stickies +from .teamspaces import Teamspaces from .work_item_properties import WorkItemProperties from .work_items import WorkItems @@ -8,4 +11,7 @@ "WorkItems", "WorkItemProperties", "Customers", + "Stickies", + "Initiatives", + "Teamspaces", ] diff --git a/plane/api/base_resource.py b/plane/api/base_resource.py index b8f6a1e..b0a711b 100644 --- a/plane/api/base_resource.py +++ b/plane/api/base_resource.py @@ -57,9 +57,11 @@ def _patch(self, endpoint: str, data: Mapping[str, Any] | None = None) -> Any: ) return self._handle_response(response) - def _delete(self, endpoint: str) -> None: + def _delete(self, endpoint: str, data: Mapping[str, Any] | None = None) -> None: url = self._build_url(endpoint) - response = self.session.delete(url, headers=self._headers(), timeout=self.config.timeout) + response = self.session.delete( + url, headers=self._headers(), json=data, timeout=self.config.timeout + ) self._handle_response(response) # Helpers diff --git a/plane/api/initiatives/__init__.py b/plane/api/initiatives/__init__.py new file mode 100644 index 0000000..f290736 --- /dev/null +++ b/plane/api/initiatives/__init__.py @@ -0,0 +1,4 @@ +from .base import Initiatives + +__all__ = ["Initiatives"] + diff --git a/plane/api/initiatives/base.py b/plane/api/initiatives/base.py new file mode 100644 index 0000000..faf7eee --- /dev/null +++ b/plane/api/initiatives/base.py @@ -0,0 +1,98 @@ +from collections.abc import Mapping +from typing import Any + +from ...models.initiatives import ( + CreateInitiative, + Initiative, + PaginatedInitiativeResponse, + UpdateInitiative, +) +from ..base_resource import BaseResource +from .epics import InitiativeEpics +from .labels import InitiativeLabels +from .projects import InitiativeProjects + + +class Initiatives(BaseResource): + """API client for managing initiatives in workspaces.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + # Initialize sub-resources + self.labels = InitiativeLabels(config) + self.projects = InitiativeProjects(config) + self.epics = InitiativeEpics(config) + + def create(self, workspace_slug: str, data: CreateInitiative) -> Initiative: + """Create a new initiative in the workspace. + + Args: + workspace_slug: The workspace slug identifier + data: Initiative data + + Returns: + The created initiative + """ + response = self._post( + f"{workspace_slug}/initiatives", + data.model_dump(exclude_none=True), + ) + return Initiative.model_validate(response) + + def retrieve(self, workspace_slug: str, initiative_id: str) -> Initiative: + """Retrieve an initiative by ID. + + Args: + workspace_slug: The workspace slug identifier + initiative_id: UUID of the initiative + + Returns: + The requested initiative + """ + response = self._get(f"{workspace_slug}/initiatives/{initiative_id}") + return Initiative.model_validate(response) + + def update( + self, workspace_slug: str, initiative_id: str, data: UpdateInitiative + ) -> Initiative: + """Update an initiative by ID. + + Args: + workspace_slug: The workspace slug identifier + initiative_id: UUID of the initiative + data: Updated initiative data + + Returns: + The updated initiative + """ + response = self._patch( + f"{workspace_slug}/initiatives/{initiative_id}", + data.model_dump(exclude_none=True), + ) + return Initiative.model_validate(response) + + def delete(self, workspace_slug: str, initiative_id: str) -> None: + """Delete an initiative by ID. + + Args: + workspace_slug: The workspace slug identifier + initiative_id: UUID of the initiative + """ + return self._delete(f"{workspace_slug}/initiatives/{initiative_id}") + + def list( + self, workspace_slug: str, params: Mapping[str, Any] | None = None + ) -> PaginatedInitiativeResponse: + """List initiatives in the workspace with optional filtering. + + Args: + workspace_slug: The workspace slug identifier + params: Optional query parameters (e.g., per_page, cursor) + + Returns: + Paginated list of initiatives + """ + response = self._get(f"{workspace_slug}/initiatives", params=params) + return PaginatedInitiativeResponse.model_validate(response) + diff --git a/plane/api/initiatives/epics.py b/plane/api/initiatives/epics.py new file mode 100644 index 0000000..70ebfc0 --- /dev/null +++ b/plane/api/initiatives/epics.py @@ -0,0 +1,60 @@ +from collections.abc import Iterable, Mapping +from typing import Any + +from ...models.epics import Epic, PaginatedEpicResponse +from ..base_resource import BaseResource + + +class InitiativeEpics(BaseResource): + """API client for managing epics associated with initiatives.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list( + self, workspace_slug: str, initiative_id: str, params: Mapping[str, Any] | None = None + ) -> PaginatedEpicResponse: + """List epics associated with an initiative. + + Args: + workspace_slug: The workspace slug identifier + initiative_id: UUID of the initiative + params: Optional query parameters (e.g., per_page, cursor) + + Returns: + Paginated list of epics + """ + response = self._get(f"{workspace_slug}/initiatives/{initiative_id}/epics", params=params) + return PaginatedEpicResponse.model_validate(response) + + def add( + self, workspace_slug: str, initiative_id: str, epic_ids: Iterable[str] + ) -> Iterable[Epic]: + """Add epics to an initiative. + + Args: + workspace_slug: The workspace slug identifier + initiative_id: UUID of the initiative + epic_ids: List of epic UUIDs to add + + Returns: + List of added epics + """ + response = self._post( + f"{workspace_slug}/initiatives/{initiative_id}/epics", + {"epic_ids": epic_ids}, + ) + return [Epic.model_validate(epic) for epic in response] + + def remove(self, workspace_slug: str, initiative_id: str, epic_ids: Iterable[str]) -> None: + """Remove epics from an initiative. + + Args: + workspace_slug: The workspace slug identifier + initiative_id: UUID of the initiative + epic_ids: List of epic UUIDs to remove + """ + return self._delete( + f"{workspace_slug}/initiatives/{initiative_id}/epics", + {"epic_ids": epic_ids}, + ) diff --git a/plane/api/initiatives/labels.py b/plane/api/initiatives/labels.py new file mode 100644 index 0000000..d327565 --- /dev/null +++ b/plane/api/initiatives/labels.py @@ -0,0 +1,139 @@ +from collections.abc import Iterable, Mapping +from typing import Any + +from ...models.initiatives import ( + CreateInitiativeLabel, + InitiativeLabel, + PaginatedInitiativeLabelResponse, + UpdateInitiativeLabel, +) +from ..base_resource import BaseResource + + +class InitiativeLabels(BaseResource): + """API client for managing labels associated with initiatives.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def create(self, workspace_slug: str, data: CreateInitiativeLabel) -> InitiativeLabel: + """Create a new initiative label in the workspace. + + Args: + workspace_slug: The workspace slug identifier + data: Initiative label data + + Returns: + The created initiative label + """ + response = self._post( + f"{workspace_slug}/initiatives/labels", + data.model_dump(exclude_none=True), + ) + return InitiativeLabel.model_validate(response) + + def retrieve(self, workspace_slug: str, label_id: str) -> InitiativeLabel: + """Retrieve an initiative label by ID. + + Args: + workspace_slug: The workspace slug identifier + label_id: UUID of the initiative label + + Returns: + The requested initiative label + """ + response = self._get(f"{workspace_slug}/initiatives/labels/{label_id}") + return InitiativeLabel.model_validate(response) + + def update( + self, workspace_slug: str, label_id: str, data: UpdateInitiativeLabel + ) -> InitiativeLabel: + """Update an initiative label by ID. + + Args: + workspace_slug: The workspace slug identifier + label_id: UUID of the initiative label + data: Updated initiative label data + + Returns: + The updated initiative label + """ + response = self._patch( + f"{workspace_slug}/initiatives/labels/{label_id}", + data.model_dump(exclude_none=True), + ) + return InitiativeLabel.model_validate(response) + + def delete(self, workspace_slug: str, label_id: str) -> None: + """Delete an initiative label by ID. + + Args: + workspace_slug: The workspace slug identifier + label_id: UUID of the initiative label + """ + return self._delete(f"{workspace_slug}/initiatives/labels/{label_id}") + + def list( + self, workspace_slug: str, params: Mapping[str, Any] | None = None + ) -> PaginatedInitiativeLabelResponse: + """List initiative labels in the workspace with optional filtering. + + Args: + workspace_slug: The workspace slug identifier + params: Optional query parameters (e.g., per_page, cursor) + + Returns: + Paginated list of initiative labels + """ + response = self._get(f"{workspace_slug}/initiatives/labels", params=params) + return PaginatedInitiativeLabelResponse.model_validate(response) + + def list_labels( + self, workspace_slug: str, initiative_id: str, params: Mapping[str, Any] | None = None + ) -> PaginatedInitiativeLabelResponse: + """List labels associated with an initiative. + + Args: + workspace_slug: The workspace slug identifier + initiative_id: UUID of the initiative + params: Optional query parameters (e.g., per_page, cursor) + + Returns: + Paginated list of initiative labels + """ + response = self._get(f"{workspace_slug}/initiatives/{initiative_id}/labels", params=params) + return PaginatedInitiativeLabelResponse.model_validate(response) + + def add_labels( + self, workspace_slug: str, initiative_id: str, label_ids: Iterable[str] + ) -> Iterable[InitiativeLabel]: + """Add labels to an initiative. + + Args: + workspace_slug: The workspace slug identifier + initiative_id: UUID of the initiative + label_ids: List of label UUIDs to add + + Returns: + List of added initiative labels + """ + response = self._post( + f"{workspace_slug}/initiatives/{initiative_id}/labels", + {"label_ids": label_ids}, + ) + return [InitiativeLabel.model_validate(label) for label in response] + + def remove_labels( + self, workspace_slug: str, initiative_id: str, label_ids: Iterable[str] + ) -> None: + """Remove labels from an initiative. + + Args: + workspace_slug: The workspace slug identifier + initiative_id: UUID of the initiative + label_ids: List of label UUIDs to remove + """ + return self._delete( + f"{workspace_slug}/initiatives/{initiative_id}/labels", + {"label_ids": label_ids}, + ) diff --git a/plane/api/initiatives/projects.py b/plane/api/initiatives/projects.py new file mode 100644 index 0000000..c95f8af --- /dev/null +++ b/plane/api/initiatives/projects.py @@ -0,0 +1,63 @@ +from collections.abc import Iterable, Mapping +from typing import Any + +from ...models.projects import PaginatedProjectResponse, Project +from ..base_resource import BaseResource + + +class InitiativeProjects(BaseResource): + """API client for managing projects associated with initiatives.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list( + self, workspace_slug: str, initiative_id: str, params: Mapping[str, Any] | None = None + ) -> PaginatedProjectResponse: + """List projects associated with an initiative. + + Args: + workspace_slug: The workspace slug identifier + initiative_id: UUID of the initiative + params: Optional query parameters (e.g., per_page, cursor) + + Returns: + Paginated list of projects + """ + response = self._get( + f"{workspace_slug}/initiatives/{initiative_id}/projects", params=params + ) + return PaginatedProjectResponse.model_validate(response) + + def add( + self, workspace_slug: str, initiative_id: str, project_ids: Iterable[str] + ) -> Iterable[Project]: + """Add projects to an initiative. + + Args: + workspace_slug: The workspace slug identifier + initiative_id: UUID of the initiative + project_ids: List of project UUIDs to add + + Returns: + List of added projects + """ + response = self._post( + f"{workspace_slug}/initiatives/{initiative_id}/projects", + {"project_ids": project_ids}, + ) + return [Project.model_validate(project) for project in response] + + def remove(self, workspace_slug: str, initiative_id: str, project_ids: Iterable[str]) -> None: + """Remove projects from an initiative. + + Args: + workspace_slug: The workspace slug identifier + initiative_id: UUID of the initiative + project_ids: List of project UUIDs to remove + """ + return self._delete( + f"{workspace_slug}/initiatives/{initiative_id}/projects", + {"project_ids": project_ids}, + ) + diff --git a/plane/api/projects.py b/plane/api/projects.py index 37af03d..67c91d5 100644 --- a/plane/api/projects.py +++ b/plane/api/projects.py @@ -5,6 +5,7 @@ CreateProject, PaginatedProjectResponse, Project, + ProjectFeature, ProjectWorklogSummary, UpdateProject, ) @@ -94,3 +95,28 @@ def get_members( """ response = self._get(f"{workspace_slug}/projects/{project_id}/members", params=params) return [UserLite.model_validate(item) for item in response or []] + + def get_features(self, workspace_slug: str, project_id: str) -> ProjectFeature: + """Get features of a project. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + """ + response = self._get(f"{workspace_slug}/projects/{project_id}/features") + return ProjectFeature.model_validate(response) + + def update_features( + self, workspace_slug: str, project_id: str, data: ProjectFeature + ) -> ProjectFeature: + """Update features of a project. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + data: Updated project features + """ + response = self._patch( + f"{workspace_slug}/projects/{project_id}/features", data.model_dump(exclude_none=True) + ) + return ProjectFeature.model_validate(response) diff --git a/plane/api/stickies.py b/plane/api/stickies.py new file mode 100644 index 0000000..8d940f3 --- /dev/null +++ b/plane/api/stickies.py @@ -0,0 +1,83 @@ +from collections.abc import Mapping +from typing import Any + +from ..models.stickies import CreateSticky, PaginatedStickyResponse, Sticky, UpdateSticky +from .base_resource import BaseResource + + +class Stickies(BaseResource): + """API client for managing stickies in workspaces.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def create(self, workspace_slug: str, data: CreateSticky) -> Sticky: + """Create a new sticky in the workspace. + + Args: + workspace_slug: The workspace slug identifier + data: Sticky data + + Returns: + The created sticky + """ + response = self._post( + f"{workspace_slug}/stickies", + data.model_dump(exclude_none=True), + ) + return Sticky.model_validate(response) + + def retrieve(self, workspace_slug: str, sticky_id: str) -> Sticky: + """Retrieve a sticky by ID. + + Args: + workspace_slug: The workspace slug identifier + sticky_id: UUID of the sticky + + Returns: + The requested sticky + """ + response = self._get(f"{workspace_slug}/stickies/{sticky_id}") + return Sticky.model_validate(response) + + def update(self, workspace_slug: str, sticky_id: str, data: UpdateSticky) -> Sticky: + """Update a sticky by ID. + + Args: + workspace_slug: The workspace slug identifier + sticky_id: UUID of the sticky + data: Updated sticky data + + Returns: + The updated sticky + """ + response = self._patch( + f"{workspace_slug}/stickies/{sticky_id}", + data.model_dump(exclude_none=True), + ) + return Sticky.model_validate(response) + + def delete(self, workspace_slug: str, sticky_id: str) -> None: + """Delete a sticky by ID. + + Args: + workspace_slug: The workspace slug identifier + sticky_id: UUID of the sticky + """ + return self._delete(f"{workspace_slug}/stickies/{sticky_id}") + + def list( + self, workspace_slug: str, params: Mapping[str, Any] | None = None + ) -> PaginatedStickyResponse: + """List stickies in the workspace with optional filtering. + + Args: + workspace_slug: The workspace slug identifier + params: Optional query parameters (e.g., query for search, per_page, cursor) + + Returns: + Paginated list of stickies + """ + response = self._get(f"{workspace_slug}/stickies", params=params) + return PaginatedStickyResponse.model_validate(response) + diff --git a/plane/api/teamspaces/__init__.py b/plane/api/teamspaces/__init__.py new file mode 100644 index 0000000..9b82064 --- /dev/null +++ b/plane/api/teamspaces/__init__.py @@ -0,0 +1,4 @@ +from .base import Teamspaces + +__all__ = ["Teamspaces"] + diff --git a/plane/api/teamspaces/base.py b/plane/api/teamspaces/base.py new file mode 100644 index 0000000..ac6db3c --- /dev/null +++ b/plane/api/teamspaces/base.py @@ -0,0 +1,96 @@ +from collections.abc import Mapping +from typing import Any + +from ...models.teamspaces import ( + CreateTeamspace, + PaginatedTeamspaceResponse, + Teamspace, + UpdateTeamspace, +) +from ..base_resource import BaseResource +from .members import TeamspaceMembers +from .projects import TeamspaceProjects + + +class Teamspaces(BaseResource): + """API client for managing teamspaces in workspaces.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + # Initialize sub-resources + self.projects = TeamspaceProjects(config) + self.members = TeamspaceMembers(config) + + def create(self, workspace_slug: str, data: CreateTeamspace) -> Teamspace: + """Create a new teamspace in the workspace. + + Args: + workspace_slug: The workspace slug identifier + data: Teamspace data + + Returns: + The created teamspace + """ + response = self._post( + f"{workspace_slug}/teamspaces", + data.model_dump(exclude_none=True), + ) + return Teamspace.model_validate(response) + + def retrieve(self, workspace_slug: str, teamspace_id: str) -> Teamspace: + """Retrieve a teamspace by ID. + + Args: + workspace_slug: The workspace slug identifier + teamspace_id: UUID of the teamspace + + Returns: + The requested teamspace + """ + response = self._get(f"{workspace_slug}/teamspaces/{teamspace_id}") + return Teamspace.model_validate(response) + + def update( + self, workspace_slug: str, teamspace_id: str, data: UpdateTeamspace + ) -> Teamspace: + """Update a teamspace by ID. + + Args: + workspace_slug: The workspace slug identifier + teamspace_id: UUID of the teamspace + data: Updated teamspace data + + Returns: + The updated teamspace + """ + response = self._patch( + f"{workspace_slug}/teamspaces/{teamspace_id}", + data.model_dump(exclude_none=True), + ) + return Teamspace.model_validate(response) + + def delete(self, workspace_slug: str, teamspace_id: str) -> None: + """Delete a teamspace by ID. + + Args: + workspace_slug: The workspace slug identifier + teamspace_id: UUID of the teamspace + """ + return self._delete(f"{workspace_slug}/teamspaces/{teamspace_id}") + + def list( + self, workspace_slug: str, params: Mapping[str, Any] | None = None + ) -> PaginatedTeamspaceResponse: + """List teamspaces in the workspace with optional filtering. + + Args: + workspace_slug: The workspace slug identifier + params: Optional query parameters (e.g., per_page, cursor) + + Returns: + Paginated list of teamspaces + """ + response = self._get(f"{workspace_slug}/teamspaces", params=params) + return PaginatedTeamspaceResponse.model_validate(response) + diff --git a/plane/api/teamspaces/members.py b/plane/api/teamspaces/members.py new file mode 100644 index 0000000..8995d2d --- /dev/null +++ b/plane/api/teamspaces/members.py @@ -0,0 +1,63 @@ +from collections.abc import Iterable, Mapping +from typing import Any + +from ...models.users import PaginatedUserLiteResponse, UserLite +from ..base_resource import BaseResource + + +class TeamspaceMembers(BaseResource): + """API client for managing members associated with teamspaces.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list( + self, workspace_slug: str, teamspace_id: str, params: Mapping[str, Any] | None = None + ) -> PaginatedUserLiteResponse: + """List members associated with a teamspace. + + Args: + workspace_slug: The workspace slug identifier + teamspace_id: UUID of the teamspace + params: Optional query parameters (e.g., per_page, cursor) + + Returns: + Paginated list of members + """ + response = self._get( + f"{workspace_slug}/teamspaces/{teamspace_id}/members", params=params + ) + return PaginatedUserLiteResponse.model_validate(response) + + def add( + self, workspace_slug: str, teamspace_id: str, member_ids: Iterable[str] + ) -> Iterable[UserLite]: + """Add members to a teamspace. + + Args: + workspace_slug: The workspace slug identifier + teamspace_id: UUID of the teamspace + member_ids: List of member UUIDs to add + + Returns: + List of added members + """ + response = self._post( + f"{workspace_slug}/teamspaces/{teamspace_id}/members", + {"member_ids": member_ids}, + ) + return [UserLite.model_validate(member) for member in response] + + def remove(self, workspace_slug: str, teamspace_id: str, member_ids: Iterable[str]) -> None: + """Remove members from a teamspace. + + Args: + workspace_slug: The workspace slug identifier + teamspace_id: UUID of the teamspace + member_ids: List of member UUIDs to remove + """ + return self._delete( + f"{workspace_slug}/teamspaces/{teamspace_id}/members", + {"member_ids": member_ids}, + ) + diff --git a/plane/api/teamspaces/projects.py b/plane/api/teamspaces/projects.py new file mode 100644 index 0000000..95010c2 --- /dev/null +++ b/plane/api/teamspaces/projects.py @@ -0,0 +1,63 @@ +from collections.abc import Iterable, Mapping +from typing import Any + +from ...models.projects import PaginatedProjectResponse, Project +from ..base_resource import BaseResource + + +class TeamspaceProjects(BaseResource): + """API client for managing projects associated with teamspaces.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list( + self, workspace_slug: str, teamspace_id: str, params: Mapping[str, Any] | None = None + ) -> PaginatedProjectResponse: + """List projects associated with a teamspace. + + Args: + workspace_slug: The workspace slug identifier + teamspace_id: UUID of the teamspace + params: Optional query parameters (e.g., per_page, cursor) + + Returns: + Paginated list of projects + """ + response = self._get( + f"{workspace_slug}/teamspaces/{teamspace_id}/projects", params=params + ) + return PaginatedProjectResponse.model_validate(response) + + def add( + self, workspace_slug: str, teamspace_id: str, project_ids: Iterable[str] + ) -> Iterable[Project]: + """Add projects to a teamspace. + + Args: + workspace_slug: The workspace slug identifier + teamspace_id: UUID of the teamspace + project_ids: List of project UUIDs to add + + Returns: + List of added projects + """ + response = self._post( + f"{workspace_slug}/teamspaces/{teamspace_id}/projects", + {"project_ids": project_ids}, + ) + return [Project.model_validate(project) for project in response] + + def remove(self, workspace_slug: str, teamspace_id: str, project_ids: Iterable[str]) -> None: + """Remove projects from a teamspace. + + Args: + workspace_slug: The workspace slug identifier + teamspace_id: UUID of the teamspace + project_ids: List of project UUIDs to remove + """ + return self._delete( + f"{workspace_slug}/teamspaces/{teamspace_id}/projects", + {"project_ids": project_ids}, + ) + diff --git a/plane/api/workspaces.py b/plane/api/workspaces.py index 52dc9b6..4c65f4c 100644 --- a/plane/api/workspaces.py +++ b/plane/api/workspaces.py @@ -1,6 +1,7 @@ from typing import Any from ..models.users import UserLite +from ..models.workspaces import WorkspaceFeature from .base_resource import BaseResource @@ -18,3 +19,22 @@ def get_members( """ response = self._get(f"{workspace_slug}/members") return [UserLite.model_validate(item) for item in response or []] + + def get_features(self, workspace_slug: str) -> WorkspaceFeature: + """Get features of a workspace. + + Args: + workspace_slug: The workspace slug identifier + """ + response = self._get(f"{workspace_slug}/features") + return WorkspaceFeature.model_validate(response) + + def update_features(self, workspace_slug: str, data: WorkspaceFeature) -> WorkspaceFeature: + """Update features of a workspace. + + Args: + workspace_slug: The workspace slug identifier + data: Updated workspace features + """ + response = self._patch(f"{workspace_slug}/features", data.model_dump(exclude_none=True)) + return WorkspaceFeature.model_validate(response) \ No newline at end of file diff --git a/plane/client/plane_client.py b/plane/client/plane_client.py index 21974d4..938337f 100644 --- a/plane/client/plane_client.py +++ b/plane/client/plane_client.py @@ -1,12 +1,15 @@ from ..api.customers import Customers from ..api.cycles import Cycles from ..api.epics import Epics +from ..api.initiatives import Initiatives from ..api.intake import Intake from ..api.labels import Labels from ..api.modules import Modules from ..api.pages import Pages from ..api.projects import Projects from ..api.states import States +from ..api.stickies import Stickies +from ..api.teamspaces import Teamspaces from ..api.users import Users from ..api.work_item_properties import WorkItemProperties from ..api.work_item_types import WorkItemTypes @@ -53,4 +56,7 @@ def __init__( self.work_item_properties = WorkItemProperties(self.config) self.customers = Customers(self.config) self.intake = Intake(self.config) + self.stickies = Stickies(self.config) + self.initiatives = Initiatives(self.config) + self.teamspaces = Teamspaces(self.config) diff --git a/plane/models/enums.py b/plane/models/enums.py index 6888174..1bc6823 100644 --- a/plane/models/enums.py +++ b/plane/models/enums.py @@ -81,6 +81,15 @@ class Priority(Enum): NONE = "none" +class InitiativeState(Enum): + """Initiative state enumeration.""" + + DRAFT = "DRAFT" + PLANNED = "PLANNED" + ACTIVE = "ACTIVE" + COMPLETED = "COMPLETED" + CLOSED = "CLOSED" + class WorkItemRelationType(Enum): """Work item relation type enumeration.""" diff --git a/plane/models/initiatives.py b/plane/models/initiatives.py new file mode 100644 index 0000000..26c602c --- /dev/null +++ b/plane/models/initiatives.py @@ -0,0 +1,107 @@ +from pydantic import BaseModel, ConfigDict + +from .enums import InitiativeState + + +class Initiative(BaseModel): + """Initiative model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str + name: str + description: str | None = None + description_html: str | None = None + description_stripped: str | None = None + description_binary: bytes | None = None + start_date: str | None = None + end_date: str | None = None + logo_props: dict + state: InitiativeState | None = None + lead: str | None = None + workspace: str + created_at: str | None = None + updated_at: str | None = None + deleted_at: str | None = None + + +class CreateInitiative(BaseModel): + """Create initiative model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str + description_html: str | None = None + start_date: str | None = None + end_date: str | None = None + logo_props: dict | None = None + state: InitiativeState | None = None + lead: str | None = None + + +class UpdateInitiative(BaseModel): + """Update initiative model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str | None = None + description_html: str | None = None + start_date: str | None = None + end_date: str | None = None + logo_props: dict | None = None + state: InitiativeState | None = None + lead: str | None = None + + +class PaginatedInitiativeResponse(BaseModel): + """Paginated initiative response model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[Initiative] + + +class InitiativeLabel(BaseModel): + """Initiative label model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str + name: str + description: str | None = None + color: str | None = None + sort_order: float | None = None + workspace: str + created_at: str | None = None + updated_at: str | None = None + deleted_at: str | None = None + + +class CreateInitiativeLabel(BaseModel): + """Create initiative label model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str + description: str | None = None + color: str | None = None + sort_order: float | None = None + + +class UpdateInitiativeLabel(BaseModel): + """Update initiative label model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str | None = None + description: str | None = None + color: str | None = None + sort_order: float | None = None + + +class PaginatedInitiativeLabelResponse(BaseModel): + """Paginated initiative label response model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[InitiativeLabel] diff --git a/plane/models/projects.py b/plane/models/projects.py index 7769433..4a81a07 100644 --- a/plane/models/projects.py +++ b/plane/models/projects.py @@ -134,3 +134,16 @@ class PaginatedProjectResponse(PaginatedResponse): model_config = ConfigDict(extra="allow", populate_by_name=True) results: list[Project] + +class ProjectFeature(BaseModel): + """Project feature model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + epics: bool + modules: bool + cycles: bool + views: bool + pages: bool + intakes: bool + work_item_types: bool \ No newline at end of file diff --git a/plane/models/stickies.py b/plane/models/stickies.py new file mode 100644 index 0000000..a958755 --- /dev/null +++ b/plane/models/stickies.py @@ -0,0 +1,62 @@ +from pydantic import BaseModel, ConfigDict + +from .pagination import PaginatedResponse + + +class Sticky(BaseModel): + """Response model for a sticky.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str + name: str | None = None + description: dict | str | None = None + description_html: str | None = None + description_stripped: str | None = None + description_binary: bytes | None = None + logo_props: dict | None = None + color: str | None = None + background_color: str | None = None + workspace: str + owner: str + sort_order: float | None = None + created_at: str | None = None + updated_at: str | None = None + + +class CreateSticky(BaseModel): + """Request model for creating a sticky.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str | None = None + description: dict | str | None = None + description_html: str | None = None + description_stripped: str | None = None + description_binary: bytes | None = None + logo_props: dict | None = None + color: str | None = None + background_color: str | None = None + + +class UpdateSticky(BaseModel): + """Request model for updating a sticky.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str | None = None + description: dict | str | None = None + description_html: str | None = None + description_stripped: str | None = None + description_binary: bytes | None = None + logo_props: dict | None = None + color: str | None = None + background_color: str | None = None + + +class PaginatedStickyResponse(PaginatedResponse): + """Paginated response for stickies.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[Sticky] diff --git a/plane/models/teamspaces.py b/plane/models/teamspaces.py new file mode 100644 index 0000000..cfd81d7 --- /dev/null +++ b/plane/models/teamspaces.py @@ -0,0 +1,50 @@ +from pydantic import BaseModel, ConfigDict + + +class Teamspace(BaseModel): + """Teamspace model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str + name: str + description_json: dict | str | None = None + description_html: str | None = None + description_stripped: str | None = None + description_binary: bytes | None = None + logo_props: dict | None = None + lead: str | None = None + workspace: str + created_at: str + updated_at: str + deleted_at: str | None = None + + +class CreateTeamspace(BaseModel): + """Create teamspace model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str + description_html: str | None = None + logo_props: dict | None = None + lead: str | None = None + + +class UpdateTeamspace(BaseModel): + """Update teamspace model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + name: str | None = None + description_html: str | None = None + logo_props: dict | None = None + lead: str | None = None + + +class PaginatedTeamspaceResponse(BaseModel): + """Paginated teamspace response model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[Teamspace] diff --git a/plane/models/users.py b/plane/models/users.py index 799334b..46355ff 100644 --- a/plane/models/users.py +++ b/plane/models/users.py @@ -1,6 +1,7 @@ from pydantic import BaseModel, ConfigDict, Field from .enums import EntityTypeEnum, TypeMimeEnum +from .pagination import PaginatedResponse class UserLite(BaseModel): @@ -17,6 +18,14 @@ class UserLite(BaseModel): display_name: str | None = None +class PaginatedUserLiteResponse(PaginatedResponse): + """Paginated response for user lite.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[UserLite] + + class UserAssetUploadRequest(BaseModel): """Request model for uploading user assets.""" diff --git a/plane/models/workspaces.py b/plane/models/workspaces.py new file mode 100644 index 0000000..e264b61 --- /dev/null +++ b/plane/models/workspaces.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, ConfigDict + +class WorkspaceFeature(BaseModel): + """Workspace feature model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + project_grouping: bool + initiatives: bool + teams: bool + customers: bool + wiki: bool + pi: bool \ No newline at end of file diff --git a/tests/unit/test_initiatives.py b/tests/unit/test_initiatives.py new file mode 100644 index 0000000..210ce7f --- /dev/null +++ b/tests/unit/test_initiatives.py @@ -0,0 +1,300 @@ +"""Unit tests for Initiatives API resource (smoke tests with real HTTP requests).""" + +from datetime import datetime + +import pytest + +from plane.client import PlaneClient +from plane.models.initiatives import ( + CreateInitiative, + CreateInitiativeLabel, + UpdateInitiative, + UpdateInitiativeLabel, +) +from plane.models.projects import Project + + +class TestInitiativesAPI: + """Test Initiatives API resource.""" + + def test_list_initiatives(self, client: PlaneClient, workspace_slug: str) -> None: + """Test listing initiatives.""" + response = client.initiatives.list(workspace_slug) + assert response is not None + assert hasattr(response, "results") + assert hasattr(response, "count") + assert isinstance(response.results, list) + + def test_list_initiatives_with_params(self, client: PlaneClient, workspace_slug: str) -> None: + """Test listing initiatives with query parameters.""" + params = {"per_page": 5} + response = client.initiatives.list(workspace_slug, params=params) + assert response is not None + assert hasattr(response, "results") + assert len(response.results) <= 5 + + +class TestInitiativesAPICRUD: + """Test Initiatives API CRUD operations.""" + + @pytest.fixture + def initiative_data(self) -> CreateInitiative: + """Create test initiative data.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + return CreateInitiative( + name=f"Test Initiative {timestamp}", + description="Test initiative for smoke tests", + ) + + @pytest.fixture + def initiative( + self, + client: PlaneClient, + workspace_slug: str, + initiative_data: CreateInitiative, + ): + """Create a test initiative and yield it, then delete it.""" + initiative = client.initiatives.create(workspace_slug, initiative_data) + yield initiative + try: + client.initiatives.delete(workspace_slug, initiative.id) + except Exception: + pass + + def test_create_initiative( + self, + client: PlaneClient, + workspace_slug: str, + initiative_data: CreateInitiative, + ) -> None: + """Test creating an initiative.""" + initiative = client.initiatives.create(workspace_slug, initiative_data) + assert initiative is not None + assert initiative.id is not None + assert initiative.name == initiative_data.name + + # Cleanup + try: + client.initiatives.delete(workspace_slug, initiative.id) + except Exception: + pass + + def test_retrieve_initiative( + self, client: PlaneClient, workspace_slug: str, initiative + ) -> None: + """Test retrieving an initiative.""" + retrieved = client.initiatives.retrieve(workspace_slug, initiative.id) + assert retrieved is not None + assert retrieved.id == initiative.id + assert retrieved.name == initiative.name + + def test_update_initiative(self, client: PlaneClient, workspace_slug: str, initiative) -> None: + """Test updating an initiative.""" + update_data = UpdateInitiative(description="Updated description") + updated = client.initiatives.update(workspace_slug, initiative.id, update_data) + assert updated is not None + assert updated.id == initiative.id + assert updated.description == "Updated description" + + +class TestInitiativeLabelsAPI: + """Test Initiative Labels API operations.""" + + @pytest.fixture + def initiative( + self, + client: PlaneClient, + workspace_slug: str, + ): + """Create a test initiative and yield it, then delete it.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + initiative_data = CreateInitiative( + name=f"Test Initiative Labels {timestamp}", + description="Test initiative for labels operations", + ) + initiative = client.initiatives.create(workspace_slug, initiative_data) + yield initiative + try: + client.initiatives.delete(workspace_slug, initiative.id) + except Exception: + pass + + def test_list_labels(self, client: PlaneClient, workspace_slug: str) -> None: + """Test listing initiative labels.""" + response = client.initiatives.labels.list(workspace_slug) + assert response is not None + assert hasattr(response, "results") + assert hasattr(response, "count") + assert isinstance(response.results, list) + + def test_create_and_delete_label(self, client: PlaneClient, workspace_slug: str) -> None: + """Test creating and deleting an initiative label.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + label_data = CreateInitiativeLabel( + name=f"Test Label {timestamp}", + color="#FF0000", + ) + label = client.initiatives.labels.create(workspace_slug, label_data) + assert label is not None + assert label.id is not None + assert label.name == label_data.name + + # Cleanup + try: + client.initiatives.labels.delete(workspace_slug, label.id) + except Exception: + pass + + def test_retrieve_label(self, client: PlaneClient, workspace_slug: str) -> None: + """Test retrieving an initiative label.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + label_data = CreateInitiativeLabel( + name=f"Test Label {timestamp}", + color="#00FF00", + ) + label = client.initiatives.labels.create(workspace_slug, label_data) + + retrieved = client.initiatives.labels.retrieve(workspace_slug, label.id) + assert retrieved is not None + assert retrieved.id == label.id + assert retrieved.name == label.name + + # Cleanup + try: + client.initiatives.labels.delete(workspace_slug, label.id) + except Exception: + pass + + def test_update_label(self, client: PlaneClient, workspace_slug: str) -> None: + """Test updating an initiative label.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + label_data = CreateInitiativeLabel( + name=f"Test Label {timestamp}", + color="#0000FF", + ) + label = client.initiatives.labels.create(workspace_slug, label_data) + + update_data = UpdateInitiativeLabel(name="Updated Label Name") + updated = client.initiatives.labels.update(workspace_slug, label.id, update_data) + assert updated is not None + assert updated.id == label.id + assert updated.name == "Updated Label Name" + + # Cleanup + try: + client.initiatives.labels.delete(workspace_slug, label.id) + except Exception: + pass + + def test_list_labels_for_initiative( + self, client: PlaneClient, workspace_slug: str, initiative + ) -> None: + """Test listing labels for a specific initiative.""" + response = client.initiatives.labels.list_labels(workspace_slug, initiative.id) + assert response is not None + assert hasattr(response, "results") + assert hasattr(response, "count") + assert isinstance(response.results, list) + + def test_add_and_remove_labels( + self, client: PlaneClient, workspace_slug: str, initiative + ) -> None: + """Test adding and removing labels from an initiative.""" + # Create a label first + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + label_data = CreateInitiativeLabel( + name=f"Test Label {timestamp}", + color="#FFFF00", + ) + label = client.initiatives.labels.create(workspace_slug, label_data) + + try: + # Add label to initiative + added_labels = client.initiatives.labels.add_labels( + workspace_slug, initiative.id, [label.id] + ) + assert isinstance(added_labels, list) + + # Remove label from initiative + client.initiatives.labels.remove_labels(workspace_slug, initiative.id, [label.id]) + finally: + # Cleanup + try: + client.initiatives.labels.delete(workspace_slug, label.id) + except Exception: + pass + + +class TestInitiativeProjectsAPI: + """Test Initiative Projects API operations.""" + + @pytest.fixture + def initiative( + self, + client: PlaneClient, + workspace_slug: str, + ): + """Create a test initiative and yield it, then delete it.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + initiative_data = CreateInitiative( + name=f"Test Initiative Projects {timestamp}", + description="Test initiative for projects operations", + ) + initiative = client.initiatives.create(workspace_slug, initiative_data) + yield initiative + try: + client.initiatives.delete(workspace_slug, initiative.id) + except Exception: + pass + + def test_list_projects(self, client: PlaneClient, workspace_slug: str, initiative) -> None: + """Test listing projects in an initiative.""" + response = client.initiatives.projects.list(workspace_slug, initiative.id) + assert response is not None + assert hasattr(response, "results") + assert hasattr(response, "count") + assert isinstance(response.results, list) + + def test_add_and_remove_projects( + self, client: PlaneClient, workspace_slug: str, initiative, project: Project + ) -> None: + """Test adding and removing projects from an initiative.""" + # Add project + added_projects = client.initiatives.projects.add( + workspace_slug, initiative.id, [project.id] + ) + assert isinstance(added_projects, list) + + # Remove project + client.initiatives.projects.remove(workspace_slug, initiative.id, [project.id]) + + +class TestInitiativeEpicsAPI: + """Test Initiative Epics API operations.""" + + @pytest.fixture + def initiative( + self, + client: PlaneClient, + workspace_slug: str, + ): + """Create a test initiative and yield it, then delete it.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + initiative_data = CreateInitiative( + name=f"Test Initiative Epics {timestamp}", + description="Test initiative for epics operations", + ) + initiative = client.initiatives.create(workspace_slug, initiative_data) + yield initiative + try: + client.initiatives.delete(workspace_slug, initiative.id) + except Exception: + pass + + def test_list_epics(self, client: PlaneClient, workspace_slug: str, initiative) -> None: + """Test listing epics in an initiative.""" + response = client.initiatives.epics.list(workspace_slug, initiative.id) + assert response is not None + assert hasattr(response, "results") + assert hasattr(response, "count") + assert isinstance(response.results, list) diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py index e2636e9..db420a7 100644 --- a/tests/unit/test_projects.py +++ b/tests/unit/test_projects.py @@ -65,7 +65,7 @@ def test_create_project( assert project is not None assert project.id is not None assert project.name == project_data.name - + # Cleanup try: client.projects.delete(workspace_slug, project.id) @@ -91,10 +91,37 @@ def test_update_project( assert updated.id == project.id assert updated.description == "Updated description" - def test_get_members( - self, client: PlaneClient, workspace_slug: str, project: Project - ) -> None: + def test_get_members(self, client: PlaneClient, workspace_slug: str, project: Project) -> None: """Test getting project members.""" members = client.projects.get_members(workspace_slug, project.id) assert isinstance(members, list) + def test_get_features(self, client: PlaneClient, workspace_slug: str, project: Project) -> None: + """Test getting project features.""" + features = client.projects.get_features(workspace_slug, project.id) + assert features is not None + assert hasattr(features, "cycles") + assert hasattr(features, "modules") + assert hasattr(features, "views") + assert hasattr(features, "pages") + assert hasattr(features, "intakes") + assert hasattr(features, "work_item_types") + + def test_update_features( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """Test updating project features.""" + # Get current features first + features = client.projects.get_features(workspace_slug, project.id) + + # Update features + features.cycles = True + updated = client.projects.update_features(workspace_slug, project.id, features) + assert updated is not None + assert updated.cycles is True + assert hasattr(updated, "cycles") + assert hasattr(updated, "modules") + assert hasattr(updated, "views") + assert hasattr(updated, "pages") + assert hasattr(updated, "intakes") + assert hasattr(updated, "work_item_types") diff --git a/tests/unit/test_stickies.py b/tests/unit/test_stickies.py new file mode 100644 index 0000000..8d751ce --- /dev/null +++ b/tests/unit/test_stickies.py @@ -0,0 +1,94 @@ +"""Unit tests for Stickies API resource (smoke tests with real HTTP requests).""" + +from datetime import datetime + +import pytest + +from plane.client import PlaneClient +from plane.models.stickies import CreateSticky, UpdateSticky + + +class TestStickiesAPI: + """Test Stickies API resource.""" + + def test_list_stickies(self, client: PlaneClient, workspace_slug: str) -> None: + """Test listing stickies.""" + response = client.stickies.list(workspace_slug) + assert response is not None + assert hasattr(response, "results") + assert hasattr(response, "count") + assert isinstance(response.results, list) + + def test_list_stickies_with_params( + self, client: PlaneClient, workspace_slug: str + ) -> None: + """Test listing stickies with query parameters.""" + params = {"per_page": 5} + response = client.stickies.list(workspace_slug, params=params) + assert response is not None + assert hasattr(response, "results") + assert len(response.results) <= 5 + + +class TestStickiesAPICRUD: + """Test Stickies API CRUD operations.""" + + @pytest.fixture + def sticky_data(self) -> CreateSticky: + """Create test sticky data.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + return CreateSticky( + name=f"Test Sticky {timestamp}", + description_html="

Test sticky for smoke tests

", + ) + + @pytest.fixture + def sticky( + self, + client: PlaneClient, + workspace_slug: str, + sticky_data: CreateSticky, + ): + """Create a test sticky and yield it, then delete it.""" + sticky = client.stickies.create(workspace_slug, sticky_data) + yield sticky + try: + client.stickies.delete(workspace_slug, sticky.id) + except Exception: + pass + + def test_create_sticky( + self, client: PlaneClient, workspace_slug: str, sticky_data: CreateSticky + ) -> None: + """Test creating a sticky.""" + sticky = client.stickies.create(workspace_slug, sticky_data) + assert sticky is not None + assert sticky.id is not None + assert sticky.name == sticky_data.name + + # Cleanup + try: + client.stickies.delete(workspace_slug, sticky.id) + except Exception: + pass + + def test_retrieve_sticky( + self, client: PlaneClient, workspace_slug: str, sticky + ) -> None: + """Test retrieving a sticky.""" + retrieved = client.stickies.retrieve(workspace_slug, sticky.id) + assert retrieved is not None + assert retrieved.id == sticky.id + assert retrieved.name == sticky.name + + def test_update_sticky( + self, client: PlaneClient, workspace_slug: str, sticky + ) -> None: + """Test updating a sticky.""" + update_data = UpdateSticky(name="Updated Sticky Name") + updated = client.stickies.update(workspace_slug, sticky.id, update_data) + assert updated is not None + assert updated.id == sticky.id + assert updated.name == "Updated Sticky Name" + + diff --git a/tests/unit/test_teamspaces.py b/tests/unit/test_teamspaces.py new file mode 100644 index 0000000..8127e55 --- /dev/null +++ b/tests/unit/test_teamspaces.py @@ -0,0 +1,205 @@ +"""Unit tests for Teamspaces API resource (smoke tests with real HTTP requests).""" + +from datetime import datetime + +import pytest + +from plane.client import PlaneClient +from plane.models.projects import Project +from plane.models.teamspaces import CreateTeamspace, UpdateTeamspace + + +class TestTeamspacesAPI: + """Test Teamspaces API resource.""" + + def test_list_teamspaces(self, client: PlaneClient, workspace_slug: str) -> None: + """Test listing teamspaces.""" + response = client.teamspaces.list(workspace_slug) + assert response is not None + assert hasattr(response, "results") + assert hasattr(response, "count") + assert isinstance(response.results, list) + + def test_list_teamspaces_with_params( + self, client: PlaneClient, workspace_slug: str + ) -> None: + """Test listing teamspaces with query parameters.""" + params = {"per_page": 5} + response = client.teamspaces.list(workspace_slug, params=params) + assert response is not None + assert hasattr(response, "results") + assert len(response.results) <= 5 + + +class TestTeamspacesAPICRUD: + """Test Teamspaces API CRUD operations.""" + + @pytest.fixture + def teamspace_data(self) -> CreateTeamspace: + """Create test teamspace data.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + return CreateTeamspace( + name=f"Test Teamspace {timestamp}", + description_html="

Test teamspace for smoke tests

", + ) + + @pytest.fixture + def teamspace( + self, + client: PlaneClient, + workspace_slug: str, + teamspace_data: CreateTeamspace, + ): + """Create a test teamspace and yield it, then delete it.""" + teamspace = client.teamspaces.create(workspace_slug, teamspace_data) + yield teamspace + try: + client.teamspaces.delete(workspace_slug, teamspace.id) + except Exception: + pass + + def test_create_teamspace( + self, client: PlaneClient, workspace_slug: str, teamspace_data: CreateTeamspace + ) -> None: + """Test creating a teamspace.""" + teamspace = client.teamspaces.create(workspace_slug, teamspace_data) + assert teamspace is not None + assert teamspace.id is not None + assert teamspace.name == teamspace_data.name + + # Cleanup + try: + client.teamspaces.delete(workspace_slug, teamspace.id) + except Exception: + pass + + def test_retrieve_teamspace( + self, client: PlaneClient, workspace_slug: str, teamspace + ) -> None: + """Test retrieving a teamspace.""" + retrieved = client.teamspaces.retrieve(workspace_slug, teamspace.id) + assert retrieved is not None + assert retrieved.id == teamspace.id + assert retrieved.name == teamspace.name + + def test_update_teamspace( + self, client: PlaneClient, workspace_slug: str, teamspace + ) -> None: + """Test updating a teamspace.""" + update_data = UpdateTeamspace(name="Updated name") + updated = client.teamspaces.update(workspace_slug, teamspace.id, update_data) + assert updated is not None + assert updated.id == teamspace.id + assert updated.name == "Updated name" + + def test_list_teamspaces_with_params( + self, client: PlaneClient, workspace_slug: str + ) -> None: + """Test listing teamspaces with query parameters.""" + params = {"per_page": 5} + response = client.teamspaces.list(workspace_slug, params=params) + assert response is not None + assert hasattr(response, "results") + assert len(response.results) <= 5 + assert hasattr(response.results[0], "id") + assert hasattr(response.results[0], "name") + assert hasattr(response.results[0], "description_html") + assert hasattr(response.results[0], "description_stripped") + assert hasattr(response.results[0], "description_binary") + assert hasattr(response.results[0], "logo_props") + assert hasattr(response.results[0], "lead") + assert hasattr(response.results[0], "workspace") + + +class TestTeamspaceMembersAPI: + """Test Teamspace Members API operations.""" + + @pytest.fixture + def teamspace( + self, + client: PlaneClient, + workspace_slug: str, + ): + """Create a test teamspace and yield it, then delete it.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + teamspace_data = CreateTeamspace( + name=f"Test Teamspace Members {timestamp}", + description_html="

Test teamspace for members operations

", + ) + teamspace = client.teamspaces.create(workspace_slug, teamspace_data) + yield teamspace + try: + client.teamspaces.delete(workspace_slug, teamspace.id) + except Exception: + pass + + def test_list_members( + self, client: PlaneClient, workspace_slug: str, teamspace + ) -> None: + """Test listing members in a teamspace.""" + response = client.teamspaces.members.list(workspace_slug, teamspace.id) + assert response is not None + assert hasattr(response, "results") + assert hasattr(response, "count") + assert isinstance(response.results, list) + + def test_add_and_remove_members( + self, client: PlaneClient, workspace_slug: str, teamspace, user_id: str + ) -> None: + """Test adding and removing members from a teamspace.""" + # Add member + added_members = client.teamspaces.members.add( + workspace_slug, teamspace.id, [user_id] + ) + assert isinstance(added_members, list) + + # Remove member + client.teamspaces.members.remove(workspace_slug, teamspace.id, [user_id]) + + +class TestTeamspaceProjectsAPI: + """Test Teamspace Projects API operations.""" + + @pytest.fixture + def teamspace( + self, + client: PlaneClient, + workspace_slug: str, + ): + """Create a test teamspace and yield it, then delete it.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + teamspace_data = CreateTeamspace( + name=f"Test Teamspace Projects {timestamp}", + description_html="

Test teamspace for projects operations

", + ) + teamspace = client.teamspaces.create(workspace_slug, teamspace_data) + yield teamspace + try: + client.teamspaces.delete(workspace_slug, teamspace.id) + except Exception: + pass + + def test_list_projects( + self, client: PlaneClient, workspace_slug: str, teamspace + ) -> None: + """Test listing projects in a teamspace.""" + response = client.teamspaces.projects.list(workspace_slug, teamspace.id) + assert response is not None + assert hasattr(response, "results") + assert hasattr(response, "count") + assert isinstance(response.results, list) + + def test_add_and_remove_projects( + self, client: PlaneClient, workspace_slug: str, teamspace, project: Project + ) -> None: + """Test adding and removing projects from a teamspace.""" + # Add project + added_projects = client.teamspaces.projects.add( + workspace_slug, teamspace.id, [project.id] + ) + assert isinstance(added_projects, list) + + # Remove project + client.teamspaces.projects.remove(workspace_slug, teamspace.id, [project.id]) + + diff --git a/tests/unit/test_workspaces.py b/tests/unit/test_workspaces.py index a184a55..fd65af9 100644 --- a/tests/unit/test_workspaces.py +++ b/tests/unit/test_workspaces.py @@ -15,3 +15,26 @@ def test_get_members(self, client: PlaneClient, workspace_slug: str) -> None: assert hasattr(member, "id") assert hasattr(member, "display_name") + def test_get_features(self, client: PlaneClient, workspace_slug: str) -> None: + """Test getting workspace features.""" + features = client.workspaces.get_features(workspace_slug) + assert features is not None + assert hasattr(features, "initiatives") + assert hasattr(features, "project_grouping") + assert hasattr(features, "teams") + assert hasattr(features, "customers") + assert hasattr(features, "wiki") + assert hasattr(features, "pi") + + def test_update_features(self, client: PlaneClient, workspace_slug: str) -> None: + """Test updating workspace features.""" + # Get current features first + features = client.workspaces.get_features(workspace_slug) + + # Update features + features.initiatives = True + updated = client.workspaces.update_features(workspace_slug, features) + assert updated is not None + assert hasattr(updated, "initiatives") + assert updated.initiatives is True + From 1e3c22f484bf07788a3bef4608a17d6914879816 Mon Sep 17 00:00:00 2001 From: Saurabhkmr98 Date: Thu, 20 Nov 2025 16:42:47 +0530 Subject: [PATCH 2/2] improve tests for module and cycles feature check --- plane/models/projects.py | 14 +++++++------- tests/unit/test_cycles.py | 4 +++- tests/unit/test_intake.py | 2 +- tests/unit/test_modules.py | 4 +++- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/plane/models/projects.py b/plane/models/projects.py index 4a81a07..551c8de 100644 --- a/plane/models/projects.py +++ b/plane/models/projects.py @@ -140,10 +140,10 @@ class ProjectFeature(BaseModel): model_config = ConfigDict(extra="allow", populate_by_name=True) - epics: bool - modules: bool - cycles: bool - views: bool - pages: bool - intakes: bool - work_item_types: bool \ No newline at end of file + epics: bool | None = None + modules: bool | None = None + cycles: bool | None = None + views: bool | None = None + pages: bool | None = None + intakes: bool | None = None + work_item_types: bool | None = None \ No newline at end of file diff --git a/tests/unit/test_cycles.py b/tests/unit/test_cycles.py index be173cf..7f4b6bf 100644 --- a/tests/unit/test_cycles.py +++ b/tests/unit/test_cycles.py @@ -6,7 +6,7 @@ from plane.client import PlaneClient from plane.models.cycles import CreateCycle, UpdateCycle -from plane.models.projects import Project +from plane.models.projects import Project, ProjectFeature class TestCyclesAPI: @@ -60,6 +60,7 @@ def cycle( cycle_data: CreateCycle, ): """Create a test cycle and yield it, then delete it.""" + client.projects.update_features(workspace_slug, project.id, ProjectFeature(cycles=True)) cycle = client.cycles.create(workspace_slug, project.id, cycle_data) yield cycle try: @@ -75,6 +76,7 @@ def test_create_cycle( cycle_data: CreateCycle, ) -> None: """Test creating a cycle.""" + client.projects.update_features(workspace_slug, project.id, ProjectFeature(cycles=True)) cycle = client.cycles.create(workspace_slug, project.id, cycle_data) assert cycle is not None assert cycle.id is not None diff --git a/tests/unit/test_intake.py b/tests/unit/test_intake.py index 79fddf0..3f8541e 100644 --- a/tests/unit/test_intake.py +++ b/tests/unit/test_intake.py @@ -4,7 +4,7 @@ from plane.client import PlaneClient from plane.models.intake import CreateIntakeWorkItem -from plane.models.projects import Project +from plane.models.projects import Project, ProjectFeature class TestIntakeAPI: diff --git a/tests/unit/test_modules.py b/tests/unit/test_modules.py index aaa8829..cfeb0df 100644 --- a/tests/unit/test_modules.py +++ b/tests/unit/test_modules.py @@ -7,7 +7,7 @@ from plane.client import PlaneClient from plane.models.enums import ModuleStatus from plane.models.modules import CreateModule, UpdateModule -from plane.models.projects import Project +from plane.models.projects import Project, ProjectFeature class TestModulesAPI: @@ -61,6 +61,7 @@ def module( module_data: CreateModule, ): """Create a test module and yield it, then delete it.""" + client.projects.update_features(workspace_slug, project.id, ProjectFeature(modules=True)) module = client.modules.create(workspace_slug, project.id, module_data) yield module try: @@ -76,6 +77,7 @@ def test_create_module( module_data: CreateModule, ) -> None: """Test creating a module.""" + client.projects.update_features(workspace_slug, project.id, ProjectFeature(modules=True)) module = client.modules.create(workspace_slug, project.id, module_data) assert module is not None assert module.id is not None