From 23e9f9ec978e187d80a7d3b2a9fc5f9c67319be5 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Wed, 27 May 2026 12:58:52 +0530 Subject: [PATCH 1/2] feat: add workflows, project templates, and ProjectFeature fields (INFRA-393) - Add Workflows API resource with list/create/update and sub-resources for WorkflowStates (attach/detach) and WorkflowTransitions (CRUD) - Add ProjectTemplates API resource with WorkItemTemplates and PageTemplates sub-resources, each supporting list/create/update/delete - Extend ProjectFeature model with missing fields: workflows, parallel_cycles, project_updates (was 7 fields, now all 10) - Export all new classes from plane/__init__.py - Add unit tests for workflows, project templates, and updated project feature tests Co-authored-by: Plane AI --- plane/__init__.py | 42 ++++ plane/api/project_templates/__init__.py | 5 + plane/api/project_templates/base.py | 15 ++ plane/api/project_templates/page_templates.py | 90 ++++++++ .../project_templates/work_item_templates.py | 90 ++++++++ plane/api/projects.py | 1 + plane/api/workflows/__init__.py | 5 + plane/api/workflows/base.py | 76 +++++++ plane/api/workflows/states.py | 50 +++++ plane/api/workflows/transitions.py | 113 ++++++++++ plane/client/plane_client.py | 5 +- plane/models/project_templates.py | 75 +++++++ plane/models/projects.py | 24 +- plane/models/workflows.py | 89 ++++++++ tests/unit/test_project_templates.py | 205 ++++++++++++++++++ tests/unit/test_projects.py | 8 + tests/unit/test_workflows.py | 187 ++++++++++++++++ 17 files changed, 1070 insertions(+), 10 deletions(-) create mode 100644 plane/api/project_templates/__init__.py create mode 100644 plane/api/project_templates/base.py create mode 100644 plane/api/project_templates/page_templates.py create mode 100644 plane/api/project_templates/work_item_templates.py create mode 100644 plane/api/workflows/__init__.py create mode 100644 plane/api/workflows/base.py create mode 100644 plane/api/workflows/states.py create mode 100644 plane/api/workflows/transitions.py create mode 100644 plane/models/project_templates.py create mode 100644 plane/models/workflows.py create mode 100644 tests/unit/test_project_templates.py create mode 100644 tests/unit/test_workflows.py diff --git a/plane/__init__.py b/plane/__init__.py index 91d3c85..a562d70 100644 --- a/plane/__init__.py +++ b/plane/__init__.py @@ -6,6 +6,7 @@ from .api.milestones import Milestones from .api.modules import Modules from .api.pages import Pages +from .api.project_templates import ProjectPageTemplates, ProjectTemplates, ProjectWorkItemTemplates from .api.projects import Projects from .api.states import States from .api.stickies import Stickies @@ -14,6 +15,7 @@ from .api.work_item_properties import WorkItemProperties from .api.work_item_types import WorkItemTypes from .api.work_items import WorkItems +from .api.workflows import Workflows, WorkflowStates, WorkflowTransitions from .api.workspaces import Workspaces from .client import ( OAuthAuthorizationParams, @@ -26,6 +28,24 @@ ) from .config import Configuration from .errors.errors import ConfigurationError, HttpError, PlaneError +from .models.project_templates import ( + CreatePageTemplate, + CreateWorkItemTemplate, + PageTemplate, + UpdatePageTemplate, + UpdateWorkItemTemplate, + WorkItemTemplate, +) +from .models.projects import ProjectFeature +from .models.workflows import ( + AttachWorkflowStates, + CreateWorkflow, + CreateWorkflowTransition, + UpdateWorkflow, + UpdateWorkflowTransition, + Workflow, + WorkflowTransition, +) __all__ = [ "PlaneClient", @@ -48,6 +68,12 @@ "Estimates", "Pages", "Workspaces", + "Workflows", + "WorkflowStates", + "WorkflowTransitions", + "ProjectTemplates", + "ProjectWorkItemTemplates", + "ProjectPageTemplates", "PlaneError", "ConfigurationError", "HttpError", @@ -56,4 +82,20 @@ "OAuthTokenExchangeParams", "OAuthRefreshTokenParams", "OAuthClientCredentialsParams", + # Workflow models + "Workflow", + "CreateWorkflow", + "UpdateWorkflow", + "AttachWorkflowStates", + "WorkflowTransition", + "CreateWorkflowTransition", + "UpdateWorkflowTransition", + "ProjectFeature", + # Project template models + "WorkItemTemplate", + "CreateWorkItemTemplate", + "UpdateWorkItemTemplate", + "PageTemplate", + "CreatePageTemplate", + "UpdatePageTemplate", ] diff --git a/plane/api/project_templates/__init__.py b/plane/api/project_templates/__init__.py new file mode 100644 index 0000000..355dc1c --- /dev/null +++ b/plane/api/project_templates/__init__.py @@ -0,0 +1,5 @@ +from .base import ProjectTemplates +from .page_templates import ProjectPageTemplates +from .work_item_templates import ProjectWorkItemTemplates + +__all__ = ["ProjectTemplates", "ProjectWorkItemTemplates", "ProjectPageTemplates"] diff --git a/plane/api/project_templates/base.py b/plane/api/project_templates/base.py new file mode 100644 index 0000000..9bbb5da --- /dev/null +++ b/plane/api/project_templates/base.py @@ -0,0 +1,15 @@ +from typing import Any + +from ..base_resource import BaseResource +from .page_templates import ProjectPageTemplates +from .work_item_templates import ProjectWorkItemTemplates + + +class ProjectTemplates(BaseResource): + """API client for managing project-scoped templates (work items and pages).""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + self.work_item_templates = ProjectWorkItemTemplates(config) + self.page_templates = ProjectPageTemplates(config) diff --git a/plane/api/project_templates/page_templates.py b/plane/api/project_templates/page_templates.py new file mode 100644 index 0000000..5198d5e --- /dev/null +++ b/plane/api/project_templates/page_templates.py @@ -0,0 +1,90 @@ +from typing import Any + +from ...models.project_templates import ( + CreatePageTemplate, + PageTemplate, + UpdatePageTemplate, +) +from ..base_resource import BaseResource + + +class ProjectPageTemplates(BaseResource): + """API client for managing page templates within a project.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list(self, workspace_slug: str, project_id: str) -> list[PageTemplate]: + """List all page templates for a project. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + + Returns: + List of page templates + """ + data = self._get(f"{workspace_slug}/projects/{project_id}/pages/templates") + items = data.get("results", data) if isinstance(data, dict) else data + return [PageTemplate.model_validate(item) for item in items] + + def create( + self, + workspace_slug: str, + project_id: str, + data: CreatePageTemplate, + ) -> PageTemplate: + """Create a new page template for a project. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + data: Template data + + Returns: + The created page template + """ + response = self._post( + f"{workspace_slug}/projects/{project_id}/pages/templates", + data.model_dump(exclude_none=True), + ) + return PageTemplate.model_validate(response) + + def update( + self, + workspace_slug: str, + project_id: str, + template_id: str, + data: UpdatePageTemplate, + ) -> PageTemplate: + """Update a page template by ID. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + template_id: UUID of the template + data: Updated template data + + Returns: + The updated page template + """ + response = self._patch( + f"{workspace_slug}/projects/{project_id}/pages/templates/{template_id}", + data.model_dump(exclude_none=True), + ) + return PageTemplate.model_validate(response) + + def delete( + self, + workspace_slug: str, + project_id: str, + template_id: str, + ) -> None: + """Delete a page template by ID. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + template_id: UUID of the template + """ + self._delete(f"{workspace_slug}/projects/{project_id}/pages/templates/{template_id}") diff --git a/plane/api/project_templates/work_item_templates.py b/plane/api/project_templates/work_item_templates.py new file mode 100644 index 0000000..33c8d79 --- /dev/null +++ b/plane/api/project_templates/work_item_templates.py @@ -0,0 +1,90 @@ +from typing import Any + +from ...models.project_templates import ( + CreateWorkItemTemplate, + UpdateWorkItemTemplate, + WorkItemTemplate, +) +from ..base_resource import BaseResource + + +class ProjectWorkItemTemplates(BaseResource): + """API client for managing work item templates within a project.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list(self, workspace_slug: str, project_id: str) -> list[WorkItemTemplate]: + """List all work item templates for a project. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + + Returns: + List of work item templates + """ + data = self._get(f"{workspace_slug}/projects/{project_id}/workitems/templates") + items = data.get("results", data) if isinstance(data, dict) else data + return [WorkItemTemplate.model_validate(item) for item in items] + + def create( + self, + workspace_slug: str, + project_id: str, + data: CreateWorkItemTemplate, + ) -> WorkItemTemplate: + """Create a new work item template for a project. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + data: Template data + + Returns: + The created work item template + """ + response = self._post( + f"{workspace_slug}/projects/{project_id}/workitems/templates", + data.model_dump(exclude_none=True), + ) + return WorkItemTemplate.model_validate(response) + + def update( + self, + workspace_slug: str, + project_id: str, + template_id: str, + data: UpdateWorkItemTemplate, + ) -> WorkItemTemplate: + """Update a work item template by ID. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + template_id: UUID of the template + data: Updated template data + + Returns: + The updated work item template + """ + response = self._patch( + f"{workspace_slug}/projects/{project_id}/workitems/templates/{template_id}", + data.model_dump(exclude_none=True), + ) + return WorkItemTemplate.model_validate(response) + + def delete( + self, + workspace_slug: str, + project_id: str, + template_id: str, + ) -> None: + """Delete a work item template by ID. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + template_id: UUID of the template + """ + self._delete(f"{workspace_slug}/projects/{project_id}/workitems/templates/{template_id}") diff --git a/plane/api/projects.py b/plane/api/projects.py index 95ae0e1..6c30207 100644 --- a/plane/api/projects.py +++ b/plane/api/projects.py @@ -149,3 +149,4 @@ def unarchive(self, workspace_slug: str, project_id: str) -> None: None (HTTP 204 No Content) """ self._delete(f"{workspace_slug}/projects/{project_id}/archive") + diff --git a/plane/api/workflows/__init__.py b/plane/api/workflows/__init__.py new file mode 100644 index 0000000..401a955 --- /dev/null +++ b/plane/api/workflows/__init__.py @@ -0,0 +1,5 @@ +from .base import Workflows +from .states import WorkflowStates +from .transitions import WorkflowTransitions + +__all__ = ["Workflows", "WorkflowStates", "WorkflowTransitions"] diff --git a/plane/api/workflows/base.py b/plane/api/workflows/base.py new file mode 100644 index 0000000..11c9ee2 --- /dev/null +++ b/plane/api/workflows/base.py @@ -0,0 +1,76 @@ +from typing import Any + +from ...models.workflows import CreateWorkflow, UpdateWorkflow, Workflow +from ..base_resource import BaseResource +from .states import WorkflowStates +from .transitions import WorkflowTransitions + + +class Workflows(BaseResource): + """API client for managing project workflows.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + self.states = WorkflowStates(config) + self.transitions = WorkflowTransitions(config) + + def list(self, workspace_slug: str, project_id: str) -> list[Workflow]: + """List all workflows for a project. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + + Returns: + List of workflows + """ + data = self._get(f"{workspace_slug}/projects/{project_id}/workflows") + items = data.get("results", data) if isinstance(data, dict) else data + return [Workflow.model_validate(item) for item in items] + + def create( + self, + workspace_slug: str, + project_id: str, + data: CreateWorkflow, + ) -> Workflow: + """Create a new workflow for a project. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + data: Workflow data + + Returns: + The created workflow + """ + response = self._post( + f"{workspace_slug}/projects/{project_id}/workflows", + data.model_dump(exclude_none=True), + ) + return Workflow.model_validate(response) + + def update( + self, + workspace_slug: str, + project_id: str, + workflow_id: str, + data: UpdateWorkflow, + ) -> Workflow: + """Update a workflow by ID. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + data: Updated workflow data + + Returns: + The updated workflow + """ + response = self._patch( + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}", + data.model_dump(exclude_none=True), + ) + return Workflow.model_validate(response) diff --git a/plane/api/workflows/states.py b/plane/api/workflows/states.py new file mode 100644 index 0000000..9344ddd --- /dev/null +++ b/plane/api/workflows/states.py @@ -0,0 +1,50 @@ +from typing import Any + +from ...models.workflows import AttachWorkflowStates +from ..base_resource import BaseResource + + +class WorkflowStates(BaseResource): + """API client for managing states attached to a workflow.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def attach( + self, + workspace_slug: str, + project_id: str, + workflow_id: str, + data: AttachWorkflowStates, + ) -> None: + """Attach states to a workflow. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + data: Request body containing the list of state IDs to attach + """ + self._post( + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/states", + data.model_dump(exclude_none=True), + ) + + def detach( + self, + workspace_slug: str, + project_id: str, + workflow_id: str, + state_id: str, + ) -> None: + """Detach a state from a workflow. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + state_id: UUID of the state to detach + """ + self._delete( + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/states/{state_id}" + ) diff --git a/plane/api/workflows/transitions.py b/plane/api/workflows/transitions.py new file mode 100644 index 0000000..2bfd75d --- /dev/null +++ b/plane/api/workflows/transitions.py @@ -0,0 +1,113 @@ +from typing import Any + +from ...models.workflows import ( + CreateWorkflowTransition, + UpdateWorkflowTransition, + WorkflowTransition, +) +from ..base_resource import BaseResource + + +class WorkflowTransitions(BaseResource): + """API client for managing state transitions within a workflow.""" + + def __init__(self, config: Any) -> None: + super().__init__(config, "/workspaces/") + + def list( + self, + workspace_slug: str, + project_id: str, + workflow_id: str, + ) -> list[WorkflowTransition]: + """List all state transitions for a workflow. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + + Returns: + List of workflow transitions + """ + data = self._get( + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/state-transitions" + ) + items = data.get("results", data) if isinstance(data, dict) else data + return [WorkflowTransition.model_validate(item) for item in items] + + def create( + self, + workspace_slug: str, + project_id: str, + workflow_id: str, + data: CreateWorkflowTransition, + ) -> WorkflowTransition | None: + """Create a new state transition for a workflow. + + Returns None if the transition already exists (HTTP 400). + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + data: Transition data + + Returns: + The created workflow transition, or None if it already exists + """ + from ...errors.errors import HttpError + + try: + response = self._post( + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/state-transitions", + data.model_dump(exclude_none=True), + ) + except HttpError as exc: + if exc.status_code == 400 and "already exists" in str(exc.response).lower(): + return None + raise + return WorkflowTransition.model_validate(response) + + def update( + self, + workspace_slug: str, + project_id: str, + workflow_id: str, + transition_id: str, + data: UpdateWorkflowTransition, + ) -> None: + """Update a workflow state transition. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + transition_id: UUID of the transition + data: Updated transition data + """ + self._patch( + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}" + f"/state-transitions/{transition_id}", + data.model_dump(exclude_none=True), + ) + + def delete( + self, + workspace_slug: str, + project_id: str, + workflow_id: str, + transition_id: str, + ) -> None: + """Delete a workflow state transition. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + workflow_id: UUID of the workflow + transition_id: UUID of the transition + """ + self._delete( + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}" + f"/state-transitions/{transition_id}" + ) diff --git a/plane/client/plane_client.py b/plane/client/plane_client.py index eb10231..ba50b10 100644 --- a/plane/client/plane_client.py +++ b/plane/client/plane_client.py @@ -9,6 +9,7 @@ from ..api.milestones import Milestones from ..api.modules import Modules from ..api.pages import Pages +from ..api.project_templates import ProjectTemplates from ..api.projects import Projects from ..api.states import States from ..api.stickies import Stickies @@ -17,6 +18,7 @@ from ..api.work_item_properties import WorkItemProperties from ..api.work_item_types import WorkItemTypes from ..api.work_items import WorkItems +from ..api.workflows import Workflows from ..api.workspaces import Workspaces from ..config import Configuration from ..errors import ConfigurationError @@ -65,4 +67,5 @@ def __init__( self.stickies = Stickies(self.config) self.initiatives = Initiatives(self.config) self.teamspaces = Teamspaces(self.config) - + self.workflows = Workflows(self.config) + self.project_templates = ProjectTemplates(self.config) diff --git a/plane/models/project_templates.py b/plane/models/project_templates.py new file mode 100644 index 0000000..a72d4dc --- /dev/null +++ b/plane/models/project_templates.py @@ -0,0 +1,75 @@ +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class WorkItemTemplate(BaseModel): + """Work item template model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + name: str + short_description: str | None = None + template_data: dict[str, Any] | None = None + template_type: str | None = None + created_at: str | None = None + updated_at: str | None = None + project: str | None = None + workspace: str | None = None + + +class CreateWorkItemTemplate(BaseModel): + """Request model for creating a work item template.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str + short_description: str | None = None + template_data: dict[str, Any] | None = None + + +class UpdateWorkItemTemplate(BaseModel): + """Request model for updating a work item template.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str | None = None + short_description: str | None = None + template_data: dict[str, Any] | None = None + + +class PageTemplate(BaseModel): + """Page template model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + name: str + short_description: str | None = None + template_data: dict[str, Any] | None = None + template_type: str | None = None + created_at: str | None = None + updated_at: str | None = None + project: str | None = None + workspace: str | None = None + + +class CreatePageTemplate(BaseModel): + """Request model for creating a page template.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str + short_description: str | None = None + template_data: dict[str, Any] | None = None + + +class UpdatePageTemplate(BaseModel): + """Request model for updating a page template.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str | None = None + short_description: str | None = None + template_data: dict[str, Any] | None = None diff --git a/plane/models/projects.py b/plane/models/projects.py index 2153c82..9a52532 100644 --- a/plane/models/projects.py +++ b/plane/models/projects.py @@ -136,15 +136,21 @@ class PaginatedProjectResponse(PaginatedResponse): results: list[Project] + class ProjectFeature(BaseModel): - """Project feature model.""" + """Project feature model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + 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 + workflows: bool | None = None + parallel_cycles: bool | None = None + project_updates: bool | None = None - model_config = ConfigDict(extra="allow", populate_by_name=True) - 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 diff --git a/plane/models/workflows.py b/plane/models/workflows.py new file mode 100644 index 0000000..9292bcd --- /dev/null +++ b/plane/models/workflows.py @@ -0,0 +1,89 @@ +from typing import Any + +from pydantic import BaseModel, ConfigDict + + +class Workflow(BaseModel): + """Workflow model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + name: str + description: str | None = None + is_active: bool | None = None + is_default: bool | None = None + work_item_type_ids: list[str] | None = None + created_at: str | None = None + updated_at: str | None = None + project: str | None = None + workspace: str | None = None + + +class CreateWorkflow(BaseModel): + """Request model for creating a workflow.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str + description: str | None = None + is_active: bool | None = None + work_item_type_ids: list[str] | None = None + + +class UpdateWorkflow(BaseModel): + """Request model for updating a workflow.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + name: str | None = None + description: str | None = None + is_active: bool | None = None + work_item_type_ids: list[str] | None = None + + +class AttachWorkflowStates(BaseModel): + """Request model for attaching states to a workflow.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + state_ids: list[str] + + +class WorkflowTransition(BaseModel): + """Workflow transition model.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + state_id: str | None = None + transition_state_id: str | None = None + type: str | None = None + member_ids: list[str] | None = None + pre_rules: list[dict[str, Any]] | None = None + post_rules: list[dict[str, Any]] | None = None + workflow_state_id: str | None = None + created_at: str | None = None + updated_at: str | None = None + + +class CreateWorkflowTransition(BaseModel): + """Request model for creating a workflow transition.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + state_id: str + transition_state_id: str + type: str | None = None + member_ids: list[str] | None = None + pre_rules: list[dict[str, Any]] | None = None + post_rules: list[dict[str, Any]] | None = None + + +class UpdateWorkflowTransition(BaseModel): + """Request model for updating a workflow transition.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + pre_rules: list[dict[str, Any]] | None = None + post_rules: list[dict[str, Any]] | None = None diff --git a/tests/unit/test_project_templates.py b/tests/unit/test_project_templates.py new file mode 100644 index 0000000..c691a1b --- /dev/null +++ b/tests/unit/test_project_templates.py @@ -0,0 +1,205 @@ +"""Unit tests for Project Templates API resources (smoke tests with real HTTP requests).""" + +import pytest + +from plane.client import PlaneClient +from plane.models.project_templates import ( + CreatePageTemplate, + CreateWorkItemTemplate, + UpdatePageTemplate, + UpdateWorkItemTemplate, +) +from plane.models.projects import Project + + +class TestProjectWorkItemTemplatesAPI: + """Test ProjectWorkItemTemplates API resource.""" + + def test_list_work_item_templates( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """Test listing work item templates for a project.""" + response = client.project_templates.work_item_templates.list(workspace_slug, project.id) + assert response is not None + assert isinstance(response, list) + + +class TestProjectWorkItemTemplatesAPICRUD: + """Test ProjectWorkItemTemplates API CRUD operations.""" + + @pytest.fixture + def template_data(self) -> CreateWorkItemTemplate: + """Create test work item template data.""" + import time + + return CreateWorkItemTemplate( + name=f"Test WI Template {int(time.time())}", + short_description="A test work item template", + ) + + @pytest.fixture + def work_item_template( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + template_data: CreateWorkItemTemplate, + ): + """Create a test work item template and yield it, then delete it.""" + tmpl = client.project_templates.work_item_templates.create( + workspace_slug, project.id, template_data + ) + yield tmpl + try: + client.project_templates.work_item_templates.delete(workspace_slug, project.id, tmpl.id) + except Exception: + pass + + def test_create_work_item_template( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + template_data: CreateWorkItemTemplate, + ) -> None: + """Test creating a work item template.""" + tmpl = client.project_templates.work_item_templates.create( + workspace_slug, project.id, template_data + ) + assert tmpl is not None + assert tmpl.id is not None + assert tmpl.name == template_data.name + # Cleanup + try: + client.project_templates.work_item_templates.delete(workspace_slug, project.id, tmpl.id) + except Exception: + pass + + def test_update_work_item_template( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + work_item_template, + ) -> None: + """Test updating a work item template.""" + updated = client.project_templates.work_item_templates.update( + workspace_slug, + project.id, + work_item_template.id, + UpdateWorkItemTemplate(short_description="Updated description"), + ) + assert updated is not None + assert updated.id == work_item_template.id + assert updated.short_description == "Updated description" + + def test_delete_work_item_template( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + template_data: CreateWorkItemTemplate, + ) -> None: + """Test deleting a work item template.""" + import time + + data = CreateWorkItemTemplate(name=f"Delete Me WI {int(time.time())}") + tmpl = client.project_templates.work_item_templates.create(workspace_slug, project.id, data) + assert tmpl.id is not None + client.project_templates.work_item_templates.delete(workspace_slug, project.id, tmpl.id) + + +class TestProjectPageTemplatesAPI: + """Test ProjectPageTemplates API resource.""" + + def test_list_page_templates( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """Test listing page templates for a project.""" + response = client.project_templates.page_templates.list(workspace_slug, project.id) + assert response is not None + assert isinstance(response, list) + + +class TestProjectPageTemplatesAPICRUD: + """Test ProjectPageTemplates API CRUD operations.""" + + @pytest.fixture + def page_template_data(self) -> CreatePageTemplate: + """Create test page template data.""" + import time + + return CreatePageTemplate( + name=f"Test Page Template {int(time.time())}", + short_description="A test page template", + ) + + @pytest.fixture + def page_template( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + page_template_data: CreatePageTemplate, + ): + """Create a test page template and yield it, then delete it.""" + tmpl = client.project_templates.page_templates.create( + workspace_slug, project.id, page_template_data + ) + yield tmpl + try: + client.project_templates.page_templates.delete(workspace_slug, project.id, tmpl.id) + except Exception: + pass + + def test_create_page_template( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + page_template_data: CreatePageTemplate, + ) -> None: + """Test creating a page template.""" + tmpl = client.project_templates.page_templates.create( + workspace_slug, project.id, page_template_data + ) + assert tmpl is not None + assert tmpl.id is not None + assert tmpl.name == page_template_data.name + # Cleanup + try: + client.project_templates.page_templates.delete(workspace_slug, project.id, tmpl.id) + except Exception: + pass + + def test_update_page_template( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + page_template, + ) -> None: + """Test updating a page template.""" + updated = client.project_templates.page_templates.update( + workspace_slug, + project.id, + page_template.id, + UpdatePageTemplate(short_description="Updated description"), + ) + assert updated is not None + assert updated.id == page_template.id + assert updated.short_description == "Updated description" + + def test_delete_page_template( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + ) -> None: + """Test deleting a page template.""" + import time + + data = CreatePageTemplate(name=f"Delete Me Page {int(time.time())}") + tmpl = client.project_templates.page_templates.create(workspace_slug, project.id, data) + assert tmpl.id is not None + client.project_templates.page_templates.delete(workspace_slug, project.id, tmpl.id) diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py index db420a7..7e5f05c 100644 --- a/tests/unit/test_projects.py +++ b/tests/unit/test_projects.py @@ -105,7 +105,11 @@ def test_get_features(self, client: PlaneClient, workspace_slug: str, project: P assert hasattr(features, "views") assert hasattr(features, "pages") assert hasattr(features, "intakes") + assert hasattr(features, "epics") assert hasattr(features, "work_item_types") + assert hasattr(features, "workflows") + assert hasattr(features, "parallel_cycles") + assert hasattr(features, "project_updates") def test_update_features( self, client: PlaneClient, workspace_slug: str, project: Project @@ -124,4 +128,8 @@ def test_update_features( assert hasattr(updated, "views") assert hasattr(updated, "pages") assert hasattr(updated, "intakes") + assert hasattr(updated, "epics") assert hasattr(updated, "work_item_types") + assert hasattr(updated, "workflows") + assert hasattr(updated, "parallel_cycles") + assert hasattr(updated, "project_updates") diff --git a/tests/unit/test_workflows.py b/tests/unit/test_workflows.py new file mode 100644 index 0000000..39a6510 --- /dev/null +++ b/tests/unit/test_workflows.py @@ -0,0 +1,187 @@ +"""Unit tests for Workflows API resource (smoke tests with real HTTP requests).""" + +import pytest + +from plane.client import PlaneClient +from plane.models.projects import Project, ProjectFeature +from plane.models.workflows import ( + AttachWorkflowStates, + CreateWorkflow, + CreateWorkflowTransition, + UpdateWorkflow, + UpdateWorkflowTransition, +) + + +class TestWorkflowsAPI: + """Test Workflows API list/create/update.""" + + def test_list_workflows( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """Test listing workflows for a project.""" + # Enable work_item_types feature which workflows depend on + client.projects.update_features( + workspace_slug, project.id, ProjectFeature(work_item_types=True) + ) + response = client.workflows.list(workspace_slug, project.id) + assert response is not None + assert isinstance(response, list) + + +class TestWorkflowsAPICRUD: + """Test Workflows API CRUD and sub-resource operations.""" + + @pytest.fixture + def workflow_data(self) -> CreateWorkflow: + """Create test workflow data.""" + import time + + return CreateWorkflow( + name=f"Test Workflow {int(time.time())}", + description="Test workflow", + is_active=True, + ) + + @pytest.fixture + def workflow( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + workflow_data: CreateWorkflow, + ): + """Create a test workflow and yield it.""" + client.projects.update_features( + workspace_slug, project.id, ProjectFeature(work_item_types=True) + ) + wf = client.workflows.create(workspace_slug, project.id, workflow_data) + yield wf + + def test_create_workflow( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + workflow_data: CreateWorkflow, + ) -> None: + """Test creating a workflow.""" + client.projects.update_features( + workspace_slug, project.id, ProjectFeature(work_item_types=True) + ) + wf = client.workflows.create(workspace_slug, project.id, workflow_data) + assert wf is not None + assert wf.id is not None + assert wf.name == workflow_data.name + + def test_update_workflow( + self, client: PlaneClient, workspace_slug: str, project: Project, workflow + ) -> None: + """Test updating a workflow.""" + updated = client.workflows.update( + workspace_slug, + project.id, + workflow.id, + UpdateWorkflow(description="Updated description"), + ) + assert updated is not None + assert updated.id == workflow.id + assert updated.description == "Updated description" + + def test_attach_states( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + workflow, + ) -> None: + """Test attaching states to a workflow.""" + # List available states and attach the first one + states = client.states.list(workspace_slug, project.id) + if not states or not states.results: + pytest.skip("No states available to attach") + state_id = states.results[0].id + # attach should not raise + client.workflows.states.attach( + workspace_slug, + project.id, + workflow.id, + AttachWorkflowStates(state_ids=[state_id]), + ) + + def test_detach_state( + self, + client: PlaneClient, + workspace_slug: str, + project: Project, + workflow, + ) -> None: + """Test detaching a state from a workflow.""" + states = client.states.list(workspace_slug, project.id) + if not states or not states.results: + pytest.skip("No states available") + state_id = states.results[0].id + # Attach first, then detach + client.workflows.states.attach( + workspace_slug, + project.id, + workflow.id, + AttachWorkflowStates(state_ids=[state_id]), + ) + client.workflows.states.detach(workspace_slug, project.id, workflow.id, state_id) + + def test_list_transitions( + self, client: PlaneClient, workspace_slug: str, project: Project, workflow + ) -> None: + """Test listing transitions for a workflow.""" + transitions = client.workflows.transitions.list(workspace_slug, project.id, workflow.id) + assert transitions is not None + assert isinstance(transitions, list) + + def test_create_transition( + self, client: PlaneClient, workspace_slug: str, project: Project, workflow + ) -> None: + """Test creating a transition between two states.""" + states = client.states.list(workspace_slug, project.id) + if not states or not states.results or len(states.results) < 2: + pytest.skip("Need at least 2 states to create a transition") + state_id = states.results[0].id + transition_state_id = states.results[1].id + result = client.workflows.transitions.create( + workspace_slug, + project.id, + workflow.id, + CreateWorkflowTransition( + state_id=state_id, + transition_state_id=transition_state_id, + ), + ) + # May return None if transition already exists (400) + assert result is None or result.id is not None + + def test_update_transition( + self, client: PlaneClient, workspace_slug: str, project: Project, workflow + ) -> None: + """Test updating a workflow transition.""" + transitions = client.workflows.transitions.list(workspace_slug, project.id, workflow.id) + if not transitions: + pytest.skip("No transitions available to update") + transition_id = transitions[0].id + # Should not raise + client.workflows.transitions.update( + workspace_slug, + project.id, + workflow.id, + transition_id, + UpdateWorkflowTransition(post_rules=[]), + ) + + def test_delete_transition( + self, client: PlaneClient, workspace_slug: str, project: Project, workflow + ) -> None: + """Test deleting a workflow transition.""" + transitions = client.workflows.transitions.list(workspace_slug, project.id, workflow.id) + if not transitions: + pytest.skip("No transitions available to delete") + transition_id = transitions[0].id + client.workflows.transitions.delete(workspace_slug, project.id, workflow.id, transition_id) From dc1f4fb62c475b8df1bf280775aadb0cc1f60f72 Mon Sep 17 00:00:00 2001 From: Manish Gupta Date: Wed, 27 May 2026 13:39:54 +0530 Subject: [PATCH 2/2] fix: address CodeRabbit review comments on INFRA-393 - Add trailing slashes to all API endpoint paths (workflows, workflow states, workflow transitions, work item templates, page templates) - Sort __all__ in plane/api/workflows/__init__.py (Ruff RUF022) - Replace int(time.time()) with uuid4().hex in test fixtures to prevent name collisions on fast/parallel runs - Replace silent `except Exception: pass` with warnings.warn in test teardown to surface cleanup failures Co-authored-by: Plane AI --- plane/api/project_templates/page_templates.py | 8 ++--- .../project_templates/work_item_templates.py | 6 ++-- plane/api/workflows/__init__.py | 2 +- plane/api/workflows/base.py | 6 ++-- plane/api/workflows/states.py | 4 +-- plane/api/workflows/transitions.py | 8 ++--- tests/unit/test_project_templates.py | 35 ++++++++----------- 7 files changed, 32 insertions(+), 37 deletions(-) diff --git a/plane/api/project_templates/page_templates.py b/plane/api/project_templates/page_templates.py index 5198d5e..0cfbde2 100644 --- a/plane/api/project_templates/page_templates.py +++ b/plane/api/project_templates/page_templates.py @@ -24,7 +24,7 @@ def list(self, workspace_slug: str, project_id: str) -> list[PageTemplate]: Returns: List of page templates """ - data = self._get(f"{workspace_slug}/projects/{project_id}/pages/templates") + data = self._get(f"{workspace_slug}/projects/{project_id}/pages/templates/") items = data.get("results", data) if isinstance(data, dict) else data return [PageTemplate.model_validate(item) for item in items] @@ -45,7 +45,7 @@ def create( The created page template """ response = self._post( - f"{workspace_slug}/projects/{project_id}/pages/templates", + f"{workspace_slug}/projects/{project_id}/pages/templates/", data.model_dump(exclude_none=True), ) return PageTemplate.model_validate(response) @@ -69,7 +69,7 @@ def update( The updated page template """ response = self._patch( - f"{workspace_slug}/projects/{project_id}/pages/templates/{template_id}", + f"{workspace_slug}/projects/{project_id}/pages/templates/{template_id}/", data.model_dump(exclude_none=True), ) return PageTemplate.model_validate(response) @@ -87,4 +87,4 @@ def delete( project_id: UUID of the project template_id: UUID of the template """ - self._delete(f"{workspace_slug}/projects/{project_id}/pages/templates/{template_id}") + self._delete(f"{workspace_slug}/projects/{project_id}/pages/templates/{template_id}/") diff --git a/plane/api/project_templates/work_item_templates.py b/plane/api/project_templates/work_item_templates.py index 33c8d79..cc2423f 100644 --- a/plane/api/project_templates/work_item_templates.py +++ b/plane/api/project_templates/work_item_templates.py @@ -24,7 +24,7 @@ def list(self, workspace_slug: str, project_id: str) -> list[WorkItemTemplate]: Returns: List of work item templates """ - data = self._get(f"{workspace_slug}/projects/{project_id}/workitems/templates") + data = self._get(f"{workspace_slug}/projects/{project_id}/workitems/templates/") items = data.get("results", data) if isinstance(data, dict) else data return [WorkItemTemplate.model_validate(item) for item in items] @@ -45,7 +45,7 @@ def create( The created work item template """ response = self._post( - f"{workspace_slug}/projects/{project_id}/workitems/templates", + f"{workspace_slug}/projects/{project_id}/workitems/templates/", data.model_dump(exclude_none=True), ) return WorkItemTemplate.model_validate(response) @@ -87,4 +87,4 @@ def delete( project_id: UUID of the project template_id: UUID of the template """ - self._delete(f"{workspace_slug}/projects/{project_id}/workitems/templates/{template_id}") + self._delete(f"{workspace_slug}/projects/{project_id}/workitems/templates/{template_id}/") diff --git a/plane/api/workflows/__init__.py b/plane/api/workflows/__init__.py index 401a955..20d00da 100644 --- a/plane/api/workflows/__init__.py +++ b/plane/api/workflows/__init__.py @@ -2,4 +2,4 @@ from .states import WorkflowStates from .transitions import WorkflowTransitions -__all__ = ["Workflows", "WorkflowStates", "WorkflowTransitions"] +__all__ = ["WorkflowStates", "WorkflowTransitions", "Workflows"] diff --git a/plane/api/workflows/base.py b/plane/api/workflows/base.py index 11c9ee2..2f93920 100644 --- a/plane/api/workflows/base.py +++ b/plane/api/workflows/base.py @@ -25,7 +25,7 @@ def list(self, workspace_slug: str, project_id: str) -> list[Workflow]: Returns: List of workflows """ - data = self._get(f"{workspace_slug}/projects/{project_id}/workflows") + data = self._get(f"{workspace_slug}/projects/{project_id}/workflows/") items = data.get("results", data) if isinstance(data, dict) else data return [Workflow.model_validate(item) for item in items] @@ -46,7 +46,7 @@ def create( The created workflow """ response = self._post( - f"{workspace_slug}/projects/{project_id}/workflows", + f"{workspace_slug}/projects/{project_id}/workflows/", data.model_dump(exclude_none=True), ) return Workflow.model_validate(response) @@ -70,7 +70,7 @@ def update( The updated workflow """ response = self._patch( - f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}", + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/", data.model_dump(exclude_none=True), ) return Workflow.model_validate(response) diff --git a/plane/api/workflows/states.py b/plane/api/workflows/states.py index 9344ddd..28c3d86 100644 --- a/plane/api/workflows/states.py +++ b/plane/api/workflows/states.py @@ -26,7 +26,7 @@ def attach( data: Request body containing the list of state IDs to attach """ self._post( - f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/states", + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/states/", data.model_dump(exclude_none=True), ) @@ -46,5 +46,5 @@ def detach( state_id: UUID of the state to detach """ self._delete( - f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/states/{state_id}" + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/states/{state_id}/" ) diff --git a/plane/api/workflows/transitions.py b/plane/api/workflows/transitions.py index 2bfd75d..509451e 100644 --- a/plane/api/workflows/transitions.py +++ b/plane/api/workflows/transitions.py @@ -31,7 +31,7 @@ def list( List of workflow transitions """ data = self._get( - f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/state-transitions" + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/state-transitions/" ) items = data.get("results", data) if isinstance(data, dict) else data return [WorkflowTransition.model_validate(item) for item in items] @@ -60,7 +60,7 @@ def create( try: response = self._post( - f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/state-transitions", + f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}/state-transitions/", data.model_dump(exclude_none=True), ) except HttpError as exc: @@ -88,7 +88,7 @@ def update( """ self._patch( f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}" - f"/state-transitions/{transition_id}", + f"/state-transitions/{transition_id}/", data.model_dump(exclude_none=True), ) @@ -109,5 +109,5 @@ def delete( """ self._delete( f"{workspace_slug}/projects/{project_id}/workflows/{workflow_id}" - f"/state-transitions/{transition_id}" + f"/state-transitions/{transition_id}/" ) diff --git a/tests/unit/test_project_templates.py b/tests/unit/test_project_templates.py index c691a1b..c556244 100644 --- a/tests/unit/test_project_templates.py +++ b/tests/unit/test_project_templates.py @@ -1,5 +1,8 @@ """Unit tests for Project Templates API resources (smoke tests with real HTTP requests).""" +import warnings +from uuid import uuid4 + import pytest from plane.client import PlaneClient @@ -30,10 +33,8 @@ class TestProjectWorkItemTemplatesAPICRUD: @pytest.fixture def template_data(self) -> CreateWorkItemTemplate: """Create test work item template data.""" - import time - return CreateWorkItemTemplate( - name=f"Test WI Template {int(time.time())}", + name=f"Test WI Template {uuid4().hex}", short_description="A test work item template", ) @@ -52,8 +53,8 @@ def work_item_template( yield tmpl try: client.project_templates.work_item_templates.delete(workspace_slug, project.id, tmpl.id) - except Exception: - pass + except Exception as exc: + warnings.warn(f"Cleanup failed for work item template {tmpl.id}: {exc}", stacklevel=1) def test_create_work_item_template( self, @@ -72,8 +73,8 @@ def test_create_work_item_template( # Cleanup try: client.project_templates.work_item_templates.delete(workspace_slug, project.id, tmpl.id) - except Exception: - pass + except Exception as exc: + warnings.warn(f"Cleanup failed for work item template {tmpl.id}: {exc}", stacklevel=1) def test_update_work_item_template( self, @@ -101,9 +102,7 @@ def test_delete_work_item_template( template_data: CreateWorkItemTemplate, ) -> None: """Test deleting a work item template.""" - import time - - data = CreateWorkItemTemplate(name=f"Delete Me WI {int(time.time())}") + data = CreateWorkItemTemplate(name=f"Delete Me WI {uuid4().hex}") tmpl = client.project_templates.work_item_templates.create(workspace_slug, project.id, data) assert tmpl.id is not None client.project_templates.work_item_templates.delete(workspace_slug, project.id, tmpl.id) @@ -127,10 +126,8 @@ class TestProjectPageTemplatesAPICRUD: @pytest.fixture def page_template_data(self) -> CreatePageTemplate: """Create test page template data.""" - import time - return CreatePageTemplate( - name=f"Test Page Template {int(time.time())}", + name=f"Test Page Template {uuid4().hex}", short_description="A test page template", ) @@ -149,8 +146,8 @@ def page_template( yield tmpl try: client.project_templates.page_templates.delete(workspace_slug, project.id, tmpl.id) - except Exception: - pass + except Exception as exc: + warnings.warn(f"Cleanup failed for page template {tmpl.id}: {exc}", stacklevel=1) def test_create_page_template( self, @@ -169,8 +166,8 @@ def test_create_page_template( # Cleanup try: client.project_templates.page_templates.delete(workspace_slug, project.id, tmpl.id) - except Exception: - pass + except Exception as exc: + warnings.warn(f"Cleanup failed for page template {tmpl.id}: {exc}", stacklevel=1) def test_update_page_template( self, @@ -197,9 +194,7 @@ def test_delete_page_template( project: Project, ) -> None: """Test deleting a page template.""" - import time - - data = CreatePageTemplate(name=f"Delete Me Page {int(time.time())}") + data = CreatePageTemplate(name=f"Delete Me Page {uuid4().hex}") tmpl = client.project_templates.page_templates.create(workspace_slug, project.id, data) assert tmpl.id is not None client.project_templates.page_templates.delete(workspace_slug, project.id, tmpl.id)