diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index d0278eb1415..928459263d8 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -68,3 +68,4 @@ ProjectMemberLiteAPISerializer, ) from .sticky import StickySerializer +from .page import PageSerializer, PageCreateSerializer, PageUpdateSerializer diff --git a/apps/api/plane/api/serializers/page.py b/apps/api/plane/api/serializers/page.py new file mode 100644 index 00000000000..ff2cfb93d6c --- /dev/null +++ b/apps/api/plane/api/serializers/page.py @@ -0,0 +1,228 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Third party imports +from rest_framework import serializers + +# Module imports +from .base import BaseSerializer +from plane.db.models import ( + Label, + Page, + PageLabel, + Project, + ProjectPage, +) + + +class PageCreateSerializer(BaseSerializer): + """ + Serializer for creating pages within a project. + + Handles page creation including label assignment, parent page validation, + and project-page link creation for project documentation setup. + """ + + labels = serializers.ListField( + child=serializers.PrimaryKeyRelatedField(queryset=Label.objects.none()), + write_only=True, + required=False, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Labels are project-scoped: only labels of the current project may be + # attached, on both create and update + project_id = self.context.get("project_id") + if project_id: + self.fields["labels"].child.queryset = Label.objects.filter(project_id=project_id) + + class Meta: + model = Page + fields = [ + "name", + "description_html", + "description_json", + "access", + "color", + "labels", + "parent", + "is_locked", + "archived_at", + "view_props", + "logo_props", + "external_id", + "external_source", + ] + read_only_fields = [ + "id", + "workspace", + "owned_by", + "created_by", + "updated_by", + "created_at", + "updated_at", + "deleted_at", + ] + + def validate(self, data): + project_id = self.context.get("project_id") + if not project_id: + raise serializers.ValidationError("Project ID is required") + project = Project.objects.get(id=project_id) + if not project: + raise serializers.ValidationError("Project not found") + + # Validate the parent page belongs to the same project + parent = data.get("parent", None) + if parent is not None and not ProjectPage.objects.filter( + project_id=project_id, + page_id=parent.id, + deleted_at__isnull=True, + ).exists(): + raise serializers.ValidationError({"parent": "Parent page does not exist in this project"}) + + return data + + def create(self, validated_data): + labels = validated_data.pop("labels", None) + + project_id = self.context["project_id"] + owned_by_id = self.context["owned_by_id"] + + # Get the workspace id from the project + project = Project.objects.get(pk=project_id) + + # Create the page + page = Page.objects.create( + **validated_data, + owned_by_id=owned_by_id, + workspace_id=project.workspace_id, + ) + + # Create the project page + ProjectPage.objects.create( + workspace_id=page.workspace_id, + project_id=project_id, + page_id=page.id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + + # Create page labels + if labels is not None: + PageLabel.objects.bulk_create( + [ + PageLabel( + label=label, + page=page, + workspace_id=page.workspace_id, + created_by_id=page.created_by_id, + updated_by_id=page.updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + return page + + +class PageUpdateSerializer(PageCreateSerializer): + """ + Serializer for updating pages with label management. + + Extends page creation with update-specific label replacement + and parent page revalidation for documentation maintenance. + """ + + class Meta(PageCreateSerializer.Meta): + model = Page + fields = PageCreateSerializer.Meta.fields + read_only_fields = PageCreateSerializer.Meta.read_only_fields + + def validate(self, data): + project_id = self.context.get("project_id") + if not project_id: + raise serializers.ValidationError("Project ID is required") + + # Validate the parent page belongs to the same project + parent = data.get("parent", None) + if parent is not None and not ProjectPage.objects.filter( + project_id=project_id, + page_id=parent.id, + deleted_at__isnull=True, + ).exists(): + raise serializers.ValidationError({"parent": "Parent page does not exist in this project"}) + + return data + + def update(self, instance, validated_data): + labels = validated_data.pop("labels", None) + if labels is not None: + PageLabel.objects.filter(page=instance).delete() + PageLabel.objects.bulk_create( + [ + PageLabel( + label=label, + page=instance, + workspace_id=instance.workspace_id, + created_by_id=instance.created_by_id, + updated_by_id=instance.updated_by_id, + ) + for label in labels + ], + batch_size=10, + ) + + return super().update(instance, validated_data) + + +class PageSerializer(BaseSerializer): + """ + Comprehensive page serializer with labels and project associations. + + Provides complete page data including content, hierarchy, label ids, + and linked project ids for project documentation management. + """ + + # Many to many, annotated on the queryset + label_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + project_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + + class Meta: + model = Page + fields = [ + "id", + "name", + "description_html", + "description_json", + "description_stripped", + "owned_by", + "access", + "color", + "parent", + "is_locked", + "archived_at", + "workspace", + "view_props", + "logo_props", + "external_id", + "external_source", + "label_ids", + "project_ids", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = [ + "id", + "workspace", + "owned_by", + "created_by", + "updated_by", + "created_at", + "updated_at", + "deleted_at", + ] diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index 4a202431bc7..ba5e6338d80 100644 --- a/apps/api/plane/api/urls/__init__.py +++ b/apps/api/plane/api/urls/__init__.py @@ -14,6 +14,7 @@ from .work_item import urlpatterns as work_item_patterns from .invite import urlpatterns as invite_patterns from .sticky import urlpatterns as sticky_patterns +from .page import urlpatterns as page_patterns urlpatterns = [ *asset_patterns, @@ -28,4 +29,5 @@ *work_item_patterns, *invite_patterns, *sticky_patterns, + *page_patterns, ] diff --git a/apps/api/plane/api/urls/page.py b/apps/api/plane/api/urls/page.py new file mode 100644 index 00000000000..34feaed78a2 --- /dev/null +++ b/apps/api/plane/api/urls/page.py @@ -0,0 +1,23 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +from django.urls import path + +from plane.api.views import ( + PageListCreateAPIEndpoint, + PageDetailAPIEndpoint, +) + +urlpatterns = [ + path( + "workspaces//projects//pages/", + PageListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="pages", + ), + path( + "workspaces//projects//pages//", + PageDetailAPIEndpoint.as_view(http_method_names=["get", "patch", "delete"]), + name="pages-detail", + ), +] diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index 5e4660a7b2b..07e3d3a31fc 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -72,3 +72,8 @@ from .invite import WorkspaceInvitationsViewset from .sticky import StickyViewSet + +from .page import ( + PageListCreateAPIEndpoint, + PageDetailAPIEndpoint, +) diff --git a/apps/api/plane/api/views/page.py b/apps/api/plane/api/views/page.py new file mode 100644 index 00000000000..638d1434de5 --- /dev/null +++ b/apps/api/plane/api/views/page.py @@ -0,0 +1,406 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +# Django imports +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Q, UUIDField, Value +from django.db.models.functions import Coalesce + +# Third party imports +from rest_framework import status +from rest_framework.response import Response +from drf_spectacular.utils import OpenApiResponse, OpenApiRequest + +# Module imports +from plane.api.serializers import ( + PageSerializer, + PageCreateSerializer, + PageUpdateSerializer, +) +from plane.app.permissions import ProjectEntityPermission +from plane.db.models import ( + Page, + Project, + ProjectMember, + UserFavorite, + UserRecentVisit, +) + +from .base import BaseAPIView +from plane.utils.order_queryset import PAGE_ORDER_BY_ALLOWLIST, sanitize_order_by +from plane.utils.openapi import ( + page_docs, + PAGE_PK_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + create_paginated_response, + # Request Examples + PAGE_CREATE_EXAMPLE, + PAGE_UPDATE_EXAMPLE, + # Response Examples + PAGE_EXAMPLE, + INVALID_REQUEST_RESPONSE, + PROJECT_NOT_FOUND_RESPONSE, + EXTERNAL_ID_EXISTS_RESPONSE, + DELETED_RESPONSE, + ADMIN_ONLY_RESPONSE, +) + + +class PageListCreateAPIEndpoint(BaseAPIView): + """Page List and Create Endpoint""" + + serializer_class = PageSerializer + model = Page + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + def get_queryset(self): + return ( + Page.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(projects__id=self.kwargs.get("project_id")) + .filter(project_pages__deleted_at__isnull=True) + .filter( + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__archived_at__isnull=True, + ) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .select_related("workspace") + .select_related("owned_by") + .select_related("parent") + .prefetch_related("labels") + .prefetch_related("projects") + .annotate( + label_ids=Coalesce( + ArrayAgg( + "page_labels__label_id", + distinct=True, + filter=~Q(page_labels__label_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + project_ids=Coalesce( + ArrayAgg("projects__id", distinct=True, filter=Q(projects__id__isnull=False)), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .order_by("-created_at") + .distinct() + ) + + @page_docs( + operation_id="create_page", + summary="Create page", + description="Create a new page in a project with content, labels, and hierarchy.", + request=OpenApiRequest( + request=PageCreateSerializer, + examples=[PAGE_CREATE_EXAMPLE], + ), + responses={ + 201: OpenApiResponse( + description="Page created", + response=PageSerializer, + examples=[PAGE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: PROJECT_NOT_FOUND_RESPONSE, + 409: EXTERNAL_ID_EXISTS_RESPONSE, + }, + ) + def post(self, request, slug, project_id): + """Create page + + Create a new page in a project with content, labels, and hierarchy. + Automatically assigns the requesting user as the page owner. + """ + project = Project.objects.get(pk=project_id, workspace__slug=slug) + serializer = PageCreateSerializer( + data=request.data, + context={"project_id": project_id, "owned_by_id": request.user.id}, + ) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and request.data.get("external_source") + and Page.objects.filter( + projects__id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).exists() + ): + page = Page.objects.filter( + projects__id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source"), + external_id=request.data.get("external_id"), + ).first() + return Response( + { + "error": "Page with the same external id and external source already exists", + "id": str(page.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save() + page = self.get_queryset().get(pk=serializer.instance.id) + serializer = PageSerializer(page) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @page_docs( + operation_id="list_pages", + summary="List pages", + description="Retrieve all pages in a project.", + parameters=[ + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + ORDER_BY_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + PageSerializer, + "PaginatedPageResponse", + "Paginated list of pages", + "Paginated Pages", + ), + 404: OpenApiResponse(description="Project not found"), + }, + ) + def get(self, request, slug, project_id): + """List pages + + Retrieve all pages in a project visible to the requesting user. + Returns paginated results with label and project associations. + """ + order_by = sanitize_order_by( + request.GET.get("order_by", "-created_at"), + PAGE_ORDER_BY_ALLOWLIST, + default="-created_at", + ) + return self.paginate( + request=request, + queryset=(self.get_queryset().filter(archived_at__isnull=True).order_by(order_by)), + on_results=lambda pages: PageSerializer(pages, many=True, fields=self.fields, expand=self.expand).data, + ) + + +class PageDetailAPIEndpoint(BaseAPIView): + """Page Detail Endpoint""" + + serializer_class = PageSerializer + model = Page + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + def get_queryset(self): + return ( + Page.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(projects__id=self.kwargs.get("project_id")) + .filter(project_pages__deleted_at__isnull=True) + .filter( + projects__project_projectmember__member=self.request.user, + projects__project_projectmember__is_active=True, + projects__archived_at__isnull=True, + ) + .filter(Q(owned_by=self.request.user) | Q(access=0)) + .select_related("workspace") + .select_related("owned_by") + .select_related("parent") + .prefetch_related("labels") + .prefetch_related("projects") + .annotate( + label_ids=Coalesce( + ArrayAgg( + "page_labels__label_id", + distinct=True, + filter=~Q(page_labels__label_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + project_ids=Coalesce( + ArrayAgg("projects__id", distinct=True, filter=Q(projects__id__isnull=False)), + Value([], output_field=ArrayField(UUIDField())), + ), + ) + .order_by("-created_at") + .distinct() + ) + + @page_docs( + operation_id="retrieve_page", + summary="Retrieve page", + description="Retrieve details of a specific page.", + parameters=[ + PAGE_PK_PARAMETER, + ], + responses={ + 200: OpenApiResponse( + description="Page", + response=PageSerializer, + examples=[PAGE_EXAMPLE], + ), + 404: OpenApiResponse(description="Page not found"), + }, + ) + def get(self, request, slug, project_id, pk): + """Retrieve page + + Retrieve details of a specific page. + """ + page = self.get_queryset().get(pk=pk) + data = PageSerializer(page, fields=self.fields, expand=self.expand).data + return Response(data, status=status.HTTP_200_OK) + + @page_docs( + operation_id="update_page", + summary="Update page", + description="Modify an existing page's properties like name, content, labels, or hierarchy.", + parameters=[ + PAGE_PK_PARAMETER, + ], + request=OpenApiRequest( + request=PageUpdateSerializer, + examples=[PAGE_UPDATE_EXAMPLE], + ), + responses={ + 200: OpenApiResponse( + description="Page updated successfully", + response=PageSerializer, + examples=[PAGE_EXAMPLE], + ), + 400: INVALID_REQUEST_RESPONSE, + 404: OpenApiResponse(description="Page not found"), + 409: EXTERNAL_ID_EXISTS_RESPONSE, + }, + ) + def patch(self, request, slug, project_id, pk): + """Update page + + Modify an existing page's properties like name, content, labels, or hierarchy. + Locked pages cannot be edited and access changes are restricted to the owner. + """ + # Resolve through get_queryset() so the owner-or-public visibility rule + # is applied before any mutation — a project member must not be able to + # update another user's private page + page = self.get_queryset().get(pk=pk) + + if page.is_locked: + return Response({"error": "Page is locked"}, status=status.HTTP_400_BAD_REQUEST) + + # Only the owner can update the access of the page + if page.access != request.data.get("access", page.access) and page.owned_by_id != request.user.id: + return Response( + {"error": "Access cannot be updated since this page is owned by someone else"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + serializer = PageUpdateSerializer( + page, + data=request.data, + context={"project_id": project_id}, + partial=True, + ) + if serializer.is_valid(): + if ( + request.data.get("external_id") + and (page.external_id != request.data.get("external_id")) + and Page.objects.filter( + projects__id=project_id, + workspace__slug=slug, + external_source=request.data.get("external_source", page.external_source), + external_id=request.data.get("external_id"), + ).exists() + ): + return Response( + { + "error": "Page with the same external id and external source already exists", + "id": str(page.id), + }, + status=status.HTTP_409_CONFLICT, + ) + serializer.save() + page = self.get_queryset().get(pk=pk) + serializer = PageSerializer(page) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @page_docs( + operation_id="delete_page", + summary="Delete page", + description="Permanently remove an archived page and detach all its child pages.", + parameters=[ + PAGE_PK_PARAMETER, + ], + responses={ + 204: DELETED_RESPONSE, + 400: OpenApiResponse(description="Page is not archived"), + 403: ADMIN_ONLY_RESPONSE, + 404: OpenApiResponse(description="Page not found"), + }, + ) + def delete(self, request, slug, project_id, pk): + """Delete page + + Permanently remove a page. The page must be archived before deleting + and only the owner or a project admin can perform this action. + """ + page = Page.objects.get( + pk=pk, + workspace__slug=slug, + projects__id=project_id, + project_pages__deleted_at__isnull=True, + ) + + if page.archived_at is None: + return Response( + {"error": "The page should be archived before deleting"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if page.owned_by_id != request.user.id and ( + not ProjectMember.objects.filter( + workspace__slug=slug, + member=request.user, + role=20, + project_id=project_id, + is_active=True, + ).exists() + ): + return Response( + {"error": "Only admin or owner can delete the page"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Remove the parent from all the children + _ = Page.objects.filter( + parent_id=pk, + projects__id=project_id, + workspace__slug=slug, + project_pages__deleted_at__isnull=True, + ).update(parent=None) + + page.delete() + # Delete the user favorite page + UserFavorite.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_type="page", + ).delete() + # Delete the page from recent visits + UserRecentVisit.objects.filter( + project_id=project_id, + workspace__slug=slug, + entity_identifier=pk, + entity_name="page", + ).delete(soft=False) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/tests/contract/api/test_pages.py b/apps/api/plane/tests/contract/api/test_pages.py new file mode 100644 index 00000000000..6fb70dd27e7 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_pages.py @@ -0,0 +1,393 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +import pytest +from uuid import uuid4 + +from rest_framework import status + +from plane.db.models import Label, Page, Project, ProjectMember, ProjectPage, User +from plane.db.models.api import APIToken + + +@pytest.fixture +def project(db, workspace, create_user): + """Create a test project with the user as an admin member""" + project = Project.objects.create( + name="Test Project", + identifier="TP", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create( + project=project, + member=create_user, + role=20, # Admin role + is_active=True, + ) + return project + + +@pytest.fixture +def page_data(): + """Sample page data for tests""" + return { + "name": "Test Page", + "description_html": "

A test page for unit tests

", + } + + +@pytest.fixture +def create_page(db, project, create_user): + """Create a test page linked to the project""" + page = Page.objects.create( + name="Existing Page", + description_html="

An existing page

", + workspace=project.workspace, + owned_by=create_user, + ) + ProjectPage.objects.create( + project=project, + workspace=project.workspace, + page=page, + created_by=create_user, + updated_by=create_user, + ) + return page + + +@pytest.fixture +def other_user(db): + """Create and return another user instance""" + user = User.objects.create( + email="other@plane.so", + username="other-user", + first_name="Other", + last_name="User", + ) + user.set_password("other-password") + user.save() + return user + + +@pytest.fixture +def other_api_key_client(api_client, other_user): + """Return an API key authenticated client for the other user""" + token = APIToken.objects.create( + user=other_user, + label="Other API Token", + token="other-api-token-12345", + ) + api_client.credentials(HTTP_X_API_KEY=token.token) + return api_client + + +@pytest.mark.contract +class TestPageListCreateAPIEndpoint: + """Test Page List and Create API Endpoint""" + + def get_page_url(self, workspace_slug, project_id): + """Helper to get page endpoint URL""" + return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/pages/" + + @pytest.mark.django_db + def test_create_page_success(self, api_key_client, workspace, project, page_data, create_user): + """Test successful page creation""" + url = self.get_page_url(workspace.slug, project.id) + + response = api_key_client.post(url, page_data, format="json") + + assert response.status_code == status.HTTP_201_CREATED + + assert Page.objects.count() == 1 + + created_page = Page.objects.first() + assert created_page.name == page_data["name"] + assert created_page.description_html == page_data["description_html"] + assert created_page.description_stripped == "A test page for unit tests" + assert created_page.owned_by == create_user + assert created_page.workspace == workspace + + # The page is linked to the project + assert ProjectPage.objects.filter(project=project, page=created_page).exists() + assert str(project.id) in response.data["project_ids"] + + @pytest.mark.django_db + def test_create_page_with_labels(self, api_key_client, workspace, project, page_data): + """Test page creation with labels""" + label = Label.objects.create(name="Docs", project=project, workspace=workspace) + url = self.get_page_url(workspace.slug, project.id) + + response = api_key_client.post(url, {**page_data, "labels": [str(label.id)]}, format="json") + + assert response.status_code == status.HTTP_201_CREATED + assert str(label.id) in response.data["label_ids"] + + @pytest.mark.django_db + def test_create_page_rejects_foreign_label(self, api_key_client, workspace, project, page_data, create_user): + """Test that labels from another project cannot be attached""" + other_project = Project.objects.create( + name="Other Project", + identifier="OP", + workspace=workspace, + created_by=create_user, + ) + foreign_label = Label.objects.create(name="Foreign", project=other_project, workspace=workspace) + url = self.get_page_url(workspace.slug, project.id) + + response = api_key_client.post(url, {**page_data, "labels": [str(foreign_label.id)]}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert Page.objects.count() == 0 + + @pytest.mark.django_db + def test_create_page_invalid_parent(self, api_key_client, workspace, project, page_data): + """Test page creation with a parent that does not exist in the project""" + url = self.get_page_url(workspace.slug, project.id) + + response = api_key_client.post(url, {**page_data, "parent": str(uuid4())}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_create_page_duplicate_external_id(self, api_key_client, workspace, project, create_page): + """Test creating page with duplicate external ID""" + create_page.external_id = "ext-123" + create_page.external_source = "github" + create_page.save() + + url = self.get_page_url(workspace.slug, project.id) + page_data = { + "name": "Second Page", + "external_id": "ext-123", + "external_source": "github", + } + + response = api_key_client.post(url, page_data, format="json") + + assert response.status_code == status.HTTP_409_CONFLICT + assert "same external id" in response.data["error"] + + @pytest.mark.django_db + def test_list_pages_success(self, api_key_client, workspace, project, create_page, create_user): + """Test successful page listing""" + url = self.get_page_url(workspace.slug, project.id) + + # Create an additional page + page = Page.objects.create( + name="Page 2", + workspace=project.workspace, + owned_by=create_user, + ) + ProjectPage.objects.create( + project=project, + workspace=project.workspace, + page=page, + created_by=create_user, + updated_by=create_user, + ) + + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert "results" in response.data + assert len(response.data["results"]) == 2 + + @pytest.mark.django_db + def test_list_pages_visibility(self, api_key_client, workspace, project, create_page, other_user): + """Test that private pages of other users are not listed""" + # Private page owned by another user + private_page = Page.objects.create( + name="Private Page", + workspace=project.workspace, + owned_by=other_user, + access=1, # Private + ) + ProjectPage.objects.create( + project=project, + workspace=project.workspace, + page=private_page, + created_by=other_user, + updated_by=other_user, + ) + + url = self.get_page_url(workspace.slug, project.id) + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + result_ids = [str(result["id"]) for result in response.data["results"]] + assert str(create_page.id) in result_ids + assert str(private_page.id) not in result_ids + + @pytest.mark.django_db + def test_pages_unauthenticated(self, api_client, workspace, project): + """Test page endpoints without authentication""" + url = self.get_page_url(workspace.slug, project.id) + + response = api_client.get(url) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + response = api_client.post(url, {"name": "Nope"}, format="json") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +@pytest.mark.contract +class TestPageDetailAPIEndpoint: + """Test Page Detail API Endpoint""" + + def get_page_detail_url(self, workspace_slug, project_id, page_id): + """Helper to get page detail endpoint URL""" + return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/pages/{page_id}/" + + @pytest.mark.django_db + def test_retrieve_page_success(self, api_key_client, workspace, project, create_page): + """Test successful page retrieval""" + url = self.get_page_detail_url(workspace.slug, project.id, create_page.id) + + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert str(response.data["id"]) == str(create_page.id) + assert response.data["name"] == create_page.name + assert response.data["description_html"] == "

An existing page

" + assert str(project.id) in response.data["project_ids"] + + @pytest.mark.django_db + def test_retrieve_page_not_found(self, api_key_client, workspace, project): + """Test page retrieval with invalid page ID""" + url = self.get_page_detail_url(workspace.slug, project.id, uuid4()) + + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_update_page_success(self, api_key_client, workspace, project, create_page): + """Test successful page update""" + url = self.get_page_detail_url(workspace.slug, project.id, create_page.id) + + response = api_key_client.patch( + url, + {"name": "Updated Page", "description_html": "

Updated content

"}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + create_page.refresh_from_db() + assert create_page.name == "Updated Page" + assert create_page.description_html == "

Updated content

" + assert create_page.description_stripped == "Updated content" + + @pytest.mark.django_db + def test_update_locked_page(self, api_key_client, workspace, project, create_page): + """Test updating a locked page""" + create_page.is_locked = True + create_page.save() + + url = self.get_page_detail_url(workspace.slug, project.id, create_page.id) + response = api_key_client.patch(url, {"name": "Should Not Update"}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.data["error"] == "Page is locked" + + @pytest.mark.django_db + def test_update_page_access_by_non_owner(self, other_api_key_client, workspace, project, create_page, other_user): + """Test that a non-owner cannot update the page access""" + # Make the other user a project admin + ProjectMember.objects.create( + project=project, + member=other_user, + role=20, + is_active=True, + ) + + url = self.get_page_detail_url(workspace.slug, project.id, create_page.id) + response = other_api_key_client.patch(url, {"access": 1}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "owned by someone else" in response.data["error"] + + @pytest.mark.django_db + def test_update_private_page_by_non_owner(self, other_api_key_client, workspace, project, create_page, other_user): + """Test that a project member cannot update another user's private page""" + create_page.access = 1 # Private + create_page.save() + ProjectMember.objects.create( + project=project, + member=other_user, + role=15, + is_active=True, + ) + + url = self.get_page_detail_url(workspace.slug, project.id, create_page.id) + response = other_api_key_client.patch(url, {"name": "Hijacked"}, format="json") + + assert response.status_code == status.HTTP_404_NOT_FOUND + create_page.refresh_from_db() + assert create_page.name == "Existing Page" + + @pytest.mark.django_db + def test_update_page_rejects_foreign_label(self, api_key_client, workspace, project, create_page, create_user): + """Test that labels from another project cannot be attached on update""" + other_project = Project.objects.create( + name="Other Project", + identifier="OP", + workspace=workspace, + created_by=create_user, + ) + foreign_label = Label.objects.create(name="Foreign", project=other_project, workspace=workspace) + + url = self.get_page_detail_url(workspace.slug, project.id, create_page.id) + response = api_key_client.patch(url, {"labels": [str(foreign_label.id)]}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert create_page.labels.count() == 0 + + @pytest.mark.django_db + def test_delete_unarchived_page(self, api_key_client, workspace, project, create_page): + """Test deleting a page that is not archived""" + url = self.get_page_detail_url(workspace.slug, project.id, create_page.id) + + response = api_key_client.delete(url) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "should be archived" in response.data["error"] + + @pytest.mark.django_db + def test_delete_archived_page(self, api_key_client, workspace, project, create_page): + """Test successful deletion of an archived page""" + from django.utils import timezone + + create_page.archived_at = timezone.now().date() + create_page.save() + + url = self.get_page_detail_url(workspace.slug, project.id, create_page.id) + response = api_key_client.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not Page.objects.filter(id=create_page.id).exists() + + @pytest.mark.django_db + def test_delete_page_forbidden_for_non_owner_non_admin( + self, other_api_key_client, workspace, project, create_page, other_user + ): + """Test that a non-owner project member cannot delete the page""" + from django.utils import timezone + + create_page.archived_at = timezone.now().date() + create_page.save() + + # Make the other user a regular project member + ProjectMember.objects.create( + project=project, + member=other_user, + role=15, # Member role + is_active=True, + ) + + url = self.get_page_detail_url(workspace.slug, project.id, create_page.id) + response = other_api_key_client.delete(url) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert Page.objects.filter(id=create_page.id).exists() diff --git a/apps/api/plane/utils/openapi/__init__.py b/apps/api/plane/utils/openapi/__init__.py index 090d076ecd1..87c5ca50a91 100644 --- a/apps/api/plane/utils/openapi/__init__.py +++ b/apps/api/plane/utils/openapi/__init__.py @@ -27,6 +27,7 @@ CYCLE_ID_PARAMETER, MODULE_ID_PARAMETER, MODULE_PK_PARAMETER, + PAGE_PK_PARAMETER, ISSUE_ID_PARAMETER, STATE_ID_PARAMETER, LABEL_ID_PARAMETER, @@ -121,6 +122,8 @@ MODULE_CREATE_EXAMPLE, MODULE_UPDATE_EXAMPLE, MODULE_ISSUE_REQUEST_EXAMPLE, + PAGE_CREATE_EXAMPLE, + PAGE_UPDATE_EXAMPLE, PROJECT_CREATE_EXAMPLE, PROJECT_UPDATE_EXAMPLE, STATE_CREATE_EXAMPLE, @@ -137,6 +140,7 @@ TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE, TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE, MODULE_EXAMPLE, + PAGE_EXAMPLE, STATE_EXAMPLE, LABEL_EXAMPLE, ISSUE_LINK_EXAMPLE, @@ -172,6 +176,7 @@ issue_attachment_docs, module_docs, module_issue_docs, + page_docs, state_docs, estimate_docs, estimate_point_docs, @@ -196,6 +201,7 @@ "CYCLE_ID_PARAMETER", "MODULE_ID_PARAMETER", "MODULE_PK_PARAMETER", + "PAGE_PK_PARAMETER", "ISSUE_ID_PARAMETER", "STATE_ID_PARAMETER", "LABEL_ID_PARAMETER", @@ -284,6 +290,8 @@ "MODULE_CREATE_EXAMPLE", "MODULE_UPDATE_EXAMPLE", "MODULE_ISSUE_REQUEST_EXAMPLE", + "PAGE_CREATE_EXAMPLE", + "PAGE_UPDATE_EXAMPLE", "PROJECT_CREATE_EXAMPLE", "PROJECT_UPDATE_EXAMPLE", "STATE_CREATE_EXAMPLE", @@ -300,6 +308,7 @@ "TRANSFER_CYCLE_ISSUE_ERROR_EXAMPLE", "TRANSFER_CYCLE_COMPLETED_ERROR_EXAMPLE", "MODULE_EXAMPLE", + "PAGE_EXAMPLE", "STATE_EXAMPLE", "LABEL_EXAMPLE", "ISSUE_LINK_EXAMPLE", @@ -332,6 +341,7 @@ "issue_attachment_docs", "module_docs", "module_issue_docs", + "page_docs", "state_docs", "estimate_docs", "estimate_point_docs", diff --git a/apps/api/plane/utils/openapi/decorators.py b/apps/api/plane/utils/openapi/decorators.py index 7ded9fb10b3..1d3caeddf1c 100644 --- a/apps/api/plane/utils/openapi/decorators.py +++ b/apps/api/plane/utils/openapi/decorators.py @@ -283,6 +283,21 @@ def state_docs(**kwargs): return extend_schema(**_merge_schema_options(defaults, kwargs)) +def page_docs(**kwargs): + """Decorator for page management endpoints""" + defaults = { + "tags": ["Pages"], + "parameters": [WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) + + def sticky_docs(**kwargs): """Decorator for sticky management endpoints""" defaults = { diff --git a/apps/api/plane/utils/openapi/examples.py b/apps/api/plane/utils/openapi/examples.py index 20aff18958a..ec8cf0b5460 100644 --- a/apps/api/plane/utils/openapi/examples.py +++ b/apps/api/plane/utils/openapi/examples.py @@ -307,6 +307,35 @@ description="Example request for adding module issues", ) +# Page Examples +PAGE_CREATE_EXAMPLE = OpenApiExample( + "PageCreateSerializer", + value={ + "name": "Getting Started", + "description_html": "

Welcome to the project wiki

", + "access": 0, + "color": "#FF9900", + "labels": [ + "0ec6cfa4-e906-4aad-9390-2df0303a41cd", + ], + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for creating a page", +) + +PAGE_UPDATE_EXAMPLE = OpenApiExample( + "PageUpdateSerializer", + value={ + "name": "Getting Started (Updated)", + "description_html": "

Updated project wiki content

", + "is_locked": True, + "external_id": "1234567890", + "external_source": "github", + }, + description="Example request for updating a page", +) + # Project Examples PROJECT_CREATE_EXAMPLE = OpenApiExample( "ProjectCreateSerializer", @@ -453,6 +482,27 @@ }, ) +# Page Response Examples +PAGE_EXAMPLE = OpenApiExample( + name="Page", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Getting Started", + "description_html": "

Welcome to the project wiki

", + "description_stripped": "Welcome to the project wiki", + "owned_by": "0ec6cfa4-e906-4aad-9390-2df0303a41cd", + "access": 0, + "color": "#FF9900", + "parent": None, + "is_locked": False, + "archived_at": None, + "label_ids": ["0ec6cfa4-e906-4aad-9390-2df0303a41cd"], + "project_ids": ["0ec6cfa4-e906-4aad-9390-2df0303a41ce"], + "created_at": "2024-01-01T10:30:00Z", + "updated_at": "2024-01-10T15:45:00Z", + }, +) + # State Response Examples STATE_EXAMPLE = OpenApiExample( name="State", diff --git a/apps/api/plane/utils/openapi/parameters.py b/apps/api/plane/utils/openapi/parameters.py index 2812892ec91..2d422bd73c5 100644 --- a/apps/api/plane/utils/openapi/parameters.py +++ b/apps/api/plane/utils/openapi/parameters.py @@ -149,6 +149,21 @@ ], ) +PAGE_PK_PARAMETER = OpenApiParameter( + name="pk", + description="Page ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example page ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical page UUID", + ) + ], +) + ISSUE_ID_PARAMETER = OpenApiParameter( name="issue_id", description="Issue ID",