mirror of
https://github.com/makeplane/plane.git
synced 2026-08-29 10:08:51 +02:00
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:
committed by
GitHub
parent
af1be50b48
commit
7cef741c29
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -318,5 +318,6 @@ class ProjectLiteSerializer(BaseSerializer):
|
||||
"emoji",
|
||||
"description",
|
||||
"cover_image_url",
|
||||
"archived_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
72
apps/api/plane/tests/contract/api/test_cycles_lite.py
Normal file
72
apps/api/plane/tests/contract/api/test_cycles_lite.py
Normal 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
|
||||
102
apps/api/plane/tests/contract/api/test_members_lite.py
Normal file
102
apps/api/plane/tests/contract/api/test_members_lite.py
Normal 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
|
||||
61
apps/api/plane/tests/contract/api/test_modules_lite.py
Normal file
61
apps/api/plane/tests/contract/api/test_modules_lite.py
Normal 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
|
||||
103
apps/api/plane/tests/contract/api/test_projects_lite.py
Normal file
103
apps/api/plane/tests/contract/api/test_projects_lite.py
Normal 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,
|
||||
)
|
||||
@@ -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*.
|
||||
|
||||
Reference in New Issue
Block a user