diff --git a/README.md b/README.md index c2887cd..0db2858 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,13 @@ while paginated_members.next_page_results: params=MemberListQueryParams(per_page=1000, cursor=paginated_members.next_cursor), ) all_members.extend(paginated_members.results) + +# Project-role distribution — member counts per role across all active +# (non-archived) projects in the workspace (built-in + custom roles) +distribution = client.workspaces.get_project_role_distribution(workspace_slug) +print(distribution.total_memberships, distribution.total_distinct_members) +for role in distribution.roles: + print(role.slug, role.membership_count, role.distinct_member_count) ``` #### Roles @@ -372,6 +379,24 @@ members = client.projects.get_members_lite( workspace_slug, project_id, params=MemberListQueryParams(per_page=1000), ) + +# Paginated "lite" project list (id, identifier, name, icon/emoji, description, +# cover image, archived_at) — for pickers/reference lookups. +from plane.models.query_params import ProjectLiteListQueryParams + +lite = client.projects.list_lite( + workspace_slug, + params=ProjectLiteListQueryParams(per_page=1000, order_by="-created_at"), +) +for p in lite.results: + print(p.identifier, p.name) + +# NOTE: archived projects are now EXCLUDED by default. Pass include_archived=True +# to restore the previous behavior of listing archived projects too. +lite = client.projects.list_lite( + workspace_slug, + params=ProjectLiteListQueryParams(include_archived=True), +) ``` #### Work Items @@ -482,6 +507,26 @@ cycle = client.cycles.create( # List cycles cycles = client.cycles.list(workspace_slug, project_id) +# Filter cycles by status: current | upcoming | completed | draft | incomplete. +# `status` is canonical; `cycle_view` is a deprecated alias (status wins if both set). +from plane.models.query_params import CycleListQueryParams + +upcoming = client.cycles.list( + workspace_slug, project_id, + params=CycleListQueryParams(status="upcoming"), +) +for c in upcoming.results: # paginated envelope + print(c.name) + +# NOTE: status="current" is a special case — the API returns a BARE LIST of cycles +# (not the paginated envelope). list() returns whichever shape the server sends. +current = client.cycles.list( + workspace_slug, project_id, + params=CycleListQueryParams(status="current"), +) +for c in current: # plain list[Cycle] + print(c.name) + # Retrieve a cycle cycle = client.cycles.retrieve(workspace_slug, project_id, cycle_id) @@ -499,6 +544,19 @@ client.cycles.delete(workspace_slug, project_id, cycle_id) # List archived cycles archived = client.cycles.list_archived(workspace_slug, project_id) +# Paginated "lite" cycle list (full cycle fields minus issue-count metrics). +# Supports a status filter: current | upcoming | completed | draft | incomplete +# (omit for all). The lite endpoint takes only `status` (no `cycle_view` alias) +# and ALWAYS paginates — even for status="current". +from plane.models.query_params import CycleLiteListQueryParams + +lite = client.cycles.list_lite( + workspace_slug, project_id, + params=CycleLiteListQueryParams(status="current", per_page=1000), +) +for c in lite.results: + print(c.name) + # Add work items to cycle from plane.models.cycles import AddWorkItemsToCycleRequest @@ -557,6 +615,16 @@ client.modules.delete(workspace_slug, project_id, module_id) # List archived modules archived = client.modules.list_archived(workspace_slug, project_id) +# Paginated "lite" module list (full module fields minus issue-count metrics) +from plane.models.query_params import LiteListQueryParams + +lite = client.modules.list_lite( + workspace_slug, project_id, + params=LiteListQueryParams(per_page=1000, order_by="-created_at"), +) +for m in lite.results: + print(m.name) + # Add work items to module from plane.models.modules import AddWorkItemsToModuleRequest diff --git a/plane/api/cycles.py b/plane/api/cycles.py index 4ffb998..5925f55 100644 --- a/plane/api/cycles.py +++ b/plane/api/cycles.py @@ -5,12 +5,17 @@ CreateCycle, Cycle, PaginatedArchivedCycleResponse, + PaginatedCycleLiteResponse, PaginatedCycleResponse, PaginatedCycleWorkItemResponse, TransferCycleWorkItemsRequest, UpdateCycle, ) -from ..models.query_params import WorkItemQueryParams +from ..models.query_params import ( + CycleListQueryParams, + CycleLiteListQueryParams, + WorkItemQueryParams, +) from .base_resource import BaseResource from .work_items.base import prepare_work_item_params @@ -72,18 +77,73 @@ def delete(self, workspace_slug: str, project_id: str, cycle_id: str) -> None: return self._delete(f"{workspace_slug}/projects/{project_id}/cycles/{cycle_id}") def list( - self, workspace_slug: str, project_id: str, params: Mapping[str, Any] | None = None - ) -> PaginatedCycleResponse: + self, + workspace_slug: str, + project_id: str, + params: CycleListQueryParams | Mapping[str, Any] | None = None, + ) -> PaginatedCycleResponse | list[Cycle]: """List cycles with optional filtering parameters. + Supports cycle status filtering via :class:`CycleListQueryParams`. Pass + ``status`` (canonical) or the deprecated ``cycle_view`` alias with one of + ``current``, ``upcoming``, ``completed``, ``draft``, ``incomplete``; if + both are supplied the server uses ``status``. + + .. note:: + With ``status=current`` (or ``cycle_view=current``) the server + returns a **bare list** of :class:`Cycle` objects instead of the + paginated :class:`PaginatedCycleResponse` envelope returned for all + other values. This method returns whichever shape the server sends. + The :meth:`list_lite` endpoint always paginates, even for + ``status=current``. + Args: workspace_slug: The workspace slug identifier project_id: UUID of the project - params: Optional query parameters + params: Optional query parameters. Prefer ``CycleListQueryParams``; + a plain mapping is also accepted for backwards compatibility. """ - response = self._get(f"{workspace_slug}/projects/{project_id}/cycles", params=params) + if isinstance(params, CycleListQueryParams): + query_params: Mapping[str, Any] | None = params.to_query_params() + else: + query_params = params + response = self._get(f"{workspace_slug}/projects/{project_id}/cycles", params=query_params) + if isinstance(response, list): + return [Cycle.model_validate(item) for item in response] return PaginatedCycleResponse.model_validate(response) + def list_lite( + self, + workspace_slug: str, + project_id: str, + params: CycleLiteListQueryParams | None = None, + ) -> PaginatedCycleLiteResponse: + """List cycles as a paginated "lite" response. + + Calls the read-only ``/cycles-lite/`` endpoint, which returns the full + cycle field set minus the issue-count metric annotations (total_issues, + completed_issues, etc.), suitable for pickers and reference lookups. + Supports ordering, cursor pagination, and a ``status`` filter -- there + are no field filters. ``per_page`` defaults to and caps at 1000. Unlike + the full cycles list, the lite endpoint accepts only ``status`` (no + ``cycle_view`` alias). + + Unlike the full ``cycles`` list endpoint (where ``status=current`` + returns a bare array), this endpoint always returns the paginated + envelope for every ``status`` value. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + params: Optional ordering + cursor pagination query parameters, + plus the ``status`` filter + """ + response = self._get( + f"{workspace_slug}/projects/{project_id}/cycles-lite", + params=params.to_query_params() if params else None, + ) + return PaginatedCycleLiteResponse.model_validate(response) + def list_archived( self, workspace_slug: str, project_id: str, params: Mapping[str, Any] | None = None ) -> PaginatedArchivedCycleResponse: diff --git a/plane/api/modules.py b/plane/api/modules.py index 19751a8..1f6de88 100644 --- a/plane/api/modules.py +++ b/plane/api/modules.py @@ -5,11 +5,12 @@ CreateModule, Module, PaginatedArchivedModuleResponse, + PaginatedModuleLiteResponse, PaginatedModuleResponse, PaginatedModuleWorkItemResponse, UpdateModule, ) -from ..models.query_params import WorkItemQueryParams +from ..models.query_params import LiteListQueryParams, WorkItemQueryParams from .base_resource import BaseResource from .work_items.base import prepare_work_item_params @@ -83,6 +84,31 @@ def list( response = self._get(f"{workspace_slug}/projects/{project_id}/modules", params=params) return PaginatedModuleResponse.model_validate(response) + def list_lite( + self, + workspace_slug: str, + project_id: str, + params: LiteListQueryParams | None = None, + ) -> PaginatedModuleLiteResponse: + """List modules as a paginated "lite" response. + + Calls the read-only ``/modules-lite/`` endpoint, which returns the full + module field set minus the issue-count metric annotations (total_issues, + completed_issues, etc.), suitable for pickers and reference lookups. + Only ordering and cursor pagination are supported -- there are no field + filters. ``per_page`` defaults to and caps at 1000. + + Args: + workspace_slug: The workspace slug identifier + project_id: UUID of the project + params: Optional ordering + cursor pagination query parameters + """ + response = self._get( + f"{workspace_slug}/projects/{project_id}/modules-lite", + params=params.to_query_params() if params else None, + ) + return PaginatedModuleLiteResponse.model_validate(response) + def list_archived( self, workspace_slug: str, project_id: str, params: Mapping[str, Any] | None = None ) -> PaginatedArchivedModuleResponse: diff --git a/plane/api/projects.py b/plane/api/projects.py index 252fb64..19c5755 100644 --- a/plane/api/projects.py +++ b/plane/api/projects.py @@ -5,6 +5,7 @@ from ..models.projects import ( CreateProject, + PaginatedProjectLiteResponse, PaginatedProjectMemberResponse, PaginatedProjectResponse, Project, @@ -17,6 +18,7 @@ MemberListQueryParams, MemberQueryParams, PaginatedQueryParams, + ProjectLiteListQueryParams, ) from .base_resource import BaseResource @@ -80,6 +82,34 @@ def list( response = self._get(f"{workspace_slug}/projects", params=query_params) return PaginatedProjectResponse.model_validate(response) + def list_lite( + self, workspace_slug: str, params: ProjectLiteListQueryParams | None = None + ) -> PaginatedProjectLiteResponse: + """List projects as a paginated "lite" response. + + Calls the read-only ``/projects-lite/`` endpoint, which returns a + field-trimmed shape (id, identifier, name, cover_image, icon_prop, + emoji, description, cover_image_url, archived_at) suitable for pickers + and reference lookups. Supports ordering, cursor pagination, and an + ``include_archived`` toggle -- there are no field filters. ``per_page`` + defaults to and caps at 1000. + + .. note:: + Archived projects are now **excluded** by default. Pass + ``ProjectLiteListQueryParams(include_archived=True)`` to restore the + previous behavior of listing archived projects too. + + Args: + workspace_slug: The workspace slug identifier + params: Optional ordering + cursor pagination query parameters, + plus the ``include_archived`` toggle + """ + response = self._get( + f"{workspace_slug}/projects-lite", + params=params.to_query_params() if params else None, + ) + return PaginatedProjectLiteResponse.model_validate(response) + def get_worklog_summary(self, workspace_slug: str, project_id: str) -> [ProjectWorklogSummary]: """Get work log summary for a project. diff --git a/plane/api/workspaces.py b/plane/api/workspaces.py index 3a4855e..3b5f9fc 100644 --- a/plane/api/workspaces.py +++ b/plane/api/workspaces.py @@ -5,6 +5,7 @@ from ..models.query_params import MemberListQueryParams, MemberQueryParams from ..models.workspaces import ( PaginatedWorkspaceMemberResponse, + ProjectRoleDistribution, WorkspaceFeature, WorkspaceMember, ) @@ -54,6 +55,19 @@ def get_members_lite( ) return PaginatedWorkspaceMemberResponse.model_validate(response) + def get_project_role_distribution(self, workspace_slug: str) -> ProjectRoleDistribution: + """Get the distribution of project members by role across the workspace. + + Aggregates member counts per role over all active (non-archived) + projects in the workspace. Both built-in roles (admin, contributor, + commenter, guest) and custom roles are included. + + Args: + workspace_slug: The workspace slug identifier + """ + response = self._get(f"{workspace_slug}/project-role-distribution") + return ProjectRoleDistribution.model_validate(response) + def get_features(self, workspace_slug: str) -> WorkspaceFeature: """Get features of a workspace. diff --git a/plane/models/__init__.py b/plane/models/__init__.py index 5121534..926bdce 100644 --- a/plane/models/__init__.py +++ b/plane/models/__init__.py @@ -15,9 +15,13 @@ ) from .query_params import ( BaseQueryParams, + CycleLiteListQueryParams, + CycleListQueryParams, + LiteListQueryParams, MemberListQueryParams, MemberQueryParams, PaginatedQueryParams, + ProjectLiteListQueryParams, RetrieveQueryParams, WorkItemQueryParams, ) @@ -39,9 +43,13 @@ "IntakeWorkItemStatusEnum", # query params "BaseQueryParams", + "CycleLiteListQueryParams", + "CycleListQueryParams", + "LiteListQueryParams", "MemberListQueryParams", "MemberQueryParams", "PaginatedQueryParams", + "ProjectLiteListQueryParams", "RetrieveQueryParams", "WorkItemQueryParams", ] diff --git a/plane/models/cycles.py b/plane/models/cycles.py index 70ad4ac..56d71b5 100644 --- a/plane/models/cycles.py +++ b/plane/models/cycles.py @@ -141,6 +141,14 @@ class PaginatedCycleResponse(PaginatedResponse): results: list[Cycle] +class PaginatedCycleLiteResponse(PaginatedResponse): + """Paginated response for the cycles-lite endpoint.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[CycleLite] + + class PaginatedArchivedCycleResponse(PaginatedResponse): """Paginated response for archived cycles.""" diff --git a/plane/models/enums.py b/plane/models/enums.py index 6b61a51..1fb8997 100644 --- a/plane/models/enums.py +++ b/plane/models/enums.py @@ -48,6 +48,10 @@ "FORMULA", ] RelationTypeEnum = Literal["ISSUE", "USER", "RELEASE"] +CycleStatusEnum = Literal["current", "upcoming", "completed", "draft", "incomplete"] +# Deprecated alias for CycleStatusEnum. ``status`` is the canonical cycle filter +# going forward; ``cycle_view`` is kept only for backward compatibility. +CycleViewEnum = CycleStatusEnum # Proper Enum classes for better type safety and IDE support @@ -93,6 +97,7 @@ class InitiativeState(Enum): COMPLETED = "COMPLETED" CLOSED = "CLOSED" + class WorkItemRelationType(Enum): """Work item relation type enumeration.""" @@ -580,6 +585,8 @@ class Group(Enum): "PriorityEnum", "PropertyTypeEnum", "RelationTypeEnum", + "CycleStatusEnum", + "CycleViewEnum", "TimezoneEnum", "TypeMimeEnum", "NetworkEnum", diff --git a/plane/models/modules.py b/plane/models/modules.py index 6b4d149..d3f3481 100644 --- a/plane/models/modules.py +++ b/plane/models/modules.py @@ -132,6 +132,14 @@ class PaginatedModuleResponse(PaginatedResponse): results: list[Module] +class PaginatedModuleLiteResponse(PaginatedResponse): + """Paginated response for the modules-lite endpoint.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[ModuleLite] + + class PaginatedArchivedModuleResponse(PaginatedResponse): """Paginated response for archived modules.""" diff --git a/plane/models/projects.py b/plane/models/projects.py index 48ab1c1..870ca10 100644 --- a/plane/models/projects.py +++ b/plane/models/projects.py @@ -58,6 +58,26 @@ class Project(BaseModel): default_state: str | None = None +class ProjectLite(BaseModel): + """Lite project information. + + Trimmed shape returned by the read-only ``projects-lite`` list endpoint + (``ProjectLiteSerializer``), intended for pickers and reference lookups. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + id: str | None = None + identifier: str + name: str + cover_image: str | None = None + icon_prop: Any | None = None + emoji: str | None = None + description: str | None = None + cover_image_url: str | None = None + archived_at: str | None = None + + class CreateProject(BaseModel): """Request model for creating a project.""" @@ -138,6 +158,14 @@ class PaginatedProjectResponse(PaginatedResponse): results: list[Project] +class PaginatedProjectLiteResponse(PaginatedResponse): + """Paginated response for the projects-lite endpoint.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + results: list[ProjectLite] + + class ProjectMember(UserLite): """Project member model. diff --git a/plane/models/query_params.py b/plane/models/query_params.py index 6ce0c24..188835d 100644 --- a/plane/models/query_params.py +++ b/plane/models/query_params.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, ConfigDict, Field +from .enums import CycleStatusEnum + class BaseQueryParams(BaseModel): """Base query parameters for API requests.""" @@ -154,6 +156,111 @@ class MemberListQueryParams(MemberQueryParams): ) +class LiteListQueryParams(BaseModel): + """Query parameters for the read-only "lite" list endpoints. + + The lite list routes (``projects-lite``, ``cycles-lite``, ``modules-lite``) + support only ordering and cursor pagination -- they expose no field filters. + ``per_page`` defaults to and caps at 1000 on the server. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + cursor: str | None = Field( + None, + description='Pagination cursor of the form "{per_page}:{page}:{offset}", ' + "e.g. 1000:0:0. Use the response's next_cursor to fetch the next page.", + ) + per_page: int | None = Field( + None, + description="Number of results per page (default and max 1000)", + ge=1, + le=1000, + ) + order_by: str | None = Field( + None, + description="Field to order results by. Prefix with '-' for descending order", + ) + + def to_query_params(self) -> dict[str, Any]: + """Serialize to a query-param dict the lite endpoints accept. + + Booleans are rendered as lowercase ``"true"``/``"false"`` strings so the + backend parses them (a Python ``True`` would be encoded as ``"True"`` and + rejected). Unset fields are dropped so they never reach the query string. + """ + raw = self.model_dump(exclude_none=True) + return {k: (str(v).lower() if isinstance(v, bool) else v) for k, v in raw.items()} + + +class ProjectLiteListQueryParams(LiteListQueryParams): + """Query parameters for the projects-lite list endpoint. + + Adds the ``include_archived`` toggle to the shared lite ordering + cursor + pagination params. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + include_archived: bool | None = Field( + None, + description=( + "Include archived projects in the results. Defaults to False on the " + "server, which excludes archived projects. Set True to restore the " + "previous behavior of listing archived projects too." + ), + ) + + +_CYCLE_STATUS_DESCRIPTION = ( + "Filter cycles by status: 'current' (started, not yet ended), 'upcoming' " + "(starts in the future), 'completed' (ended), 'draft' (no start/end dates), " + "or 'incomplete' (not yet finished or open-ended). Omit to return all cycles." +) + + +class CycleLiteListQueryParams(LiteListQueryParams): + """Query parameters for the cycles-lite list endpoint. + + Adds the ``status`` filter to the shared lite ordering + cursor pagination + params. Unlike the full cycles list, the lite endpoint accepts only + ``status`` (no ``cycle_view`` alias) and always returns a paginated envelope. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + status: CycleStatusEnum | None = Field(None, description=_CYCLE_STATUS_DESCRIPTION) + + +class CycleListQueryParams(PaginatedQueryParams): + """Query parameters for the full cycles list endpoint. + + Adds cycle status filtering on top of the standard pagination params. + ``status`` is the canonical filter; ``cycle_view`` is a deprecated alias kept + for backward compatibility. If both are sent, the server uses ``status`` and + ignores ``cycle_view``. + + Note: with ``status=current`` (or ``cycle_view=current``) the server returns + a bare array of cycles rather than the paginated envelope returned for all + other values. :meth:`~plane.api.cycles.Cycles.list` handles both shapes. + """ + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + status: CycleStatusEnum | None = Field(None, description=_CYCLE_STATUS_DESCRIPTION) + cycle_view: CycleStatusEnum | None = Field( + None, + description=( + "Deprecated alias for ``status``, kept for backward compatibility. " + "Prefer ``status``; if both are supplied the server uses ``status``." + ), + ) + + def to_query_params(self) -> dict[str, Any]: + """Serialize to a query-param dict, dropping unset fields.""" + return self.model_dump(exclude_none=True) + + WorkItemCountGroupBy = Literal[ "state_id", "state__group", @@ -220,8 +327,12 @@ class WorkItemCountQueryParams(BaseModel): __all__ = [ "BaseQueryParams", + "CycleLiteListQueryParams", + "CycleListQueryParams", + "LiteListQueryParams", "MemberListQueryParams", "MemberQueryParams", + "ProjectLiteListQueryParams", "PaginatedQueryParams", "RetrieveQueryParams", "WorkItemCountGroupBy", diff --git a/plane/models/workspaces.py b/plane/models/workspaces.py index 94b554b..89247e6 100644 --- a/plane/models/workspaces.py +++ b/plane/models/workspaces.py @@ -37,3 +37,31 @@ class WorkspaceFeature(BaseModel): customers: bool wiki: bool pi: bool + + +class ProjectRoleDistributionEntry(BaseModel): + """Per-role membership counts within a workspace's project-role distribution.""" + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + role_id: str | None = None + name: str | None = None + slug: str | None = None + is_system: bool | None = None + level: int | None = None + membership_count: int | None = None + distinct_member_count: int | None = None + + +class ProjectRoleDistribution(BaseModel): + """Aggregate count of project members by role across a workspace. + + Counts span all active (non-archived) projects in the workspace and include + both built-in roles (admin, contributor, commenter, guest) and custom roles. + """ + + model_config = ConfigDict(extra="allow", populate_by_name=True) + + total_memberships: int | None = None + total_distinct_members: int | None = None + roles: list[ProjectRoleDistributionEntry] = [] diff --git a/pyproject.toml b/pyproject.toml index 0178cbc..ee42cd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plane-sdk" -version = "0.2.18" +version = "0.2.19" description = "Python SDK for Plane API" readme = "README.md" requires-python = ">=3.10" diff --git a/tests/unit/test_cycles.py b/tests/unit/test_cycles.py index 7f4b6bf..82a9cbd 100644 --- a/tests/unit/test_cycles.py +++ b/tests/unit/test_cycles.py @@ -5,8 +5,9 @@ import pytest from plane.client import PlaneClient -from plane.models.cycles import CreateCycle, UpdateCycle +from plane.models.cycles import CreateCycle, Cycle, CycleLite, UpdateCycle from plane.models.projects import Project, ProjectFeature +from plane.models.query_params import CycleLiteListQueryParams, CycleListQueryParams class TestCyclesAPI: @@ -22,6 +23,49 @@ def test_list_cycles( assert hasattr(response, "count") assert isinstance(response.results, list) + def test_list_lite( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """list_lite returns a paginated envelope of CycleLite items.""" + params = CycleLiteListQueryParams(per_page=5, order_by="-created_at") + response = client.cycles.list_lite(workspace_slug, project.id, params=params) + assert isinstance(response.results, list) + assert isinstance(response.total_count, int) + assert isinstance(response.next_page_results, bool) + assert len(response.results) <= 5 + for cycle in response.results: + assert isinstance(cycle, CycleLite) + + def test_list_lite_status( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """list_lite honors the status filter and stays paginated, even for current.""" + params = CycleLiteListQueryParams(status="current", per_page=5) + response = client.cycles.list_lite(workspace_slug, project.id, params=params) + assert isinstance(response.results, list) + assert isinstance(response.total_count, int) + for cycle in response.results: + assert isinstance(cycle, CycleLite) + + def test_list_status_paginated( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """Full list with a non-current status returns the paginated envelope.""" + params = CycleListQueryParams(status="upcoming") + response = client.cycles.list(workspace_slug, project.id, params=params) + assert hasattr(response, "results") + assert isinstance(response.results, list) + + def test_list_status_current_returns_bare_list( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """Full list with status=current returns a bare list of cycles, not an envelope.""" + params = CycleListQueryParams(status="current") + response = client.cycles.list(workspace_slug, project.id, params=params) + assert isinstance(response, list) + for cycle in response: + assert isinstance(cycle, Cycle) + def test_list_archived_cycles( self, client: PlaneClient, workspace_slug: str, project: Project ) -> None: diff --git a/tests/unit/test_modules.py b/tests/unit/test_modules.py index cfeb0df..f174ce7 100644 --- a/tests/unit/test_modules.py +++ b/tests/unit/test_modules.py @@ -6,8 +6,9 @@ from plane.client import PlaneClient from plane.models.enums import ModuleStatus -from plane.models.modules import CreateModule, UpdateModule +from plane.models.modules import CreateModule, ModuleLite, UpdateModule from plane.models.projects import Project, ProjectFeature +from plane.models.query_params import LiteListQueryParams class TestModulesAPI: @@ -23,6 +24,19 @@ def test_list_modules( assert hasattr(response, "count") assert isinstance(response.results, list) + def test_list_lite( + self, client: PlaneClient, workspace_slug: str, project: Project + ) -> None: + """list_lite returns a paginated envelope of ModuleLite items.""" + params = LiteListQueryParams(per_page=5, order_by="-created_at") + response = client.modules.list_lite(workspace_slug, project.id, params=params) + assert isinstance(response.results, list) + assert isinstance(response.total_count, int) + assert isinstance(response.next_page_results, bool) + assert len(response.results) <= 5 + for module in response.results: + assert isinstance(module, ModuleLite) + def test_list_archived_modules( self, client: PlaneClient, workspace_slug: str, project: Project ) -> None: diff --git a/tests/unit/test_projects.py b/tests/unit/test_projects.py index e4d05b7..04dfbd9 100644 --- a/tests/unit/test_projects.py +++ b/tests/unit/test_projects.py @@ -5,11 +5,18 @@ import pytest from plane.client import PlaneClient -from plane.models.projects import CreateProject, Project, ProjectMember, UpdateProject +from plane.models.projects import ( + CreateProject, + Project, + ProjectLite, + ProjectMember, + UpdateProject, +) from plane.models.query_params import ( MemberListQueryParams, MemberQueryParams, PaginatedQueryParams, + ProjectLiteListQueryParams, ) @@ -32,6 +39,34 @@ def test_list_projects_with_params(self, client: PlaneClient, workspace_slug: st assert hasattr(response, "results") assert len(response.results) <= 5 + def test_list_lite(self, client: PlaneClient, workspace_slug: str) -> None: + """list_lite returns a paginated envelope of ProjectLite items.""" + response = client.projects.list_lite(workspace_slug) + assert isinstance(response.results, list) + assert isinstance(response.total_count, int) + assert isinstance(response.next_page_results, bool) + for project in response.results: + assert isinstance(project, ProjectLite) + assert project.name is not None + assert project.identifier is not None + + def test_list_lite_with_params(self, client: PlaneClient, workspace_slug: str) -> None: + """list_lite honors ordering + cursor pagination params.""" + params = ProjectLiteListQueryParams(per_page=5, order_by="-created_at") + response = client.projects.list_lite(workspace_slug, params=params) + assert isinstance(response.results, list) + assert len(response.results) <= 5 + + def test_list_lite_include_archived(self, client: PlaneClient, workspace_slug: str) -> None: + """list_lite with include_archived=True returns a valid envelope. + + Archived projects are excluded by default; this opt-in restores them. + """ + params = ProjectLiteListQueryParams(include_archived=True, per_page=5) + response = client.projects.list_lite(workspace_slug, params=params) + assert isinstance(response.results, list) + assert isinstance(response.total_count, int) + class TestProjectsAPICRUD: """Test Projects API CRUD operations.""" diff --git a/tests/unit/test_workspaces.py b/tests/unit/test_workspaces.py index dd4d41d..e787209 100644 --- a/tests/unit/test_workspaces.py +++ b/tests/unit/test_workspaces.py @@ -2,7 +2,7 @@ from plane.client import PlaneClient from plane.models.query_params import MemberListQueryParams, MemberQueryParams -from plane.models.workspaces import WorkspaceMember +from plane.models.workspaces import ProjectRoleDistributionEntry, WorkspaceMember class TestWorkspacesAPI: @@ -57,6 +57,18 @@ def test_get_members_lite_paginated(self, client: PlaneClient, workspace_slug: s for member in paginated_members.results: assert isinstance(member, WorkspaceMember) + def test_get_project_role_distribution(self, client: PlaneClient, workspace_slug: str) -> None: + """get_project_role_distribution returns aggregate role counts.""" + distribution = client.workspaces.get_project_role_distribution(workspace_slug) + assert isinstance(distribution.total_memberships, int) + assert isinstance(distribution.total_distinct_members, int) + assert isinstance(distribution.roles, list) + for entry in distribution.roles: + assert isinstance(entry, ProjectRoleDistributionEntry) + assert hasattr(entry, "slug") + assert hasattr(entry, "membership_count") + assert hasattr(entry, "distinct_member_count") + def test_get_features(self, client: PlaneClient, workspace_slug: str) -> None: """Test getting workspace features.""" features = client.workspaces.get_features(workspace_slug)