feat(api): add lite list endpoints for projects, members, cycles, and modules (#9410)

* feat: add lite list endpoints for projects, members, cycles, and modules

* refactor: enhance order_by sanitization for cycle and module endpoints, update error handling for non-existent projects and workspaces
This commit is contained in:
Akhil Vamshi Konam
2026-07-17 17:40:26 +05:30
committed by GitHub
parent af1be50b48
commit 7cef741c29
18 changed files with 763 additions and 7 deletions

View File

@@ -62,5 +62,9 @@ from .asset import (
FileAssetSerializer,
)
from .invite import WorkspaceInviteSerializer
from .member import ProjectMemberSerializer
from .member import (
ProjectMemberSerializer,
WorkspaceMemberLiteAPISerializer,
ProjectMemberLiteAPISerializer,
)
from .sticky import StickySerializer

View File

@@ -41,3 +41,45 @@ class ProjectMemberSerializer(BaseSerializer):
model = ProjectMember
fields = ["id", "member", "role"]
read_only_fields = ["id"]
class BaseMemberLiteAPISerializer(BaseSerializer):
"""Common flattened member representation for paginated member pickers/directories."""
id = serializers.UUIDField(source="member.id", read_only=True)
first_name = serializers.CharField(source="member.first_name", read_only=True)
last_name = serializers.CharField(source="member.last_name", read_only=True)
email = serializers.EmailField(source="member.email", read_only=True)
avatar = serializers.CharField(source="member.avatar", read_only=True, allow_null=True)
avatar_url = serializers.CharField(source="member.avatar_url", read_only=True, allow_null=True)
display_name = serializers.CharField(source="member.display_name", read_only=True)
is_bot = serializers.BooleanField(source="member.is_bot", read_only=True)
class Meta:
fields = [
"id",
"first_name",
"last_name",
"email",
"avatar",
"avatar_url",
"display_name",
"role",
"is_active",
"is_bot",
]
read_only_fields = fields
class WorkspaceMemberLiteAPISerializer(BaseMemberLiteAPISerializer):
"""Minimal WorkspaceMember representation for paginated member pickers/directories."""
class Meta(BaseMemberLiteAPISerializer.Meta):
model = WorkspaceMember
class ProjectMemberLiteAPISerializer(BaseMemberLiteAPISerializer):
"""Minimal ProjectMember representation for paginated member pickers/directories."""
class Meta(BaseMemberLiteAPISerializer.Meta):
model = ProjectMember

View File

@@ -318,5 +318,6 @@ class ProjectLiteSerializer(BaseSerializer):
"emoji",
"description",
"cover_image_url",
"archived_at",
]
read_only_fields = fields

View File

@@ -6,6 +6,7 @@ from django.urls import path
from plane.api.views.cycle import (
CycleListCreateAPIEndpoint,
CycleListLiteAPIEndpoint,
CycleDetailAPIEndpoint,
CycleIssueListCreateAPIEndpoint,
CycleIssueDetailAPIEndpoint,
@@ -19,6 +20,11 @@ urlpatterns = [
CycleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="cycles",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles-lite/",
CycleListLiteAPIEndpoint.as_view(http_method_names=["get"]),
name="cycles-lite",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/cycles/<uuid:pk>/",
CycleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),

View File

@@ -7,7 +7,9 @@ from django.urls import path
from plane.api.views import (
ProjectMemberListCreateAPIEndpoint,
ProjectMemberDetailAPIEndpoint,
ProjectMemberLiteAPIEndpoint,
WorkspaceMemberAPIEndpoint,
WorkspaceMemberLiteAPIEndpoint,
)
urlpatterns = [
@@ -27,6 +29,11 @@ urlpatterns = [
ProjectMemberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="project-members",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members-lite/",
ProjectMemberLiteAPIEndpoint.as_view(http_method_names=["get"]),
name="project-members-lite",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/project-members/<uuid:pk>/",
ProjectMemberDetailAPIEndpoint.as_view(http_method_names=["patch", "delete", "get"]),
@@ -37,4 +44,9 @@ urlpatterns = [
WorkspaceMemberAPIEndpoint.as_view(http_method_names=["get"]),
name="workspace-members",
),
path(
"workspaces/<str:slug>/members-lite/",
WorkspaceMemberLiteAPIEndpoint.as_view(http_method_names=["get"]),
name="workspace-members-lite",
),
]

View File

@@ -6,6 +6,7 @@ from django.urls import path
from plane.api.views import (
ModuleListCreateAPIEndpoint,
ModuleListLiteAPIEndpoint,
ModuleDetailAPIEndpoint,
ModuleIssueListCreateAPIEndpoint,
ModuleIssueDetailAPIEndpoint,
@@ -18,6 +19,11 @@ urlpatterns = [
ModuleListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="modules",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules-lite/",
ModuleListLiteAPIEndpoint.as_view(http_method_names=["get"]),
name="modules-lite",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/modules/<uuid:pk>/",
ModuleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),

View File

@@ -6,6 +6,7 @@ from django.urls import path
from plane.api.views import (
ProjectListCreateAPIEndpoint,
ProjectListLiteAPIEndpoint,
ProjectDetailAPIEndpoint,
ProjectArchiveUnarchiveAPIEndpoint,
ProjectSummaryAPIEndpoint,
@@ -17,6 +18,11 @@ urlpatterns = [
ProjectListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="project",
),
path(
"workspaces/<str:slug>/projects-lite/",
ProjectListLiteAPIEndpoint.as_view(http_method_names=["get"]),
name="project-lite",
),
path(
"workspaces/<str:slug>/projects/<uuid:pk>/",
ProjectDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]),

View File

@@ -4,6 +4,7 @@
from .project import (
ProjectListCreateAPIEndpoint,
ProjectListLiteAPIEndpoint,
ProjectDetailAPIEndpoint,
ProjectArchiveUnarchiveAPIEndpoint,
ProjectSummaryAPIEndpoint,
@@ -34,6 +35,7 @@ from .issue import (
from .cycle import (
CycleListCreateAPIEndpoint,
CycleListLiteAPIEndpoint,
CycleDetailAPIEndpoint,
CycleIssueListCreateAPIEndpoint,
CycleIssueDetailAPIEndpoint,
@@ -43,13 +45,20 @@ from .cycle import (
from .module import (
ModuleListCreateAPIEndpoint,
ModuleListLiteAPIEndpoint,
ModuleDetailAPIEndpoint,
ModuleIssueListCreateAPIEndpoint,
ModuleIssueDetailAPIEndpoint,
ModuleArchiveUnarchiveAPIEndpoint,
)
from .member import ProjectMemberListCreateAPIEndpoint, ProjectMemberDetailAPIEndpoint, WorkspaceMemberAPIEndpoint
from .member import (
ProjectMemberListCreateAPIEndpoint,
ProjectMemberDetailAPIEndpoint,
ProjectMemberLiteAPIEndpoint,
WorkspaceMemberAPIEndpoint,
WorkspaceMemberLiteAPIEndpoint,
)
from .intake import (
IntakeIssueListCreateAPIEndpoint,

View File

@@ -27,6 +27,7 @@ from drf_spectacular.utils import OpenApiRequest, OpenApiResponse
from plane.api.serializers import (
CycleIssueSerializer,
CycleSerializer,
CycleLiteSerializer,
CycleIssueRequestSerializer,
TransferCycleIssueRequestSerializer,
CycleCreateSerializer,
@@ -46,7 +47,7 @@ from plane.db.models import (
UserFavorite,
)
from plane.utils.cycle_transfer_issues import transfer_cycle_issues
from plane.utils.order_queryset import ISSUE_ORDER_BY_ALLOWLIST, sanitize_order_by
from plane.utils.order_queryset import CYCLE_ORDER_BY_ALLOWLIST, ISSUE_ORDER_BY_ALLOWLIST, sanitize_order_by
from plane.utils.host import base_host
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
@@ -356,6 +357,54 @@ class CycleListCreateAPIEndpoint(BaseAPIView):
)
class CycleListLiteAPIEndpoint(BaseAPIView):
"""Cycle Lite List Endpoint"""
serializer_class = CycleLiteSerializer
model = Cycle
permission_classes = [ProjectEntityPermission]
use_read_replica = True
@cycle_docs(
operation_id="list_cycles_lite",
summary="List cycles (lite)",
description="Retrieve a paginated, lightweight list of cycles in a project for pickers and references.",
parameters=[CURSOR_PARAMETER, PER_PAGE_PARAMETER, ORDER_BY_PARAMETER],
responses={
200: create_paginated_response(
CycleLiteSerializer,
"PaginatedCycleLiteResponse",
"Paginated list of cycles with minimal fields",
"Paginated Cycles (Lite)",
),
},
)
def get(self, request, slug, project_id):
"""List cycles (lite)
Retrieve a paginated, lightweight list of non-archived cycles in a project,
optimized for pickers and references.
"""
cycles = (
Cycle.objects.filter(workspace__slug=slug, project_id=project_id)
.filter(archived_at__isnull=True)
.select_related("project", "workspace", "owned_by")
.order_by(
sanitize_order_by(
request.GET.get("order_by", "-created_at"),
CYCLE_ORDER_BY_ALLOWLIST,
default="-created_at",
)
)
.distinct()
)
return self.paginate(
request=request,
queryset=cycles,
on_results=lambda cycles: CycleLiteSerializer(cycles, many=True).data,
)
class CycleDetailAPIEndpoint(BaseAPIView):
"""
This viewset automatically provides `retrieve`, `update` and `destroy` actions related to cycle.

View File

@@ -314,6 +314,20 @@ class IssueListCreateAPIEndpoint(BaseAPIView):
Supports filtering, ordering, and field selection through query parameters.
"""
unsupported_filters = [param for param in ("pql", "filters") if request.GET.get(param)]
if unsupported_filters:
return Response(
{
"pql": (
"PQL and structured filters are not supported on this Plane edition. "
"Remove the pql/filters parameter and filter results client-side, or use "
"a Plane edition that supports work item query filtering."
),
"unsupported_parameters": unsupported_filters,
},
status=status.HTTP_400_BAD_REQUEST,
)
external_id = request.GET.get("external_id")
external_source = request.GET.get("external_source")

View File

@@ -13,18 +13,26 @@ from drf_spectacular.utils import (
# Module imports
from .base import BaseAPIView
from plane.api.serializers import UserLiteSerializer, ProjectMemberSerializer
from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember
from plane.api.serializers import (
UserLiteSerializer,
ProjectMemberSerializer,
WorkspaceMemberLiteAPISerializer,
ProjectMemberLiteAPISerializer,
)
from plane.db.models import User, Workspace, WorkspaceMember, Project, ProjectMember
from plane.utils.permissions import ProjectMemberPermission, WorkSpaceAdminPermission, ProjectAdminPermission
from plane.utils.openapi import (
WORKSPACE_SLUG_PARAMETER,
PROJECT_ID_PARAMETER,
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
UNAUTHORIZED_RESPONSE,
FORBIDDEN_RESPONSE,
WORKSPACE_NOT_FOUND_RESPONSE,
PROJECT_NOT_FOUND_RESPONSE,
WORKSPACE_MEMBER_EXAMPLE,
PROJECT_MEMBER_EXAMPLE,
create_paginated_response,
)
@@ -220,3 +228,105 @@ class ProjectMemberDetailAPIEndpoint(ProjectMemberListCreateAPIEndpoint):
project_member.is_active = False
project_member.save()
return Response(status=status.HTTP_204_NO_CONTENT)
class WorkspaceMemberLiteAPIEndpoint(BaseAPIView):
"""Workspace members (lite) list endpoint."""
permission_classes = [WorkSpaceAdminPermission]
use_read_replica = True
@extend_schema(
operation_id="get_workspace_members_lite",
summary="List workspace members (lite)",
description="Retrieve a paginated, lightweight list of workspace members for pickers and directories.",
tags=["Members"],
parameters=[WORKSPACE_SLUG_PARAMETER, CURSOR_PARAMETER, PER_PAGE_PARAMETER],
responses={
200: create_paginated_response(
WorkspaceMemberLiteAPISerializer,
"PaginatedWorkspaceMemberLite",
"Paginated list of workspace members with minimal fields",
"Paginated Workspace Members (Lite)",
),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: WORKSPACE_NOT_FOUND_RESPONSE,
},
)
def get(self, request, slug):
"""List workspace members (lite)
Retrieve a paginated, lightweight list of workspace members, optimized for
pickers and directories.
"""
# Check if the workspace exists
if not Workspace.objects.filter(slug=slug).exists():
return Response(
{"error": "Provided workspace does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
workspace_members = (
WorkspaceMember.objects.filter(workspace__slug=slug).select_related("member").order_by("-created_at")
)
return self.paginate(
request=request,
queryset=workspace_members,
on_results=lambda members: WorkspaceMemberLiteAPISerializer(members, many=True).data,
)
class ProjectMemberLiteAPIEndpoint(BaseAPIView):
"""Project members (lite) list endpoint."""
permission_classes = [ProjectMemberPermission]
use_read_replica = True
@extend_schema(
operation_id="get_project_members_lite",
summary="List project members (lite)",
description="Retrieve a paginated, lightweight list of project members for pickers and directories.",
tags=["Members"],
parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER, CURSOR_PARAMETER, PER_PAGE_PARAMETER],
responses={
200: create_paginated_response(
ProjectMemberLiteAPISerializer,
"PaginatedProjectMemberLite",
"Paginated list of project members with minimal fields",
"Paginated Project Members (Lite)",
),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: PROJECT_NOT_FOUND_RESPONSE,
},
)
def get(self, request, slug, project_id):
"""List project members (lite)
Retrieve a paginated, lightweight list of project members, optimized for
pickers and directories.
"""
# Check if the workspace exists
if not Workspace.objects.filter(slug=slug).exists():
return Response(
{"error": "Provided workspace does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
if not Project.objects.filter(id=project_id, workspace__slug=slug).exists():
return Response(
{"error": "Provided project does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
project_members = (
ProjectMember.objects.filter(project_id=project_id, workspace__slug=slug)
.select_related("member")
.order_by("-created_at")
)
return self.paginate(
request=request,
queryset=project_members,
on_results=lambda members: ProjectMemberLiteAPISerializer(members, many=True).data,
)

View File

@@ -21,6 +21,7 @@ from plane.api.serializers import (
IssueSerializer,
ModuleIssueSerializer,
ModuleSerializer,
ModuleLiteSerializer,
ModuleIssueRequestSerializer,
ModuleCreateSerializer,
ModuleUpdateSerializer,
@@ -42,7 +43,7 @@ from plane.db.models import (
from .base import BaseAPIView
from plane.bgtasks.webhook_task import model_activity
from plane.utils.host import base_host
from plane.utils.order_queryset import ISSUE_ORDER_BY_ALLOWLIST, sanitize_order_by
from plane.utils.order_queryset import ISSUE_ORDER_BY_ALLOWLIST, MODULE_ORDER_BY_ALLOWLIST, sanitize_order_by
from plane.utils.openapi import (
module_docs,
module_issue_docs,
@@ -277,6 +278,54 @@ class ModuleListCreateAPIEndpoint(BaseAPIView):
)
class ModuleListLiteAPIEndpoint(BaseAPIView):
"""Module Lite List Endpoint"""
serializer_class = ModuleLiteSerializer
model = Module
permission_classes = [ProjectEntityPermission]
use_read_replica = True
@module_docs(
operation_id="list_modules_lite",
summary="List modules (lite)",
description="Retrieve a paginated, lightweight list of modules in a project for pickers and references.",
parameters=[CURSOR_PARAMETER, PER_PAGE_PARAMETER, ORDER_BY_PARAMETER],
responses={
200: create_paginated_response(
ModuleLiteSerializer,
"PaginatedModuleLiteResponse",
"Paginated list of modules with minimal fields",
"Paginated Modules (Lite)",
),
},
)
def get(self, request, slug, project_id):
"""List modules (lite)
Retrieve a paginated, lightweight list of non-archived modules in a project,
optimized for pickers and references.
"""
modules = (
Module.objects.filter(project_id=project_id, workspace__slug=slug)
.filter(archived_at__isnull=True)
.select_related("project", "workspace", "lead")
.prefetch_related("members")
.order_by(
sanitize_order_by(
request.GET.get("order_by", "-created_at"),
MODULE_ORDER_BY_ALLOWLIST,
default="-created_at",
)
)
)
return self.paginate(
request=request,
queryset=modules,
on_results=lambda modules: ModuleLiteSerializer(modules, many=True).data,
)
class ModuleDetailAPIEndpoint(BaseAPIView):
"""Module Detail Endpoint"""

View File

@@ -16,7 +16,8 @@ from django.core.serializers.json import DjangoJSONEncoder
from rest_framework import status
from rest_framework.response import Response
from rest_framework.serializers import ValidationError
from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
from drf_spectacular.utils import OpenApiResponse, OpenApiRequest, OpenApiParameter
from drf_spectacular.types import OpenApiTypes
# Module imports
@@ -44,6 +45,7 @@ from plane.utils.host import base_host
from plane.utils.order_queryset import PROJECT_ORDER_BY_ALLOWLIST, sanitize_order_by
from plane.api.serializers import (
ProjectSerializer,
ProjectLiteSerializer,
ProjectCreateSerializer,
ProjectUpdateSerializer,
)
@@ -337,6 +339,94 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
)
class ProjectListLiteAPIEndpoint(BaseAPIView):
"""Project Lite List Endpoint"""
serializer_class = ProjectLiteSerializer
model = Project
permission_classes = [ProjectBasePermission]
use_read_replica = True
def get_queryset(self):
# Projects the user can access: those they are an active member of, plus
# public (network=2) ones.
return (
Project.objects.filter(workspace__slug=self.kwargs.get("slug"))
.filter(
Q(
project_projectmember__member=self.request.user,
project_projectmember__is_active=True,
)
| Q(network=2)
)
.distinct()
)
@project_docs(
operation_id="list_projects_lite",
summary="List projects (lite)",
description="Retrieve a paginated, lightweight list of projects for pickers and references.",
parameters=[
CURSOR_PARAMETER,
PER_PAGE_PARAMETER,
ORDER_BY_PARAMETER,
OpenApiParameter(
name="include_archived",
type=OpenApiTypes.BOOL,
location=OpenApiParameter.QUERY,
required=False,
description=(
"Include archived projects in the response. Defaults to false. "
"Each project carries an archived_at timestamp (null when not archived)."
),
),
],
responses={
200: create_paginated_response(
ProjectLiteSerializer,
"PaginatedProjectLiteResponse",
"Paginated list of projects with minimal fields",
"Paginated Projects (Lite)",
),
404: WORKSPACE_NOT_FOUND_RESPONSE,
},
)
def get(self, request, slug):
"""List projects (lite)
Retrieve a paginated, lightweight list of projects the user can access in
the workspace (projects they are an active member of, plus public projects),
optimized for pickers and references.
"""
if not Workspace.objects.filter(slug=slug).exists():
return Response(
{"error": "Provided workspace does not exist"},
status=status.HTTP_404_NOT_FOUND,
)
projects = self.get_queryset()
# Archived projects are excluded by default; pass ?include_archived=true
# (or 1) to include them. Each project's archived_at timestamp marks the
# archived ones.
include_archived = request.GET.get("include_archived", "false").lower() in ("true", "1")
if not include_archived:
projects = projects.filter(archived_at__isnull=True)
projects = projects.order_by(
sanitize_order_by(
request.GET.get("order_by", "-created_at"),
PROJECT_ORDER_BY_ALLOWLIST,
default="-created_at",
)
)
return self.paginate(
request=request,
queryset=projects,
on_results=lambda projects: ProjectLiteSerializer(projects, many=True).data,
)
class ProjectDetailAPIEndpoint(BaseAPIView):
"""Project Endpoints to update, retrieve and delete endpoint"""

View File

@@ -0,0 +1,72 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for the cycles-lite endpoint.
GET /api/v1/workspaces/<slug>/projects/<project_id>/cycles-lite/
"""
from datetime import timedelta
import pytest
from django.utils import timezone
from rest_framework import status
from plane.db.models import Cycle, Project, ProjectMember
def _url(slug, project_id):
return f"/api/v1/workspaces/{slug}/projects/{project_id}/cycles-lite/"
@pytest.fixture
def project(db, workspace, create_user):
project = Project.objects.create(
name="Cycle Lite Project",
identifier="CLP",
workspace=workspace,
created_by=create_user,
cycle_view=True,
)
ProjectMember.objects.create(
workspace=workspace,
project=project,
member=create_user,
role=20,
is_active=True,
)
return project
@pytest.fixture
def cycles(db, project, create_user):
now = timezone.now()
active = Cycle.objects.create(
name="Active Cycle",
project=project,
workspace=project.workspace,
owned_by=create_user,
start_date=now,
end_date=now + timedelta(days=7),
)
archived = Cycle.objects.create(
name="Archived Cycle",
project=project,
workspace=project.workspace,
owned_by=create_user,
archived_at=now,
)
return {"active": active, "archived": archived}
@pytest.mark.contract
class TestCyclesLite:
@pytest.mark.django_db
def test_paginated_and_excludes_archived(self, api_key_client, workspace, project, cycles):
response = api_key_client.get(_url(workspace.slug, project.id))
assert response.status_code == status.HTTP_200_OK
assert "results" in response.data
names = {item["name"] for item in response.data["results"]}
assert "Active Cycle" in names
assert "Archived Cycle" not in names

View File

@@ -0,0 +1,102 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for the workspace/project members-lite endpoints.
GET /api/v1/workspaces/<slug>/members-lite/
GET /api/v1/workspaces/<slug>/projects/<project_id>/project-members-lite/
"""
import pytest
from rest_framework import status
from plane.db.models import Project, ProjectMember
_LITE_MEMBER_FIELDS = (
"id",
"first_name",
"last_name",
"email",
"avatar",
"avatar_url",
"display_name",
"role",
"is_active",
"is_bot",
)
def _ws_url(slug):
return f"/api/v1/workspaces/{slug}/members-lite/"
def _project_url(slug, project_id):
return f"/api/v1/workspaces/{slug}/projects/{project_id}/project-members-lite/"
@pytest.fixture
def project(db, workspace, create_user):
project = Project.objects.create(
name="Members Lite Project",
identifier="MLP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
workspace=workspace,
project=project,
member=create_user,
role=20,
is_active=True,
)
return project
@pytest.mark.contract
class TestWorkspaceMembersLite:
@pytest.mark.django_db
def test_returns_paginated_member(self, api_key_client, workspace):
response = api_key_client.get(_ws_url(workspace.slug))
assert response.status_code == status.HTTP_200_OK
assert "results" in response.data
emails = {item["email"] for item in response.data["results"]}
assert "test@plane.so" in emails
@pytest.mark.django_db
def test_lite_member_shape(self, api_key_client, workspace):
response = api_key_client.get(_ws_url(workspace.slug))
assert response.status_code == status.HTTP_200_OK
item = response.data["results"][0]
for key in _LITE_MEMBER_FIELDS:
assert key in item
# The requesting user is the workspace owner (admin role = 20).
assert item["role"] == 20
@pytest.mark.django_db
def test_unknown_workspace_is_rejected(self, api_key_client):
response = api_key_client.get(_ws_url("does-not-exist"))
assert response.status_code in (
status.HTTP_400_BAD_REQUEST,
status.HTTP_403_FORBIDDEN,
status.HTTP_404_NOT_FOUND,
)
@pytest.mark.contract
class TestProjectMembersLite:
@pytest.mark.django_db
def test_returns_paginated_member(self, api_key_client, workspace, project):
response = api_key_client.get(_project_url(workspace.slug, project.id))
assert response.status_code == status.HTTP_200_OK
assert "results" in response.data
emails = {item["email"] for item in response.data["results"]}
assert "test@plane.so" in emails
@pytest.mark.django_db
def test_lite_member_shape(self, api_key_client, workspace, project):
response = api_key_client.get(_project_url(workspace.slug, project.id))
assert response.status_code == status.HTTP_200_OK
item = response.data["results"][0]
for key in _LITE_MEMBER_FIELDS:
assert key in item

View File

@@ -0,0 +1,61 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for the modules-lite endpoint.
GET /api/v1/workspaces/<slug>/projects/<project_id>/modules-lite/
"""
import pytest
from django.utils import timezone
from rest_framework import status
from plane.db.models import Module, Project, ProjectMember
def _url(slug, project_id):
return f"/api/v1/workspaces/{slug}/projects/{project_id}/modules-lite/"
@pytest.fixture
def project(db, workspace, create_user):
project = Project.objects.create(
name="Module Lite Project",
identifier="MDL",
workspace=workspace,
created_by=create_user,
module_view=True,
)
ProjectMember.objects.create(
workspace=workspace,
project=project,
member=create_user,
role=20,
is_active=True,
)
return project
@pytest.fixture
def modules(db, project):
active = Module.objects.create(name="Active Module", project=project, workspace=project.workspace)
archived = Module.objects.create(
name="Archived Module",
project=project,
workspace=project.workspace,
archived_at=timezone.now(),
)
return {"active": active, "archived": archived}
@pytest.mark.contract
class TestModulesLite:
@pytest.mark.django_db
def test_paginated_and_excludes_archived(self, api_key_client, workspace, project, modules):
response = api_key_client.get(_url(workspace.slug, project.id))
assert response.status_code == status.HTTP_200_OK
assert "results" in response.data
names = {item["name"] for item in response.data["results"]}
assert "Active Module" in names
assert "Archived Module" not in names

View File

@@ -0,0 +1,103 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
"""Contract tests for the projects-lite endpoint.
GET /api/v1/workspaces/<slug>/projects-lite/
"""
import pytest
from django.utils import timezone
from rest_framework import status
from plane.db.models import Project, ProjectMember
def _url(slug):
return f"/api/v1/workspaces/{slug}/projects-lite/"
@pytest.fixture
def project(db, workspace, create_user):
"""A non-archived project the requesting user is an active admin of."""
project = Project.objects.create(
name="Lite Project",
identifier="LP",
workspace=workspace,
created_by=create_user,
)
ProjectMember.objects.create(
workspace=workspace,
project=project,
member=create_user,
role=20,
is_active=True,
)
return project
@pytest.fixture
def archived_project(db, workspace, create_user):
"""An archived project the requesting user is an active admin of."""
project = Project.objects.create(
name="Archived Project",
identifier="ARCH",
workspace=workspace,
created_by=create_user,
archived_at=timezone.now(),
)
ProjectMember.objects.create(
workspace=workspace,
project=project,
member=create_user,
role=20,
is_active=True,
)
return project
@pytest.mark.contract
class TestProjectsLite:
@pytest.mark.django_db
def test_returns_paginated_results(self, api_key_client, workspace, project):
response = api_key_client.get(_url(workspace.slug))
assert response.status_code == status.HTTP_200_OK
assert "results" in response.data
ids = {str(item["id"]) for item in response.data["results"]}
assert str(project.id) in ids
@pytest.mark.django_db
def test_returns_only_lite_fields(self, api_key_client, workspace, project):
response = api_key_client.get(_url(workspace.slug))
assert response.status_code == status.HTTP_200_OK
item = response.data["results"][0]
# Trimmed shape — archived_at present, heavy computed fields absent.
for key in ("id", "identifier", "name", "cover_image_url", "archived_at"):
assert key in item
assert "total_members" not in item
@pytest.mark.django_db
def test_archived_excluded_by_default(self, api_key_client, workspace, project, archived_project):
response = api_key_client.get(_url(workspace.slug))
assert response.status_code == status.HTTP_200_OK
ids = {str(item["id"]) for item in response.data["results"]}
assert str(project.id) in ids
assert str(archived_project.id) not in ids
@pytest.mark.django_db
def test_include_archived_returns_all(self, api_key_client, workspace, project, archived_project):
response = api_key_client.get(_url(workspace.slug), {"include_archived": "true"})
assert response.status_code == status.HTTP_200_OK
ids = {str(item["id"]) for item in response.data["results"]}
assert str(project.id) in ids
assert str(archived_project.id) in ids
@pytest.mark.django_db
def test_unknown_workspace_is_rejected(self, api_key_client, project):
response = api_key_client.get(_url("does-not-exist"))
assert response.status_code in (
status.HTTP_400_BAD_REQUEST,
status.HTTP_403_FORBIDDEN,
status.HTTP_404_NOT_FOUND,
)

View File

@@ -97,6 +97,26 @@ ISSUE_GROUP_BY_ALLOWLIST = frozenset({
"start_date",
})
# Cycle list queryset.
CYCLE_ORDER_BY_ALLOWLIST = frozenset({
"created_at",
"updated_at",
"name",
"start_date",
"end_date",
"sort_order",
})
# Module list queryset.
MODULE_ORDER_BY_ALLOWLIST = frozenset({
"created_at",
"updated_at",
"name",
"start_date",
"target_date",
"status",
"sort_order",
})
def sanitize_order_by(value, allowed_fields, default="-created_at"):
"""Return a safe ordering string derived from *value*.