Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion plane/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
UpdateWorkItemTemplate,
WorkItemTemplate,
)
from .models.projects import ProjectFeature
from .models.projects import ProjectFeature, ProjectMember
from .models.workflows import (
AttachWorkflowStates,
CreateWorkflow,
Expand All @@ -53,6 +53,7 @@
Workflow,
WorkflowTransition,
)
from .models.workspaces import WorkspaceMember

__all__ = [
"PlaneClient",
Expand Down Expand Up @@ -105,6 +106,8 @@
"CreateWorkflowTransition",
"UpdateWorkflowTransition",
"ProjectFeature",
"ProjectMember",
"WorkspaceMember",
# Project template models
"WorkItemTemplate",
"CreateWorkItemTemplate",
Expand Down
11 changes: 8 additions & 3 deletions plane/api/projects.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from collections.abc import Mapping
from typing import Any

Expand All @@ -6,11 +8,11 @@
PaginatedProjectResponse,
Project,
ProjectFeature,
ProjectMember,
ProjectWorklogSummary,
UpdateProject,
)
from ..models.query_params import PaginatedQueryParams
from ..models.users import UserLite
from .base_resource import BaseResource


Expand Down Expand Up @@ -85,16 +87,19 @@ def get_worklog_summary(self, workspace_slug: str, project_id: str) -> [ProjectW

def get_members(
self, workspace_slug: str, project_id: str, params: Mapping[str, Any] | None = None
) -> [UserLite]:
) -> list[ProjectMember]:
"""Get all members of a project.

Returns a list of ProjectMember objects that include role (int) and
role_slug (str) fields in addition to basic identity fields.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
params: Optional query parameters
"""
response = self._get(f"{workspace_slug}/projects/{project_id}/members", params=params)
return [UserLite.model_validate(item) for item in response or []]
return [ProjectMember.model_validate(item) for item in response or []]

def get_features(self, workspace_slug: str, project_id: str) -> ProjectFeature:
"""Get features of a project.
Expand Down
23 changes: 23 additions & 0 deletions plane/api/work_item_types.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

from collections.abc import Mapping
from typing import Any

Expand Down Expand Up @@ -87,3 +89,24 @@ def list(
f"{workspace_slug}/projects/{project_id}/work-item-types", params=params
)
return [WorkItemType.model_validate(item) for item in response]

def import_to_project(
self,
workspace_slug: str,
project_id: str,
work_item_type_ids: list[str],
) -> None:
"""Bulk-link workspace-level work item types to a project.

Imports one or more workspace-scoped work item types into a project so
that they become available for use within that project.

Args:
workspace_slug: The workspace slug identifier
project_id: UUID of the project
work_item_type_ids: List of workspace work item type UUIDs to import
"""
self._post(
f"{workspace_slug}/projects/{project_id}/import-work-item-types",
{"work_item_types": work_item_type_ids},
)
12 changes: 8 additions & 4 deletions plane/api/workspaces.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from __future__ import annotations

from typing import Any

from ..models.users import UserLite
from ..models.workspaces import WorkspaceFeature
from ..models.workspaces import WorkspaceFeature, WorkspaceMember
from .base_resource import BaseResource


Expand All @@ -11,14 +12,17 @@ def __init__(self, config: Any) -> None:

def get_members(
self, workspace_slug: str
) -> [UserLite]:
) -> list[WorkspaceMember]:
"""Get all members of a workspace.

Returns a list of WorkspaceMember objects that include role (int) and
role_slug (str) fields in addition to basic identity fields.

Args:
workspace_slug: The workspace slug identifier
"""
response = self._get(f"{workspace_slug}/members")
return [UserLite.model_validate(item) for item in response or []]
return [WorkspaceMember.model_validate(item) for item in response or []]

def get_features(self, workspace_slug: str) -> WorkspaceFeature:
"""Get features of a workspace.
Expand Down
13 changes: 13 additions & 0 deletions plane/models/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from .enums import NetworkEnum, TimezoneEnum
from .pagination import PaginatedResponse
from .users import UserLite


class Project(BaseModel):
Expand Down Expand Up @@ -137,6 +138,18 @@ class PaginatedProjectResponse(PaginatedResponse):
results: list[Project]


class ProjectMember(UserLite):
"""Project member model.

Extends UserLite with project-scoped role fields. Returned by
Projects.get_members(). isinstance(member, UserLite) remains True,
so existing callers that type-check against UserLite are unaffected.
"""

role: int | None = None
role_slug: str | None = None


class ProjectFeature(BaseModel):
"""Project feature model."""

Expand Down
15 changes: 15 additions & 0 deletions plane/models/workspaces.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
from pydantic import BaseModel, ConfigDict

from .users import UserLite


class WorkspaceMember(UserLite):
"""Workspace member model.

Extends UserLite with workspace-scoped role fields. Returned by
Workspaces.get_members(). isinstance(member, UserLite) remains True,
so existing callers that type-check against UserLite are unaffected.
"""

role: int | None = None
role_slug: str | None = None


class WorkspaceFeature(BaseModel):
"""Workspace feature model."""

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "plane-sdk"
version = "0.2.13"
version = "0.2.14"
description = "Python SDK for Plane API"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
11 changes: 9 additions & 2 deletions tests/unit/test_projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import pytest

from plane.client import PlaneClient
from plane.models.projects import CreateProject, Project, UpdateProject
from plane.models.projects import CreateProject, Project, ProjectMember, UpdateProject
from plane.models.query_params import PaginatedQueryParams


Expand Down Expand Up @@ -92,9 +92,16 @@ def test_update_project(
assert updated.description == "Updated description"

def test_get_members(self, client: PlaneClient, workspace_slug: str, project: Project) -> None:
"""Test getting project members."""
"""Test getting project members returns ProjectMember objects with role fields."""
members = client.projects.get_members(workspace_slug, project.id)
assert isinstance(members, list)
for member in members:
assert isinstance(member, ProjectMember)
# role and role_slug should be present (may be None only on very old servers)
assert hasattr(member, "role")
assert hasattr(member, "role_slug")
assert hasattr(member, "id")
assert hasattr(member, "email")

def test_get_features(self, client: PlaneClient, workspace_slug: str, project: Project) -> None:
"""Test getting project features."""
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/test_work_item_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,25 @@ def test_update_work_item_type(
assert updated.id == work_item_type.id
assert updated.description == "Updated description"

def test_import_to_project_accepts_list(
self, client: PlaneClient, workspace_slug: str, project: Project
) -> None:
"""Test that import_to_project sends correct payload and returns None.

Uses a non-existent UUID list — the API may return 200 or 400, but the
method signature and request plumbing is what we're validating here.
The live integration path is covered by the compose e2e suite.
"""
import uuid
try:
result = client.work_item_types.import_to_project(
workspace_slug,
project.id,
[str(uuid.uuid4())],
)
# If the API accepts it (200/204), result must be None
assert result is None
except Exception:
# 400/404 is acceptable — we just confirm the call reaches the API
pass

9 changes: 6 additions & 3 deletions tests/unit/test_workspaces.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
"""Unit tests for Workspaces API resource (smoke tests with real HTTP requests)."""

from plane.client import PlaneClient
from plane.models.workspaces import WorkspaceMember


class TestWorkspacesAPI:
"""Test Workspaces API resource."""

def test_get_members(self, client: PlaneClient, workspace_slug: str) -> None:
"""Test getting workspace members."""
"""Test getting workspace members returns WorkspaceMember objects with role fields."""
members = client.workspaces.get_members(workspace_slug)
assert isinstance(members, list)
if members:
member = members[0]
for member in members:
assert isinstance(member, WorkspaceMember)
assert hasattr(member, "id")
assert hasattr(member, "display_name")
assert hasattr(member, "role")
assert hasattr(member, "role_slug")

def test_get_features(self, client: PlaneClient, workspace_slug: str) -> None:
"""Test getting workspace features."""
Expand Down