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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,30 @@ users = client.users.list()
members = client.workspaces.get_members(workspace_slug)
```

#### Roles

```python
# List all role definitions (workspace + project), paginated envelope
page = client.roles.list(workspace_slug)
for role in page.results:
print(role.namespace, role.slug, role.name)

# Only workspace-level roles (Owner / Admin / Member / Guest)
workspace_roles = client.roles.list(workspace_slug, namespace="workspace")

# Only project-role definitions (Admin / Contributor / Commenter / Guest).
# These are shared across every project in the workspace — there is no
# per-project roles endpoint.
project_roles = client.roles.list(workspace_slug, namespace="project")

# Retrieve a single role by id
role = client.roles.retrieve(workspace_slug, role_id)
```

> `slug` is the stable identifier to use in code, but it is **not** globally
> unique (`admin`/`guest` exist in both namespaces) — key roles by
> `(namespace, slug)` when indexing them.

### Project Management

#### Projects
Expand Down
59 changes: 59 additions & 0 deletions plane/api/roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from __future__ import annotations

from typing import Any, Literal

from ..models.roles import PaginatedRoleResponse, Role
from .base_resource import BaseResource


class Roles(BaseResource):
"""API client for workspace and project role definitions.

Roles are defined at the workspace level. ``namespace="workspace"`` returns
workspace-level roles (Owner / Admin / Member / Guest); ``namespace="project"``
returns the project-role definitions available across the workspace (Admin /
Contributor / Commenter / Guest). The project-role definitions are shared by
every project in the workspace — there is no per-project roles endpoint, so
this resource takes no project id.
"""

def __init__(self, config: Any) -> None:
super().__init__(config, "/workspaces/")

def list(
self,
workspace_slug: str,
namespace: Literal["workspace", "project"] | None = None,
per_page: int | None = None,
cursor: str | None = None,
) -> PaginatedRoleResponse:
"""List role definitions in a workspace.

Args:
workspace_slug: The workspace slug identifier
namespace: Optional filter — ``"workspace"`` for workspace-level
roles, ``"project"`` for project-role definitions. When omitted,
both are returned, ordered by namespace, sort order, then name.
per_page: Number of results per page (server default 20)
cursor: Pagination cursor from a previous response's ``next_cursor``
"""
params: dict[str, Any] = {}
if namespace is not None:
params["namespace"] = namespace
if per_page is not None:
params["per_page"] = per_page
if cursor is not None:
params["cursor"] = cursor

response = self._get(f"{workspace_slug}/roles", params=params if params else None)
return PaginatedRoleResponse.model_validate(response)

def retrieve(self, workspace_slug: str, role_id: str) -> Role:
"""Retrieve a single role definition by id.

Args:
workspace_slug: The workspace slug identifier
role_id: UUID of the role
"""
response = self._get(f"{workspace_slug}/roles/{role_id}")
return Role.model_validate(response)
2 changes: 2 additions & 0 deletions plane/client/plane_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from ..api.project_templates import ProjectTemplates
from ..api.projects import Projects
from ..api.releases import Releases
from ..api.roles import Roles
from ..api.states import States
from ..api.stickies import Stickies
from ..api.teamspaces import Teamspaces
Expand Down Expand Up @@ -83,3 +84,4 @@ def __init__(
self.workspace_project_states = WorkspaceProjectStates(self.config)
self.work_item_relation_definitions = WorkItemRelationDefinitions(self.config)
self.releases = Releases(self.config)
self.roles = Roles(self.config)
28 changes: 28 additions & 0 deletions plane/models/roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
from pydantic import BaseModel, ConfigDict

from .pagination import PaginatedResponse


class Role(BaseModel):
"""Workspace or project role definition.

The API returns additional fields (``id``, ``permissions``, ``level``,
``member_count``, ...); only the three below are modeled and the rest are
ignored. ``slug`` is the stable identifier to use in code, but it is **not**
Comment on lines +9 to +11

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

In Pydantic v2.4, when model_config uses ConfigDict(extra="allow"), are unknown fields preserved and included in model_dump by default?

💡 Result:

Yes, in Pydantic v2.4 (and throughout the v2 series), when a model is configured with ConfigDict(extra="allow"), unknown fields are preserved and included in the output of model_dump by default [1][2][3]. When extra="allow" is set, any data provided to the model that does not correspond to a defined field is stored in a special dictionary attribute called pydantic_extra [1][2]. During serialization, Pydantic's model_dump method automatically includes the contents of pydantic_extra alongside the standard fields [1][2][3]. Example behavior: from pydantic import BaseModel, ConfigDict class Model(BaseModel): x: int model_config = ConfigDict(extra='allow') m = Model(x=1, y='a') assert m.model_dump == {'x': 1, 'y': 'a'} # Unknown field 'y' is included [1][3] assert m.pydantic_extra == {'y': 'a'} # Extra data is stored here [1][2] Note that while this is the standard behavior for model_dump, recent discussions in the Pydantic ecosystem have highlighted that this integration can sometimes be complex when using model_construct or specific serialization overrides [4][5]. However, under normal instantiation and serialization, these extra fields are included [1][2][3].

Citations:


Docstring should say extras are accepted, not ignored
plane/models/roles.py:9-11 — The API returns additional fields (id, permissions, level, member_count, ...); only the three below are explicitly typed, and the rest are accepted and preserved for forward compatibility. slug is the stable identifier to use in code, but it is not ...

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plane/models/roles.py` around lines 9 - 11, Update the docstring in roles.py
for the role model (the class that defines id/label/slug) so it says the extra
API fields are accepted and preserved for forward compatibility, not ignored.
Keep the note that slug is the stable identifier to use in code, and make the
wording consistent with the model’s behavior and any typed extras handling in
the Role class.

globally unique — e.g. ``admin``/``guest`` exist in both namespaces — so key
roles by ``(namespace, slug)`` when indexing them.
"""

model_config = ConfigDict(extra="allow", populate_by_name=True)

name: str | None = None
slug: str | None = None
namespace: str | None = None


class PaginatedRoleResponse(PaginatedResponse):
"""Paginated response for workspace/project role definitions."""

model_config = ConfigDict(extra="allow", populate_by_name=True)

results: list[Role]
46 changes: 46 additions & 0 deletions tests/unit/test_roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Unit tests for Roles API resource (smoke tests with real HTTP requests)."""

from plane.client import PlaneClient
from plane.models.roles import Role


class TestRolesAPI:
"""Test Roles API resource."""

def test_list_roles(self, client: PlaneClient, workspace_slug: str) -> None:
"""Listing roles returns a paginated envelope of Role objects."""
page = client.roles.list(workspace_slug)
assert isinstance(page.results, list)
assert isinstance(page.next_page_results, bool)
for role in page.results:
assert isinstance(role, Role)
assert role.slug is not None
assert role.namespace in ("workspace", "project")

def test_list_roles_namespace_workspace(self, client: PlaneClient, workspace_slug: str) -> None:
"""Filtering by namespace=workspace returns only workspace-level roles."""
page = client.roles.list(workspace_slug, namespace="workspace")
assert isinstance(page.results, list)
for role in page.results:
assert role.namespace == "workspace"

def test_list_roles_namespace_project(self, client: PlaneClient, workspace_slug: str) -> None:
"""Filtering by namespace=project returns only project-level roles."""
page = client.roles.list(workspace_slug, namespace="project")
assert isinstance(page.results, list)
for role in page.results:
assert role.namespace == "project"

def test_retrieve_role(self, client: PlaneClient, workspace_slug: str) -> None:
"""Retrieving a role by id returns a single Role matching the listed one."""
page = client.roles.list(workspace_slug, per_page=1)
if not page.results:
return
listed = page.results[0]
role_id = getattr(listed, "id", None)
if role_id is None:
return
retrieved = client.roles.retrieve(workspace_slug, role_id)
assert isinstance(retrieved, Role)
assert retrieved.slug == listed.slug
assert retrieved.namespace == listed.namespace