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..0cfbde2 --- /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..cc2423f --- /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..20d00da --- /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__ = ["WorkflowStates", "WorkflowTransitions", "Workflows"] diff --git a/plane/api/workflows/base.py b/plane/api/workflows/base.py new file mode 100644 index 0000000..2f93920 --- /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..28c3d86 --- /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..509451e --- /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..c556244 --- /dev/null +++ b/tests/unit/test_project_templates.py @@ -0,0 +1,200 @@ +"""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 +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.""" + return CreateWorkItemTemplate( + name=f"Test WI Template {uuid4().hex}", + 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 as exc: + warnings.warn(f"Cleanup failed for work item template {tmpl.id}: {exc}", stacklevel=1) + + 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 as exc: + warnings.warn(f"Cleanup failed for work item template {tmpl.id}: {exc}", stacklevel=1) + + 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.""" + 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) + + +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.""" + return CreatePageTemplate( + name=f"Test Page Template {uuid4().hex}", + 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 as exc: + warnings.warn(f"Cleanup failed for page template {tmpl.id}: {exc}", stacklevel=1) + + 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 as exc: + warnings.warn(f"Cleanup failed for page template {tmpl.id}: {exc}", stacklevel=1) + + 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.""" + 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) 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)