Skip to content
Merged
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
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 @@ -15,6 +15,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 .subscriber import urlpatterns as subscriber_patterns

urlpatterns = [
*asset_patterns,
Expand All @@ -30,4 +31,5 @@
*work_item_patterns,
*invite_patterns,
*sticky_patterns,
*subscriber_patterns,
]
20 changes: 20 additions & 0 deletions apps/api/plane/api/urls/subscriber.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 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 WorkItemSubscriberAPIEndpoint

urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/subscribers/",
WorkItemSubscriberAPIEndpoint.as_view(http_method_names=["get", "post"]),
name="work-item-subscribers",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/work-items/<uuid:issue_id>/subscribers/<uuid:subscriber_id>/",
WorkItemSubscriberAPIEndpoint.as_view(http_method_names=["delete"]),
name="work-item-subscriber",
),
]
2 changes: 2 additions & 0 deletions apps/api/plane/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@

from .sticky import StickyViewSet

from .subscriber import WorkItemSubscriberAPIEndpoint

from .release import (
ReleaseCandidateAPIEndpoint,
ReleaseChangelogAPIEndpoint,
Expand Down
158 changes: 158 additions & 0 deletions apps/api/plane/api/views/subscriber.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# 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 status
from rest_framework.response import Response
from drf_spectacular.utils import OpenApiResponse, extend_schema

# Module imports
from plane.api.views.base import BaseAPIView
from plane.db.models import Issue, IssueSubscriber, ProjectMember, User
from plane.utils.openapi import (
FORBIDDEN_RESPONSE,
PROJECT_ID_PARAMETER,
PROJECT_NOT_FOUND_RESPONSE,
UNAUTHORIZED_RESPONSE,
WORKSPACE_SLUG_PARAMETER,
)
from plane.utils.permissions import ProjectAdminPermission, ProjectEntityPermission


class WorkItemSubscriberAPIEndpoint(BaseAPIView):
"""Manage who is notified about a work item, over the API-key surface.

Upstream exposes issue subscribers only on the session-authenticated app API
(`/api/workspaces/.../issues/<id>/issue-subscribers/`), where `subscribe`
can only ever act on `request.user`. That is unusable for an integration:
an API token acts as its own bot user, so there is no way for an external
system to say "notify THIS person about THIS work item".

We need exactly that. Bug reports filed from the Psyclo apps land in Intake
as work items owned by the token user, and a reporter who ticked "allow
Psyclopedia to contact me" has to receive Plane's own comment notification
email so the thread can be two-way. See the notification recipient filter in
`plane/bgtasks/notification_task.py` -- a subscriber is only mailed if they
are ALSO an active `ProjectMember`, which is why that is validated here
rather than left to fail silently later.
"""

permission_classes = [ProjectEntityPermission]

def get_permissions(self):
# Reading who is subscribed is ordinary project-entity access; changing
# someone else's notifications is not, so writes require project admin.
if self.request.method == "GET":
return [ProjectEntityPermission()]
return [ProjectAdminPermission()]

@extend_schema(
operation_id="get_work_item_subscribers",
summary="List work item subscribers",
description="Retrieve the users subscribed to notifications for a work item.",
tags=["Work Items"],
parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
responses={
200: OpenApiResponse(description="List of subscriber user ids"),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: PROJECT_NOT_FOUND_RESPONSE,
},
)
def get(self, request, slug, project_id, issue_id):
subscriber_ids = IssueSubscriber.objects.filter(
workspace__slug=slug, project_id=project_id, issue_id=issue_id
).values_list("subscriber_id", flat=True)
return Response(
[str(subscriber_id) for subscriber_id in subscriber_ids],
status=status.HTTP_200_OK,
)

@extend_schema(
operation_id="create_work_item_subscriber",
summary="Subscribe a user to a work item",
description=(
"Subscribe a user to notifications for a work item. Accepts either "
"`subscriber` (a user id) or `email`. The user must already be an "
"active member of the project, otherwise Plane will not send them "
"notifications."
),
tags=["Work Items"],
parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
responses={
201: OpenApiResponse(description="Subscriber created"),
200: OpenApiResponse(description="Subscriber already existed"),
400: OpenApiResponse(description="Invalid subscriber"),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: PROJECT_NOT_FOUND_RESPONSE,
},
)
def post(self, request, slug, project_id, issue_id):
issue = Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=issue_id).first()
if issue is None:
return Response({"error": "Work item does not exist"}, status=status.HTTP_404_NOT_FOUND)

# `email` is accepted alongside `subscriber` because the calling system
# generally knows the person by address, not by their Plane user id, and
# would otherwise have to page the whole member list to translate it.
subscriber_id = request.data.get("subscriber")
email = request.data.get("email")
if subscriber_id:
user = User.objects.filter(pk=subscriber_id).first()
elif email:
user = User.objects.filter(email__iexact=str(email).strip()).first()
else:
return Response(
{"error": "Either subscriber or email is required"},
status=status.HTTP_400_BAD_REQUEST,
)

if user is None:
return Response({"error": "User does not exist"}, status=status.HTTP_400_BAD_REQUEST)

# Enforced here, not merely documented: a subscriber who is not an
# active project member is dropped by the notification task's recipient
# filter, so accepting one would create a row that looks correct and
# silently never delivers anything.
if not ProjectMember.objects.filter(project_id=project_id, member=user, is_active=True).exists():
return Response(
{"error": "User is not an active member of this project"},
status=status.HTTP_400_BAD_REQUEST,
)

_, created = IssueSubscriber.objects.get_or_create(
issue_id=issue_id,
subscriber=user,
defaults={"project_id": project_id, "workspace_id": issue.workspace_id},
)
return Response(
{"subscriber": str(user.id), "created": created},
status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
)

@extend_schema(
operation_id="delete_work_item_subscriber",
summary="Unsubscribe a user from a work item",
description="Remove a user's subscription to a work item's notifications.",
tags=["Work Items"],
parameters=[WORKSPACE_SLUG_PARAMETER, PROJECT_ID_PARAMETER],
responses={
204: OpenApiResponse(description="Subscriber removed"),
401: UNAUTHORIZED_RESPONSE,
403: FORBIDDEN_RESPONSE,
404: PROJECT_NOT_FOUND_RESPONSE,
},
)
def delete(self, request, slug, project_id, issue_id, subscriber_id):
subscription = IssueSubscriber.objects.filter(
workspace__slug=slug,
project_id=project_id,
issue_id=issue_id,
subscriber_id=subscriber_id,
).first()
if subscription is None:
return Response({"error": "Subscription does not exist"}, status=status.HTTP_404_NOT_FOUND)
subscription.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
17 changes: 0 additions & 17 deletions apps/web/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url";
import faviconIco from "@/app/assets/favicon/favicon.ico?url";
import icon180 from "@/app/assets/icons/icon-180x180.png?url";
import icon512 from "@/app/assets/icons/icon-512x512.png?url";
import ogImage from "@/app/assets/og-image.png?url";
import globalStyles from "@/styles/globals.css?url";
import type { Route } from "./+types/root";
// components
Expand Down Expand Up @@ -99,27 +98,11 @@ export function Layout({ children }: { children: ReactNode }) {
export const meta: Route.MetaFunction = () => [
{ title: APP_TITLE },
{ name: "description", content: SITE_DESCRIPTION },
{ property: "og:title", content: APP_TITLE },
{
property: "og:description",
content: "Open-source project management tool to manage work items, cycles, and product roadmaps easily",
},
{ property: "og:url", content: "https://app.plane.so/" },
{ property: "og:image", content: ogImage },
{ property: "og:image:width", content: "1200" },
{ property: "og:image:height", content: "630" },
{ property: "og:image:alt", content: "Plane - Modern project management" },
{
name: "keywords",
content:
"software development, plan, ship, software, accelerate, code management, release management, project management, work item tracking, agile, scrum, kanban, collaboration",
},
{ name: "twitter:site", content: "@planepowers" },
{ name: "twitter:card", content: "summary_large_image" },
{ name: "twitter:image", content: ogImage },
{ name: "twitter:image:width", content: "1200" },
{ name: "twitter:image:height", content: "630" },
{ name: "twitter:image:alt", content: "Plane - Modern project management" },
];

export default function Root() {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/core/components/dropdowns/module/base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export const ModuleDropdownBase = observer(function ModuleDropdownBase(props: TM
multiple,
onChange,
onClose,
onDropdownOpen,
placeholder = "",
placement,
projectId,
Expand Down Expand Up @@ -194,6 +195,7 @@ export const ModuleDropdownBase = observer(function ModuleDropdownBase(props: TM
getModuleById={getModuleById}
moduleIds={moduleIds}
value={value}
onDropdownOpen={onDropdownOpen}
/>
)}
</ComboDropDown>
Expand Down
Loading
Loading