From d55b51f40a819f8b42f8cb4bb132de99412cc84b Mon Sep 17 00:00:00 2001 From: Crewlet Date: Sat, 11 Jul 2026 19:37:55 +0000 Subject: [PATCH] feat(api): expose work item subscribers on the public v1 API Add token-authenticated endpoints to list, add, and remove the subscribers (watchers) of a work item on the public /api/v1 API, mirroring the internal IssueSubscriberViewSet but following the public API's conventions (APIKeyAuthentication, the public BaseAPIView, cursor pagination, and the ProjectEntityPermission used by sibling public issue endpoints). Endpoints (under the work-items prefix): GET .../work-items/{issue_id}/subscribers/ list subscribers POST .../work-items/{issue_id}/subscribers/ add by subscriber_id DELETE .../work-items/{issue_id}/subscribers/{user_id}/ remove by user id - IssueSubscriberSerializer / IssueSubscriberCreateSerializer reuse the IssueSubscriber through-model and embed the subscriber user as `member`. - New BaseAPIView endpoints enforce project membership, validate the target user is an active project member, reject duplicates, and 404 cleanly. - Routes registered in the work-items URL module; an OpenAPI decorator and path parameter are added so the endpoints appear in the generated schema. - Contract tests mirror the existing public-API issue tests (10 cases). Signed-off-by: Seena Fallah --- apps/api/plane/api/serializers/__init__.py | 2 + apps/api/plane/api/serializers/issue.py | 62 +++++ apps/api/plane/api/urls/work_item.py | 12 + apps/api/plane/api/views/__init__.py | 2 + apps/api/plane/api/views/issue.py | 184 ++++++++++++++ .../tests/contract/api/test_subscribers.py | 231 ++++++++++++++++++ apps/api/plane/utils/openapi/__init__.py | 4 + apps/api/plane/utils/openapi/decorators.py | 19 +- apps/api/plane/utils/openapi/parameters.py | 15 ++ 9 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 apps/api/plane/tests/contract/api/test_subscribers.py diff --git a/apps/api/plane/api/serializers/__init__.py b/apps/api/plane/api/serializers/__init__.py index 2ab639d5466..6cfdbd42057 100644 --- a/apps/api/plane/api/serializers/__init__.py +++ b/apps/api/plane/api/serializers/__init__.py @@ -28,6 +28,8 @@ IssueRelationCreateSerializer, IssueRelationResponseSerializer, IssueRelationSerializer, + IssueSubscriberSerializer, + IssueSubscriberCreateSerializer, RelatedIssueSerializer, ) from .state import StateLiteSerializer, StateSerializer diff --git a/apps/api/plane/api/serializers/issue.py b/apps/api/plane/api/serializers/issue.py index 5a771f2ad5c..7d650013fee 100644 --- a/apps/api/plane/api/serializers/issue.py +++ b/apps/api/plane/api/serializers/issue.py @@ -21,6 +21,7 @@ IssueLabel, IssueLink, IssueRelation, + IssueSubscriber, Label, ProjectMember, State, @@ -480,6 +481,67 @@ class Meta: ] +class IssueSubscriberSerializer(BaseSerializer): + """ + Serializer for work item subscribers (watchers). + + Reuses the ``IssueSubscriber`` through-model to expose who is watching a + work item on the public API. ``subscriber`` is the subscriber's user id and + ``member`` embeds that user's lightweight profile. + """ + + member = UserLiteSerializer(source="subscriber", read_only=True) + + def to_representation(self, instance): + # ``member`` already embeds the subscriber's UserLite profile via + # ``source="subscriber"``. It is not a real relation on IssueSubscriber, + # so the generic ``expand`` handling in BaseSerializer would treat an + # ``expand=member`` request as an unknown relation and overwrite the + # embedded profile with ``instance.member_id`` (which does not exist → + # ``None``). Exclude ``member`` from that fallback so the profile is + # preserved, while leaving every other expandable field untouched. + original_expand = self.expand + if original_expand: + self.expand = [field for field in original_expand if field != "member"] + try: + return super().to_representation(instance) + finally: + self.expand = original_expand + + class Meta: + model = IssueSubscriber + fields = [ + "id", + "subscriber", + "member", + "issue", + "project", + "workspace", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + read_only_fields = [ + "id", + "subscriber", + "member", + "issue", + "project", + "workspace", + "created_at", + "updated_at", + "created_by", + "updated_by", + ] + + +class IssueSubscriberCreateSerializer(serializers.Serializer): + """Request payload for adding a subscriber (watcher) to a work item.""" + + subscriber_id = serializers.UUIDField(help_text="User ID of the subscriber (watcher) to add to the work item") + + class IssueRelationRefSerializer(serializers.Serializer): """Project-scoped reference to a related work item.""" diff --git a/apps/api/plane/api/urls/work_item.py b/apps/api/plane/api/urls/work_item.py index 1a1704f2773..5a062cad84e 100644 --- a/apps/api/plane/api/urls/work_item.py +++ b/apps/api/plane/api/urls/work_item.py @@ -18,6 +18,8 @@ WorkspaceIssueAPIEndpoint, IssueSearchEndpoint, IssueRelationListCreateAPIEndpoint, + IssueSubscriberListCreateAPIEndpoint, + IssueSubscriberDetailAPIEndpoint, ) # Deprecated url patterns @@ -151,6 +153,16 @@ IssueRelationListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), name="work-item-relation-list", ), + path( + "workspaces//projects//work-items//subscribers/", + IssueSubscriberListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]), + name="work-item-subscriber-list", + ), + path( + "workspaces//projects//work-items//subscribers//", + IssueSubscriberDetailAPIEndpoint.as_view(http_method_names=["delete"]), + name="work-item-subscriber-detail", + ), ] urlpatterns = old_url_patterns + new_url_patterns diff --git a/apps/api/plane/api/views/__init__.py b/apps/api/plane/api/views/__init__.py index e8549afb437..8e9c954fa0d 100644 --- a/apps/api/plane/api/views/__init__.py +++ b/apps/api/plane/api/views/__init__.py @@ -30,6 +30,8 @@ IssueAttachmentDetailAPIEndpoint, IssueSearchEndpoint, IssueRelationListCreateAPIEndpoint, + IssueSubscriberListCreateAPIEndpoint, + IssueSubscriberDetailAPIEndpoint, ) from .cycle import ( diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index 9224ffb4db4..667d2b8184e 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -49,6 +49,8 @@ IssueRelationCreateSerializer, IssueRelationResponseSerializer, IssueRelationSerializer, + IssueSubscriberSerializer, + IssueSubscriberCreateSerializer, IssueSerializer, LabelSerializer, IssueAttachmentUploadSerializer, @@ -72,6 +74,7 @@ IssueComment, IssueLink, IssueRelation, + IssueSubscriber, Label, Project, ProjectMember, @@ -94,6 +97,7 @@ from plane.utils.openapi import ( work_item_docs, work_item_relation_docs, + work_item_subscriber_docs, label_docs, issue_link_docs, issue_comment_docs, @@ -108,6 +112,7 @@ COMMENT_ID_PARAMETER, LINK_ID_PARAMETER, ATTACHMENT_ID_PARAMETER, + SUBSCRIBER_ID_PARAMETER, ACTIVITY_ID_PARAMETER, PROJECT_ID_QUERY_PARAMETER, CURSOR_PARAMETER, @@ -2573,3 +2578,182 @@ def post(self, request, slug, project_id, issue_id): serializer_class(refetched_relations, many=True).data, status=status.HTTP_201_CREATED, ) + + +class IssueSubscriberListCreateAPIEndpoint(BaseAPIView): + """Work Item Subscriber (watcher) List and Create Endpoint""" + + serializer_class = IssueSubscriberSerializer + model = IssueSubscriber + permission_classes = [ProjectEntityPermission] + use_read_replica = True + + def get_queryset(self): + return ( + IssueSubscriber.objects.filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter( + project__project_projectmember__member=self.request.user, + project__project_projectmember__is_active=True, + ) + .filter(project__archived_at__isnull=True) + .select_related("subscriber") + .order_by(self.kwargs.get("order_by", "-created_at")) + .distinct() + ) + + @work_item_subscriber_docs( + operation_id="list_work_item_subscribers", + summary="List work item subscribers", + description="Retrieve all users subscribed to (watching) a work item.", + parameters=[ + ISSUE_ID_PARAMETER, + CURSOR_PARAMETER, + PER_PAGE_PARAMETER, + FIELDS_PARAMETER, + EXPAND_PARAMETER, + ], + responses={ + 200: create_paginated_response( + IssueSubscriberSerializer, + "PaginatedIssueSubscriberResponse", + "Paginated list of work item subscribers", + "Paginated Work Item Subscribers", + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ISSUE_NOT_FOUND_RESPONSE, + }, + ) + def get(self, request, slug, project_id, issue_id): + """List work item subscribers + + Retrieve all users subscribed to (watching) a work item. Each entry + embeds the subscriber's user profile under ``member``. + """ + # The work item must exist within this project/workspace, so a bogus + # issue id returns a 404 rather than a misleading empty list (matches + # post()'s behavior and the documented ISSUE_NOT_FOUND_RESPONSE). + if not Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id, pk=issue_id).exists(): + return Response( + {"error": "The requested resource does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + return self.paginate( + request=request, + queryset=(self.get_queryset()), + on_results=lambda issue_subscribers: ( + IssueSubscriberSerializer(issue_subscribers, many=True, fields=self.fields, expand=self.expand).data + ), + ) + + @work_item_subscriber_docs( + operation_id="add_work_item_subscriber", + summary="Add work item subscriber", + description="Subscribe a workspace/project member to a work item so they watch its updates.", + parameters=[ + ISSUE_ID_PARAMETER, + ], + request=OpenApiRequest( + request=IssueSubscriberCreateSerializer, + examples=[ + OpenApiExample( + name="Add subscriber", + value={"subscriber_id": "550e8400-e29b-41d4-a716-446655440000"}, + ) + ], + ), + responses={ + 201: OpenApiResponse( + description="Work item subscriber added successfully", + response=IssueSubscriberSerializer, + ), + 400: INVALID_REQUEST_RESPONSE, + 404: ISSUE_NOT_FOUND_RESPONSE, + }, + ) + def post(self, request, slug, project_id, issue_id): + """Add a work item subscriber + + Subscribe a project member to a work item by their user id. The target + user must be an active member of the project. Duplicate subscriptions + are rejected. + """ + serializer = IssueSubscriberCreateSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + subscriber_id = serializer.validated_data["subscriber_id"] + + # The work item must exist within this project/workspace + if not Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id, pk=issue_id).exists(): + return Response( + {"error": "The requested resource does not exist."}, + status=status.HTTP_404_NOT_FOUND, + ) + + # The target user must be an active member of the project + if not ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + member_id=subscriber_id, + is_active=True, + ).exists(): + return Response( + {"error": "The user is not a member of the project."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Reject duplicate subscriptions cleanly + if IssueSubscriber.objects.filter( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + subscriber_id=subscriber_id, + ).exists(): + return Response( + {"error": "User already subscribed to the work item."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + issue_subscriber = IssueSubscriber.objects.create( + issue_id=issue_id, + subscriber_id=subscriber_id, + project_id=project_id, + ) + serializer = IssueSubscriberSerializer(issue_subscriber) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +class IssueSubscriberDetailAPIEndpoint(BaseAPIView): + """Work Item Subscriber (watcher) Detail Endpoint""" + + serializer_class = IssueSubscriberSerializer + model = IssueSubscriber + permission_classes = [ProjectEntityPermission] + + @work_item_subscriber_docs( + operation_id="remove_work_item_subscriber", + summary="Remove work item subscriber", + description="Unsubscribe a user from a work item by their user id.", + parameters=[ + ISSUE_ID_PARAMETER, + SUBSCRIBER_ID_PARAMETER, + ], + responses={ + 204: OpenApiResponse(description="Work item subscriber removed successfully"), + 404: OpenApiResponse(description="Work item subscriber not found"), + }, + ) + def delete(self, request, slug, project_id, issue_id, subscriber_id): + """Remove a work item subscriber + + Unsubscribe a user from a work item by their user id. + """ + issue_subscriber = IssueSubscriber.objects.get( + workspace__slug=slug, + project_id=project_id, + issue_id=issue_id, + subscriber_id=subscriber_id, + ) + issue_subscriber.delete() + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apps/api/plane/tests/contract/api/test_subscribers.py b/apps/api/plane/tests/contract/api/test_subscribers.py new file mode 100644 index 00000000000..7d35ce4d361 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_subscribers.py @@ -0,0 +1,231 @@ +# 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 ( + Issue, + IssueSubscriber, + Project, + ProjectMember, + State, + User, + WorkspaceMember, +) + + +@pytest.fixture +def project(db, workspace, create_user): + """Create a test project with the requesting user as an active 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 + is_active=True, + ) + return project + + +@pytest.fixture +def state(db, workspace, project): + return State.objects.create( + name="Todo", + project=project, + workspace=workspace, + group="backlog", + default=True, + ) + + +@pytest.fixture +def issue(db, workspace, project, state, create_user): + return Issue.objects.create( + name="Test Issue", + workspace=workspace, + project=project, + state=state, + created_by=create_user, + ) + + +@pytest.fixture +def member_user(db, workspace, project): + """A second user who is an active member of the workspace and project.""" + user = User.objects.create( + email="member@plane.so", + username="subscriber-member", + first_name="Member", + last_name="User", + ) + WorkspaceMember.objects.create(workspace=workspace, member=user, role=15) + ProjectMember.objects.create(project=project, member=user, role=15, is_active=True) + return user + + +@pytest.fixture +def non_member_user(db): + """A user who is not a member of the workspace/project.""" + return User.objects.create( + email="outsider@plane.so", + username="subscriber-outsider", + first_name="Out", + last_name="Sider", + ) + + +@pytest.mark.contract +class TestIssueSubscriberListCreateAPIEndpoint: + """Test the public work-item subscriber (watcher) list/create endpoint.""" + + def get_url(self, workspace_slug, project_id, issue_id): + return f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}/work-items/{issue_id}/subscribers/" + + @pytest.mark.django_db + def test_list_subscribers_empty(self, api_key_client, workspace, project, issue): + """A work item with no subscribers returns an empty paginated list.""" + url = self.get_url(workspace.slug, project.id, issue.id) + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert "results" in response.data + assert response.data["results"] == [] + + @pytest.mark.django_db + def test_list_subscribers_issue_not_found(self, api_key_client, workspace, project): + """Listing subscribers of a non-existent work item returns a 404, + not a misleading empty list (mirrors add/remove).""" + url = self.get_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_list_subscribers_returns_subscribers(self, api_key_client, workspace, project, issue, member_user): + """Listing returns the subscribers with the embedded user under ``member``.""" + IssueSubscriber.objects.create(issue=issue, subscriber=member_user, project=project) + + url = self.get_url(workspace.slug, project.id, issue.id) + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 1 + result = response.data["results"][0] + assert str(result["subscriber"]) == str(member_user.id) + assert str(result["member"]["id"]) == str(member_user.id) + assert result["member"]["email"] == member_user.email + + @pytest.mark.django_db + def test_list_subscribers_expand_member_preserved(self, api_key_client, workspace, project, issue, member_user): + """``expand=member`` must keep the embedded subscriber profile. + + ``member`` is not a real relation on IssueSubscriber, so the generic + expansion fallback would otherwise overwrite it with a non-existent + ``member_id`` (``None``). It must stay the UserLite profile. + """ + IssueSubscriber.objects.create(issue=issue, subscriber=member_user, project=project) + + url = self.get_url(workspace.slug, project.id, issue.id) + response = api_key_client.get(url, {"expand": "member"}) + + assert response.status_code == status.HTTP_200_OK + result = response.data["results"][0] + assert result["member"] is not None + assert str(result["member"]["id"]) == str(member_user.id) + assert result["member"]["email"] == member_user.email + + @pytest.mark.django_db + def test_add_subscriber_success(self, api_key_client, workspace, project, issue, member_user): + """A project member can be subscribed by user id.""" + url = self.get_url(workspace.slug, project.id, issue.id) + response = api_key_client.post(url, {"subscriber_id": str(member_user.id)}, format="json") + + assert response.status_code == status.HTTP_201_CREATED + assert str(response.data["subscriber"]) == str(member_user.id) + assert IssueSubscriber.objects.filter(issue=issue, subscriber=member_user).exists() + + @pytest.mark.django_db + def test_add_subscriber_missing_id(self, api_key_client, workspace, project, issue): + """Missing subscriber_id returns a 400 with field errors.""" + url = self.get_url(workspace.slug, project.id, issue.id) + response = api_key_client.post(url, {}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.django_db + def test_add_subscriber_non_member(self, api_key_client, workspace, project, issue, non_member_user): + """A user who is not a project member cannot be subscribed.""" + url = self.get_url(workspace.slug, project.id, issue.id) + response = api_key_client.post(url, {"subscriber_id": str(non_member_user.id)}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert not IssueSubscriber.objects.filter(issue=issue, subscriber=non_member_user).exists() + + @pytest.mark.django_db + def test_add_subscriber_duplicate(self, api_key_client, workspace, project, issue, member_user): + """Subscribing the same user twice is rejected cleanly.""" + IssueSubscriber.objects.create(issue=issue, subscriber=member_user, project=project) + + url = self.get_url(workspace.slug, project.id, issue.id) + response = api_key_client.post(url, {"subscriber_id": str(member_user.id)}, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert IssueSubscriber.objects.filter(issue=issue, subscriber=member_user).count() == 1 + + @pytest.mark.django_db + def test_add_subscriber_issue_not_found(self, api_key_client, workspace, project, member_user): + """Subscribing on a non-existent work item returns a 404.""" + url = self.get_url(workspace.slug, project.id, uuid4()) + response = api_key_client.post(url, {"subscriber_id": str(member_user.id)}, format="json") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @pytest.mark.django_db + def test_list_requires_authentication(self, api_client, workspace, project, issue): + """Without an API key the endpoint rejects the request.""" + url = self.get_url(workspace.slug, project.id, issue.id) + response = api_client.get(url) + + assert response.status_code in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ) + + +@pytest.mark.contract +class TestIssueSubscriberDetailAPIEndpoint: + """Test the public work-item subscriber (watcher) delete endpoint.""" + + def get_url(self, workspace_slug, project_id, issue_id, subscriber_id): + return ( + f"/api/v1/workspaces/{workspace_slug}/projects/{project_id}" + f"/work-items/{issue_id}/subscribers/{subscriber_id}/" + ) + + @pytest.mark.django_db + def test_remove_subscriber_success(self, api_key_client, workspace, project, issue, member_user): + """A subscriber can be removed by user id.""" + IssueSubscriber.objects.create(issue=issue, subscriber=member_user, project=project) + + url = self.get_url(workspace.slug, project.id, issue.id, member_user.id) + response = api_key_client.delete(url) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not IssueSubscriber.objects.filter(issue=issue, subscriber=member_user).exists() + + @pytest.mark.django_db + def test_remove_subscriber_not_found(self, api_key_client, workspace, project, issue, member_user): + """Removing a non-existent subscription returns a 404.""" + url = self.get_url(workspace.slug, project.id, issue.id, member_user.id) + response = api_key_client.delete(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND diff --git a/apps/api/plane/utils/openapi/__init__.py b/apps/api/plane/utils/openapi/__init__.py index 090d076ecd1..a17ee24555b 100644 --- a/apps/api/plane/utils/openapi/__init__.py +++ b/apps/api/plane/utils/openapi/__init__.py @@ -33,6 +33,7 @@ COMMENT_ID_PARAMETER, LINK_ID_PARAMETER, ATTACHMENT_ID_PARAMETER, + SUBSCRIBER_ID_PARAMETER, ACTIVITY_ID_PARAMETER, CURSOR_PARAMETER, PER_PAGE_PARAMETER, @@ -165,6 +166,7 @@ cycle_docs, work_item_docs, work_item_relation_docs, + work_item_subscriber_docs, label_docs, issue_link_docs, issue_comment_docs, @@ -202,6 +204,7 @@ "COMMENT_ID_PARAMETER", "LINK_ID_PARAMETER", "ATTACHMENT_ID_PARAMETER", + "SUBSCRIBER_ID_PARAMETER", "ACTIVITY_ID_PARAMETER", "CURSOR_PARAMETER", "PER_PAGE_PARAMETER", @@ -325,6 +328,7 @@ "cycle_docs", "work_item_docs", "work_item_relation_docs", + "work_item_subscriber_docs", "label_docs", "issue_link_docs", "issue_comment_docs", diff --git a/apps/api/plane/utils/openapi/decorators.py b/apps/api/plane/utils/openapi/decorators.py index 7ded9fb10b3..150dedf852c 100644 --- a/apps/api/plane/utils/openapi/decorators.py +++ b/apps/api/plane/utils/openapi/decorators.py @@ -238,6 +238,21 @@ def work_item_relation_docs(**kwargs): return extend_schema(**_merge_schema_options(defaults, kwargs)) +def work_item_subscriber_docs(**kwargs): + """Decorator for work item subscriber (watcher) endpoints""" + defaults = { + "tags": ["Work Item Subscribers"], + "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 module_docs(**kwargs): """Decorator for module management endpoints""" defaults = { @@ -298,6 +313,7 @@ def sticky_docs(**kwargs): return extend_schema(**_merge_schema_options(defaults, kwargs)) + def estimate_docs(**kwargs): """Decorator for estimate-related endpoints""" defaults = { @@ -311,6 +327,7 @@ def estimate_docs(**kwargs): } return extend_schema(**_merge_schema_options(defaults, kwargs)) + def estimate_point_docs(**kwargs): """Decorator for estimate point-related endpoints""" defaults = { @@ -322,4 +339,4 @@ def estimate_point_docs(**kwargs): 404: NOT_FOUND_RESPONSE, }, } - return extend_schema(**_merge_schema_options(defaults, kwargs)) \ No newline at end of file + return extend_schema(**_merge_schema_options(defaults, kwargs)) diff --git a/apps/api/plane/utils/openapi/parameters.py b/apps/api/plane/utils/openapi/parameters.py index 2812892ec91..6cca2fe0a66 100644 --- a/apps/api/plane/utils/openapi/parameters.py +++ b/apps/api/plane/utils/openapi/parameters.py @@ -240,6 +240,21 @@ ], ) +SUBSCRIBER_ID_PARAMETER = OpenApiParameter( + name="subscriber_id", + description="Subscriber (watcher) user ID", + required=True, + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + examples=[ + OpenApiExample( + name="Example subscriber ID", + value="550e8400-e29b-41d4-a716-446655440000", + description="A typical subscriber user UUID", + ) + ], +) + ACTIVITY_ID_PARAMETER = OpenApiParameter( name="pk", description="Activity ID",