diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index 2ab639d546..d0278eb141 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -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 diff --git a/apps/api/plane/api/serializers/member.py b/apps/api/plane/api/serializers/member.py index 266a4cfe11..0fa5bb43e7 100644 --- a/apps/api/plane/api/serializers/member.py +++ b/apps/api/plane/api/serializers/member.py @@ -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 diff --git a/apps/api/plane/api/serializers/project.py b/apps/api/plane/api/serializers/project.py index aa98dd439f..89953d5d61 100644 --- a/apps/api/plane/api/serializers/project.py +++ b/apps/api/plane/api/serializers/project.py @@ -318,5 +318,6 @@ class ProjectLiteSerializer(BaseSerializer): "emoji", "description", "cover_image_url", + "archived_at", ] read_only_fields = fields diff --git a/apps/api/plane/api/urls/cycle.py b/apps/api/plane/api/urls/cycle.py index 6d582784e8..633b5d5bc7 100644 --- a/apps/api/plane/api/urls/cycle.py +++ b/apps/api/plane/api/urls/cycle.py @@ -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//projects//cycles-lite/", + CycleListLiteAPIEndpoint.as_view(http_method_names=["get"]), + name="cycles-lite", + ), path( "workspaces//projects//cycles//", CycleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), diff --git a/apps/api/plane/api/urls/member.py b/apps/api/plane/api/urls/member.py index 4f2b032307..f0f5b02231 100644 --- a/apps/api/plane/api/urls/member.py +++ b/apps/api/plane/api/urls/member.py @@ -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//projects//project-members-lite/", + ProjectMemberLiteAPIEndpoint.as_view(http_method_names=["get"]), + name="project-members-lite", + ), path( "workspaces//projects//project-members//", 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//members-lite/", + WorkspaceMemberLiteAPIEndpoint.as_view(http_method_names=["get"]), + name="workspace-members-lite", + ), ] diff --git a/apps/api/plane/api/urls/module.py b/apps/api/plane/api/urls/module.py index a0924100d3..d30690163e 100644 --- a/apps/api/plane/api/urls/module.py +++ b/apps/api/plane/api/urls/module.py @@ -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//projects//modules-lite/", + ModuleListLiteAPIEndpoint.as_view(http_method_names=["get"]), + name="modules-lite", + ), path( "workspaces//projects//modules//", ModuleDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), diff --git a/apps/api/plane/api/urls/project.py b/apps/api/plane/api/urls/project.py index eb22249e0a..1b7712526a 100644 --- a/apps/api/plane/api/urls/project.py +++ b/apps/api/plane/api/urls/project.py @@ -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//projects-lite/", + ProjectListLiteAPIEndpoint.as_view(http_method_names=["get"]), + name="project-lite", + ), path( "workspaces//projects//", ProjectDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index e8549afb43..5e4660a7b2 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -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, diff --git a/apps/api/plane/api/views/cycle.py b/apps/api/plane/api/views/cycle.py index 99ebb18512..6274b94de8 100644 --- a/apps/api/plane/api/views/cycle.py +++ b/apps/api/plane/api/views/cycle.py @@ -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. diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index 9224ffb4db..da9edc66d6 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -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") diff --git a/apps/api/plane/api/views/member.py b/apps/api/plane/api/views/member.py index adb28be003..4c8a04d599 100644 --- a/apps/api/plane/api/views/member.py +++ b/apps/api/plane/api/views/member.py @@ -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, + ) diff --git a/apps/api/plane/api/views/module.py b/apps/api/plane/api/views/module.py index 0a057ebacd..52fec7e7b7 100644 --- a/apps/api/plane/api/views/module.py +++ b/apps/api/plane/api/views/module.py @@ -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""" diff --git a/apps/api/plane/api/views/project.py b/apps/api/plane/api/views/project.py index 28ac2f32b8..77a195bdec 100644 --- a/apps/api/plane/api/views/project.py +++ b/apps/api/plane/api/views/project.py @@ -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""" diff --git a/apps/api/plane/tests/contract/api/test_cycles_lite.py b/apps/api/plane/tests/contract/api/test_cycles_lite.py new file mode 100644 index 0000000000..1e3d23e5ef --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_cycles_lite.py @@ -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//projects//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 diff --git a/apps/api/plane/tests/contract/api/test_members_lite.py b/apps/api/plane/tests/contract/api/test_members_lite.py new file mode 100644 index 0000000000..10d6722e53 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_members_lite.py @@ -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//members-lite/ +GET /api/v1/workspaces//projects//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 diff --git a/apps/api/plane/tests/contract/api/test_modules_lite.py b/apps/api/plane/tests/contract/api/test_modules_lite.py new file mode 100644 index 0000000000..78bea469f6 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_modules_lite.py @@ -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//projects//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 diff --git a/apps/api/plane/tests/contract/api/test_projects_lite.py b/apps/api/plane/tests/contract/api/test_projects_lite.py new file mode 100644 index 0000000000..622b927e66 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_projects_lite.py @@ -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//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, + ) diff --git a/apps/api/plane/utils/order_queryset.py b/apps/api/plane/utils/order_queryset.py index 8816fc4c6d..c799dadbd7 100644 --- a/apps/api/plane/utils/order_queryset.py +++ b/apps/api/plane/utils/order_queryset.py @@ -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*.