Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/plane/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,4 @@
ProjectMemberLiteAPISerializer,
)
from .sticky import StickySerializer
from .page import PageAPISerializer
114 changes: 114 additions & 0 deletions apps/api/plane/api/serializers/page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.

from django.db.models import Q
from rest_framework import serializers

from plane.app.serializers import PageSerializer
from plane.db.models import Page, ProjectPage
from plane.utils.content_validator import validate_html_content


class PageAPISerializer(PageSerializer):
"""Public API representation for a project Page.

The app serializer already owns Page creation, project linking, and label
updates. The public API adds the content fields that are otherwise handled
by the browser-only description endpoint and preserves HTML byte-for-byte.
"""

description_html = serializers.CharField(allow_blank=True, trim_whitespace=False)
description_json = serializers.JSONField(read_only=True)

class Meta(PageSerializer.Meta):
fields = [
"id",
"name",
"description_html",
"description_json",
"owned_by",
"access",
"color",
"labels",
"parent",
"is_locked",
"archived_at",
"workspace",
"created_at",
"updated_at",
"created_by",
"updated_by",
"view_props",
"logo_props",
"external_id",
"external_source",
]
read_only_fields = [
"id",
"workspace",
"owned_by",
"created_at",
"updated_at",
"created_by",
"updated_by",
"archived_at",
"is_locked",
]

def validate_description_html(self, value):
if not value:
return value

is_valid, error_message, sanitized_html = validate_html_content(value)
if not is_valid:
raise serializers.ValidationError(error_message)
return sanitized_html

def validate(self, attrs):
attrs = super().validate(attrs)
project_id = self.context["project_id"]
workspace_slug = self.context["workspace_slug"]

parent = attrs.get("parent")
if parent:
accessible_parent = ProjectPage.objects.filter(
project_id=project_id,
workspace__slug=workspace_slug,
page=parent,
deleted_at__isnull=True,
).filter(Q(page__owned_by_id=self.context["owned_by_id"]) | Q(page__access=Page.PUBLIC_ACCESS))
if not accessible_parent.exists():
raise serializers.ValidationError({"parent": "The parent page is not accessible in this project."})
if self.instance:
if parent.id == self.instance.id:
raise serializers.ValidationError({"parent": "A page cannot be its own parent."})

ancestor_id = parent.parent_id
visited_ids = {parent.id}
while ancestor_id:
if ancestor_id == self.instance.id or ancestor_id in visited_ids:
raise serializers.ValidationError({"parent": "The parent would create a page cycle."})
visited_ids.add(ancestor_id)
ancestor_id = Page.objects.filter(pk=ancestor_id).values_list("parent_id", flat=True).first()

labels = attrs.get("labels")
if labels and any(label.project_id != project_id for label in labels):
raise serializers.ValidationError({"labels": "All labels must belong to this project."})

return attrs

def create(self, validated_data):
self.context["description_html"] = validated_data.pop("description_html")
self.context["description_json"] = {}
self.context["description_binary"] = None
return super().create(validated_data)

def update(self, instance, validated_data):
if "description_html" in validated_data:
# Plane Live treats a non-empty Yjs binary as authoritative. Clear
# it so the next editor connection imports this PAT-authored HTML
# instead of restoring and later persisting stale editor state.
validated_data["description_binary"] = None
validated_data["description_json"] = {}
return super().update(instance, validated_data)
2 changes: 2 additions & 0 deletions apps/api/plane/api/urls/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,4 +29,5 @@
*work_item_patterns,
*invite_patterns,
*sticky_patterns,
*page_patterns,
]
21 changes: 21 additions & 0 deletions apps/api/plane/api/urls/page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# 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 ProjectPageDetailAPIEndpoint, ProjectPageListCreateAPIEndpoint


urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/",
ProjectPageListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="project-pages",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/pages/<uuid:page_id>/",
ProjectPageDetailAPIEndpoint.as_view(http_method_names=["get", "patch"]),
name="project-page-detail",
),
]
1 change: 1 addition & 0 deletions apps/api/plane/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,4 @@
from .invite import WorkspaceInvitationsViewset

from .sticky import StickyViewSet
from .page import ProjectPageDetailAPIEndpoint, ProjectPageListCreateAPIEndpoint
29 changes: 25 additions & 4 deletions apps/api/plane/api/views/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,18 @@ class IssueLinkListCreateAPIEndpoint(BaseAPIView):
permission_classes = [ProjectEntityPermission]
use_read_replica = True

def get_scoped_issue(self):
return (
Issue.issue_objects.select_related("project__workspace")
.filter(
workspace__slug=self.kwargs.get("slug"),
project_id=self.kwargs.get("project_id"),
project__archived_at__isnull=True,
pk=self.kwargs.get("issue_id"),
)
.first()
)

def get_queryset(self):
return (
IssueLink.objects.filter(workspace__slug=self.kwargs.get("slug"))
Expand Down Expand Up @@ -1159,6 +1171,9 @@ def get(self, request, slug, project_id, issue_id):

Retrieve all links associated with a work item.
"""
if self.get_scoped_issue() is None:
return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND)

return self.paginate(
request=request,
queryset=(self.get_queryset()),
Expand Down Expand Up @@ -1193,13 +1208,19 @@ def post(self, request, slug, project_id, issue_id):
Add a new external link to a work item with URL, title, and metadata.
Automatically tracks link creation activity.
"""
issue = self.get_scoped_issue()
if issue is None:
return Response({"error": "Issue not found"}, status=status.HTTP_404_NOT_FOUND)

serializer = IssueLinkCreateSerializer(data=request.data)
if serializer.is_valid():
serializer.save(project_id=project_id, issue_id=issue_id)
serializer.save(
project=issue.project,
issue_id=issue.id,
created_by_id=request.user.id,
)
crawl_work_item_link_title.delay(serializer.instance.id, serializer.instance.url)
link = IssueLink.objects.get(pk=serializer.instance.id)
link.created_by_id = request.data.get("created_by", request.user.id)
link.save(update_fields=["created_by"])
link = serializer.instance
issue_activity.delay(
type="link.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
Expand Down
Loading