diff --git a/README.md b/README.md index ecadafb..f9e9eaa 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/plane/api/roles.py b/plane/api/roles.py new file mode 100644 index 0000000..b933a7c --- /dev/null +++ b/plane/api/roles.py @@ -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) diff --git a/plane/client/plane_client.py b/plane/client/plane_client.py index 77fc26a..121df15 100644 --- a/plane/client/plane_client.py +++ b/plane/client/plane_client.py @@ -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 @@ -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) diff --git a/plane/models/roles.py b/plane/models/roles.py new file mode 100644 index 0000000..5ffdcf4 --- /dev/null +++ b/plane/models/roles.py @@ -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** + 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] diff --git a/tests/unit/test_roles.py b/tests/unit/test_roles.py new file mode 100644 index 0000000..08c2c1e --- /dev/null +++ b/tests/unit/test_roles.py @@ -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