From cea6f7530bfbecf857e3f460c7d726289f9f2759 Mon Sep 17 00:00:00 2001 From: Saurabh Kumar <70131915+Saurabhkmr98@users.noreply.github.com> Date: Mon, 1 Dec 2025 18:57:54 +0530 Subject: [PATCH] [SILO-671] feat: add sticky external apis (#8139) * add sticky external apis * add created_at sort by to list * remove select related method from query set --- apps/api/plane/api/serializers/__init__.py | 3 +- apps/api/plane/api/serializers/sticky.py | 30 ++++++ apps/api/plane/api/urls/__init__.py | 2 + apps/api/plane/api/urls/sticky.py | 12 +++ apps/api/plane/api/views/__init__.py | 4 +- apps/api/plane/api/views/sticky.py | 109 +++++++++++++++++++++ apps/api/plane/utils/openapi/__init__.py | 2 + apps/api/plane/utils/openapi/decorators.py | 15 +++ apps/api/plane/utils/openapi/examples.py | 17 ++++ 9 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 apps/api/plane/api/serializers/sticky.py create mode 100644 apps/api/plane/api/urls/sticky.py create mode 100644 apps/api/plane/api/views/sticky.py diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index b58b9fdcb..6525ddce6 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -54,4 +54,5 @@ from .asset import ( FileAssetSerializer, ) from .invite import WorkspaceInviteSerializer -from .member import ProjectMemberSerializer \ No newline at end of file +from .member import ProjectMemberSerializer +from .sticky import StickySerializer \ No newline at end of file diff --git a/apps/api/plane/api/serializers/sticky.py b/apps/api/plane/api/serializers/sticky.py new file mode 100644 index 000000000..067fc1b89 --- /dev/null +++ b/apps/api/plane/api/serializers/sticky.py @@ -0,0 +1,30 @@ +from rest_framework import serializers + +from .base import BaseSerializer +from plane.db.models import Sticky +from plane.utils.content_validator import validate_html_content, validate_binary_data + + +class StickySerializer(BaseSerializer): + class Meta: + model = Sticky + fields = "__all__" + read_only_fields = ["workspace", "owner"] + extra_kwargs = {"name": {"required": False}} + + def validate(self, data): + # Validate description content for security + if "description_html" in data and data["description_html"]: + is_valid, error_msg, sanitized_html = validate_html_content(data["description_html"]) + if not is_valid: + raise serializers.ValidationError({"error": "html content is not valid"}) + # Update the data with sanitized HTML if available + if sanitized_html is not None: + data["description_html"] = sanitized_html + + if "description_binary" in data and data["description_binary"]: + is_valid, error_msg = validate_binary_data(data["description_binary"]) + if not is_valid: + raise serializers.ValidationError({"description_binary": "Invalid binary data"}) + + return data diff --git a/apps/api/plane/api/urls/__init__.py b/apps/api/plane/api/urls/__init__.py index d239b6788..593501939 100644 --- a/apps/api/plane/api/urls/__init__.py +++ b/apps/api/plane/api/urls/__init__.py @@ -9,6 +9,7 @@ from .state import urlpatterns as state_patterns from .user import urlpatterns as user_patterns from .work_item import urlpatterns as work_item_patterns from .invite import urlpatterns as invite_patterns +from .sticky import urlpatterns as sticky_patterns urlpatterns = [ *asset_patterns, @@ -22,4 +23,5 @@ urlpatterns = [ *user_patterns, *work_item_patterns, *invite_patterns, + *sticky_patterns, ] diff --git a/apps/api/plane/api/urls/sticky.py b/apps/api/plane/api/urls/sticky.py new file mode 100644 index 000000000..0066e77ea --- /dev/null +++ b/apps/api/plane/api/urls/sticky.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from plane.api.views import StickyViewSet + + +router = DefaultRouter() +router.register(r"stickies", StickyViewSet, basename="workspace-stickies") + +urlpatterns = [ + path("workspaces//", include(router.urls)), +] diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index 280c23bc2..75b1b17c4 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -54,4 +54,6 @@ from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpo from .user import UserEndpoint -from .invite import WorkspaceInvitationsViewset \ No newline at end of file +from .invite import WorkspaceInvitationsViewset + +from .sticky import StickyViewSet diff --git a/apps/api/plane/api/views/sticky.py b/apps/api/plane/api/views/sticky.py new file mode 100644 index 000000000..a5173edc7 --- /dev/null +++ b/apps/api/plane/api/views/sticky.py @@ -0,0 +1,109 @@ +from rest_framework.response import Response +from rest_framework import status + +from plane.api.views.base import BaseViewSet +from plane.app.permissions import WorkspaceUserPermission +from plane.db.models import Sticky, Workspace +from plane.api.serializers import StickySerializer + +# OpenAPI imports +from plane.utils.openapi.decorators import sticky_docs + +from drf_spectacular.utils import OpenApiRequest, OpenApiResponse +from plane.utils.openapi import ( + STICKY_EXAMPLE, + create_paginated_response, + DELETED_RESPONSE, +) + + +class StickyViewSet(BaseViewSet): + serializer_class = StickySerializer + model = Sticky + use_read_replica = True + permission_classes = [WorkspaceUserPermission] + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(owner_id=self.request.user.id) + .distinct() + ) + + @sticky_docs( + operation_id="create_sticky", + summary="Create a new sticky", + description="Create a new sticky in the workspace", + request=OpenApiRequest(request=StickySerializer), + responses={ + 201: OpenApiResponse(description="Sticky created", response=StickySerializer, examples=[STICKY_EXAMPLE]) + }, + ) + def create(self, request, slug): + workspace = Workspace.objects.get(slug=slug) + serializer = StickySerializer(data=request.data) + if serializer.is_valid(): + serializer.save(workspace_id=workspace.id, owner_id=request.user.id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @sticky_docs( + operation_id="list_stickies", + summary="List stickies", + description="List all stickies in the workspace", + responses={ + 200: create_paginated_response( + StickySerializer, "Sticky", "List of stickies", example_name="List of stickies" + ) + }, + ) + def list(self, request, slug): + query = request.query_params.get("query", False) + stickies = self.get_queryset().order_by("-created_at") + if query: + stickies = stickies.filter(description_stripped__icontains=query) + + return self.paginate( + request=request, + queryset=(stickies), + on_results=lambda stickies: StickySerializer(stickies, many=True).data, + default_per_page=20, + ) + + @sticky_docs( + operation_id="retrieve_sticky", + summary="Retrieve a sticky", + description="Retrieve a sticky by its ID", + responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])}, + ) + def retrieve(self, request, slug, pk): + sticky = self.get_object() + return Response(StickySerializer(sticky).data) + + @sticky_docs( + operation_id="update_sticky", + summary="Update a sticky", + description="Update a sticky by its ID", + request=OpenApiRequest(request=StickySerializer), + responses={200: OpenApiResponse(description="Sticky", response=StickySerializer, examples=[STICKY_EXAMPLE])}, + ) + def partial_update(self, request, slug, pk): + sticky = self.get_object() + serializer = StickySerializer(sticky, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + @sticky_docs( + operation_id="delete_sticky", + summary="Delete a sticky", + description="Delete a sticky by its ID", + responses={204: DELETED_RESPONSE}, + ) + def destroy(self, request, slug, pk): + sticky = self.get_object() + sticky.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/utils/openapi/__init__.py b/apps/api/plane/utils/openapi/__init__.py index bf6821258..b2c9ba6b0 100644 --- a/apps/api/plane/utils/openapi/__init__.py +++ b/apps/api/plane/utils/openapi/__init__.py @@ -140,6 +140,7 @@ from .examples import ( WORKSPACE_MEMBER_EXAMPLE, PROJECT_MEMBER_EXAMPLE, CYCLE_ISSUE_EXAMPLE, + STICKY_EXAMPLE, ) # Helper decorators @@ -292,6 +293,7 @@ __all__ = [ "WORKSPACE_MEMBER_EXAMPLE", "PROJECT_MEMBER_EXAMPLE", "CYCLE_ISSUE_EXAMPLE", + "STICKY_EXAMPLE", # Decorators "workspace_docs", "project_docs", diff --git a/apps/api/plane/utils/openapi/decorators.py b/apps/api/plane/utils/openapi/decorators.py index e4a86839f..c1ba9612e 100644 --- a/apps/api/plane/utils/openapi/decorators.py +++ b/apps/api/plane/utils/openapi/decorators.py @@ -262,3 +262,18 @@ def state_docs(**kwargs): } return extend_schema(**_merge_schema_options(defaults, kwargs)) + +def sticky_docs(**kwargs): + """Decorator for sticky management endpoints""" + defaults = { + "tags": ["Stickies"], + "summary": "Endpoints for sticky create/update/delete and fetch sticky details", + "parameters": [WORKSPACE_SLUG_PARAMETER], + "responses": { + 401: UNAUTHORIZED_RESPONSE, + 403: FORBIDDEN_RESPONSE, + 404: NOT_FOUND_RESPONSE, + }, + } + + return extend_schema(**_merge_schema_options(defaults, kwargs)) \ No newline at end of file diff --git a/apps/api/plane/utils/openapi/examples.py b/apps/api/plane/utils/openapi/examples.py index db7ee50c4..f41bdddbc 100644 --- a/apps/api/plane/utils/openapi/examples.py +++ b/apps/api/plane/utils/openapi/examples.py @@ -672,6 +672,15 @@ CYCLE_ISSUE_EXAMPLE = OpenApiExample( }, ) +STICKY_EXAMPLE = OpenApiExample( + name="Sticky", + value={ + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Sticky 1", + "description_html": "

Sticky 1 description

", + "created_at": "2024-01-01T10:30:00Z", + }, +) # Sample data for different entity types SAMPLE_ISSUE = { @@ -781,6 +790,13 @@ SAMPLE_CYCLE_ISSUE = { "created_at": "2024-01-01T10:30:00Z", } +SAMPLE_STICKY = { + "id": "550e8400-e29b-41d4-a716-446655440000", + "name": "Sticky 1", + "description_html": "

Sticky 1 description

", + "created_at": "2024-01-01T10:30:00Z", +} + # Mapping of schema types to sample data SCHEMA_EXAMPLES = { "Issue": SAMPLE_ISSUE, @@ -795,6 +811,7 @@ SCHEMA_EXAMPLES = { "Activity": SAMPLE_ACTIVITY, "Intake": SAMPLE_INTAKE, "CycleIssue": SAMPLE_CYCLE_ISSUE, + "Sticky": SAMPLE_STICKY, }