ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: причина отклонения, reply и in-app уведомления

This commit is contained in:
DCCONSTRUCTIONS
2026-04-19 09:22:15 +03:00
parent 61a8625a5c
commit 0a584abf26
25 changed files with 623 additions and 32 deletions
@@ -56,6 +56,7 @@ from .intake import (
from .external_contours import (
ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer,
ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer,
@@ -29,6 +29,16 @@ class ExternalContourRequestCreateSerializer(serializers.Serializer):
class ExternalContourRequestDecisionSerializer(serializers.Serializer):
action = serializers.ChoiceField(choices=["accept", "decline"])
comment = serializers.CharField(required=False, allow_blank=True)
def validate(self, data):
if data.get("action") == "decline" and not (data.get("comment") or "").strip():
raise serializers.ValidationError({"comment": "Decline reason is required"})
return data
class ExternalContourRequestReplySerializer(serializers.Serializer):
comment = serializers.CharField()
class ExternalContourTargetProjectSerializer(BaseSerializer):
@@ -4,44 +4,51 @@
from django.urls import path
from plane.api.views import (
ExternalContourAttachmentDownloadAPIEndpoint,
ExternalContourDetailAPIEndpoint,
ExternalContourDecisionAPIEndpoint,
ExternalContourListCreateAPIEndpoint,
ExternalContourTargetOptionsAPIEndpoint,
ExternalContourTargetProjectListAPIEndpoint,
from plane.app.views import (
ExternalContourAttachmentDownloadEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourListCreateEndpoint,
ExternalContourReplyEndpoint,
ExternalContourTargetOptionsEndpoint,
ExternalContourTargetProjectListEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/",
ExternalContourListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]),
name="external-contours",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/targets/",
ExternalContourTargetProjectListAPIEndpoint.as_view(http_method_names=["get"]),
ExternalContourTargetProjectListEndpoint.as_view(http_method_names=["get"]),
name="external-contour-targets",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/targets/<uuid:target_project_id>/options/",
ExternalContourTargetOptionsAPIEndpoint.as_view(http_method_names=["get"]),
ExternalContourTargetOptionsEndpoint.as_view(http_method_names=["get"]),
name="external-contour-target-options",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/",
ExternalContourDetailAPIEndpoint.as_view(http_method_names=["get"]),
ExternalContourDetailEndpoint.as_view(http_method_names=["get"]),
name="external-contour-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/decision/",
ExternalContourDecisionAPIEndpoint.as_view(http_method_names=["post"]),
ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]),
name="external-contour-decision",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/reply/",
ExternalContourReplyEndpoint.as_view(http_method_names=["post"]),
name="external-contour-reply",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/attachments/<uuid:attachment_id>/",
ExternalContourAttachmentDownloadAPIEndpoint.as_view(http_method_names=["get"]),
ExternalContourAttachmentDownloadEndpoint.as_view(http_method_names=["get"]),
name="external-contour-attachment-download",
),
]
@@ -60,6 +60,7 @@ from .external_contours import (
ExternalContourListCreateEndpoint as ExternalContourListCreateAPIEndpoint,
ExternalContourDetailEndpoint as ExternalContourDetailAPIEndpoint,
ExternalContourDecisionEndpoint as ExternalContourDecisionAPIEndpoint,
ExternalContourReplyEndpoint as ExternalContourReplyAPIEndpoint,
ExternalContourTargetProjectListEndpoint as ExternalContourTargetProjectListAPIEndpoint,
ExternalContourTargetOptionsEndpoint as ExternalContourTargetOptionsAPIEndpoint,
)
@@ -5,22 +5,26 @@
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from rest_framework import status
from rest_framework.response import Response
from plane.utils.host import base_host
from plane.api.serializers import (
ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer,
ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer,
)
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission
from .base import BaseAPIView
from plane.app.views.base import BaseAPIView
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType
from plane.settings.storage import S3Storage
from plane.utils.external_contours import create_external_contour_issue_comment
class ExternalContourListCreateEndpoint(BaseAPIView):
@@ -309,6 +313,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
serializer.is_valid(raise_exception=True)
action = serializer.validated_data["action"]
comment = (serializer.validated_data.get("comment") or "").strip()
issue = contour_request.issue
if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
@@ -337,6 +342,16 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
if not target_default_state:
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
try:
create_external_contour_issue_comment(
issue=issue,
actor=request.user,
comment=comment,
origin=base_host(request=request, is_app=True),
)
except ValidationError as exc:
return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)
issue.state = target_default_state
issue.save(update_fields=["state", "updated_at"])
@@ -344,6 +359,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
extra.pop("source_decision", None)
extra.pop("source_decision_at", None)
extra.pop("source_decision_by_name", None)
extra["last_decline_comment"] = comment
extra["last_reopened_at"] = issue.updated_at.isoformat() if issue.updated_at else None
extra["last_reopened_by_name"] = request.user.display_name
contour_request.extra = extra
@@ -368,6 +384,58 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourReplyEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer
def get_queryset(self):
return (
IntakeIssue.objects.filter(
workspace__slug=self.kwargs.get("slug"),
extra__bridge="external-contours",
extra__source_project_id=str(self.kwargs.get("project_id")),
pk=self.kwargs.get("request_id"),
)
.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
)
def post(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestReplySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
issue = contour_request.issue
if not issue:
return Response({"error": "Target issue was not found"}, status=status.HTTP_404_NOT_FOUND)
try:
create_external_contour_issue_comment(
issue=issue,
actor=request.user,
comment=serializer.validated_data["comment"],
origin=base_host(request=request, is_app=True),
)
except ValidationError as exc:
return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)
contour_request.refresh_from_db()
response_serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
},
)
return Response(response_serializer.data, status=status.HTTP_200_OK)
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
@@ -15,6 +15,7 @@ class NotificationSerializer(BaseSerializer):
triggered_by_details = UserLiteSerializer(read_only=True, source="triggered_by")
is_inbox_issue = serializers.BooleanField(read_only=True)
is_intake_issue = serializers.BooleanField(read_only=True)
is_external_contour = serializers.BooleanField(read_only=True)
is_mentioned_notification = serializers.BooleanField(read_only=True)
class Meta:
@@ -9,6 +9,7 @@ from plane.app.views import (
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourListCreateEndpoint,
ExternalContourReplyEndpoint,
ExternalContourTargetOptionsEndpoint,
ExternalContourTargetProjectListEndpoint,
)
@@ -40,6 +41,11 @@ urlpatterns = [
ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]),
name="external-contour-decision",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/reply/",
ExternalContourReplyEndpoint.as_view(http_method_names=["post"]),
name="external-contour-reply",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/attachments/<uuid:attachment_id>/",
ExternalContourAttachmentDownloadEndpoint.as_view(http_method_names=["get"]),
@@ -229,6 +229,7 @@ from .external_contours import (
ExternalContourListCreateEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourReplyEndpoint,
ExternalContourTargetProjectListEndpoint,
ExternalContourTargetOptionsEndpoint,
)
@@ -5,12 +5,15 @@
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework.exceptions import ValidationError
from rest_framework import status
from rest_framework.response import Response
from plane.utils.host import base_host
from plane.api.serializers import (
ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer,
ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer,
@@ -21,6 +24,7 @@ from plane.app.views.base import BaseAPIView
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType
from plane.settings.storage import S3Storage
from plane.utils.external_contours import create_external_contour_issue_comment
class ExternalContourListCreateEndpoint(BaseAPIView):
@@ -309,6 +313,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
serializer.is_valid(raise_exception=True)
action = serializer.validated_data["action"]
comment = (serializer.validated_data.get("comment") or "").strip()
issue = contour_request.issue
if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]:
@@ -337,6 +342,16 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
if not target_default_state:
return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST)
try:
create_external_contour_issue_comment(
issue=issue,
actor=request.user,
comment=comment,
origin=base_host(request=request, is_app=True),
)
except ValidationError as exc:
return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)
issue.state = target_default_state
issue.save(update_fields=["state", "updated_at"])
@@ -344,6 +359,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
extra.pop("source_decision", None)
extra.pop("source_decision_at", None)
extra.pop("source_decision_by_name", None)
extra["last_decline_comment"] = comment
extra["last_reopened_at"] = issue.updated_at.isoformat() if issue.updated_at else None
extra["last_reopened_by_name"] = request.user.display_name
contour_request.extra = extra
@@ -368,6 +384,58 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourReplyEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer
def get_queryset(self):
return (
IntakeIssue.objects.filter(
workspace__slug=self.kwargs.get("slug"),
extra__bridge="external-contours",
extra__source_project_id=str(self.kwargs.get("project_id")),
pk=self.kwargs.get("request_id"),
)
.select_related(
"issue",
"issue__state",
"issue__project",
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
)
def post(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestReplySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
issue = contour_request.issue
if not issue:
return Response({"error": "Target issue was not found"}, status=status.HTTP_404_NOT_FOUND)
try:
create_external_contour_issue_comment(
issue=issue,
actor=request.user,
comment=serializer.validated_data["comment"],
origin=base_host(request=request, is_app=True),
)
except ValidationError as exc:
return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)
contour_request.refresh_from_db()
response_serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
},
)
return Response(response_serializer.data, status=status.HTTP_200_OK)
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
@@ -65,6 +65,13 @@ class NotificationViewSet(BaseViewSet, BasePaginator):
.filter(entity_name="issue")
.annotate(is_inbox_issue=Exists(intake_issue))
.annotate(is_intake_issue=Exists(intake_issue))
.annotate(
is_external_contour=Case(
When(sender__startswith="in_app:external_contours:", then=True),
default=False,
output_field=BooleanField(),
)
)
.annotate(
is_mentioned_notification=Case(
When(sender__icontains="mentioned", then=True),
@@ -23,8 +23,10 @@ from plane.db.models import (
IssueActivity,
UserNotificationPreference,
ProjectMember,
IntakeIssue,
)
from django.db.models import Subquery
from plane.utils.external_contours import create_external_contour_notification
# Third Party imports
from celery import shared_task
@@ -99,6 +101,10 @@ def extract_mentions_as_subscribers(project_id, issue_id, mentions):
and ProjectMember.objects.filter(project_id=project_id, member_id=mention_id, is_active=True).exists()
):
project = Project.objects.get(pk=project_id)
external_contour_request = IntakeIssue.objects.filter(
issue_id=issue_id,
extra__bridge="external-contours",
).first()
bulk_mention_subscribers.append(
IssueSubscriber(
@@ -288,6 +294,10 @@ def notifications(
)
issue = Issue.objects.filter(pk=issue_id).first()
external_contour_request = IntakeIssue.objects.filter(
issue_id=issue_id,
extra__bridge="external-contours",
).first()
if subscriber:
# add the user to issue subscriber
@@ -517,7 +527,19 @@ def notifications(
},
)
)
bulk_notifications.append(notification)
if external_contour_request is not None:
for issue_activity in issue_activities_created:
if issue_activity.get("issue_detail").get("id") != issue_id:
continue
external_contour_notification = create_external_contour_notification(
contour_request=external_contour_request,
issue=issue,
issue_activity_data=issue_activity,
)
if external_contour_notification is not None:
bulk_notifications.append(external_contour_notification)
for mention_id in new_mentions:
if mention_id != actor_id:
@@ -0,0 +1,123 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import json
from django.core.serializers.json import DjangoJSONEncoder
from django.utils import timezone
from django.utils.html import escape
from plane.app.serializers import IssueCommentSerializer
from plane.db.models import IssueComment, Notification, Project
EXTERNAL_CONTOUR_NOTIFICATION_FIELDS = {"state", "comment", "attachment", "description"}
def build_external_contour_comment_html(comment: str) -> str:
escaped_comment = escape((comment or "").strip())
escaped_comment = escaped_comment.replace("\n", "<br />")
return f"<p>{escaped_comment}</p>"
def create_external_contour_issue_comment(*, issue, actor, comment: str, origin: str):
from plane.bgtasks.issue_activities_task import issue_activity
comment_html = build_external_contour_comment_html(comment)
serializer = IssueCommentSerializer(data={"comment_html": comment_html})
serializer.is_valid(raise_exception=True)
serializer.save(project_id=issue.project_id, issue_id=issue.id, actor=actor)
issue_activity.delay(
type="comment.activity.created",
requested_data=json.dumps(serializer.data, cls=DjangoJSONEncoder),
actor_id=str(actor.id),
issue_id=str(issue.id),
project_id=str(issue.project_id),
current_instance=None,
epoch=int(timezone.now().timestamp()),
notification=True,
origin=origin,
)
return serializer.data
def create_external_contour_notification(*, contour_request, issue, issue_activity_data):
extra = contour_request.extra or {}
receiver_id = extra.get("requested_by_id")
source_project_id = extra.get("source_project_id")
field = issue_activity_data.get("field")
actor_id = issue_activity_data.get("actor_id") or (issue_activity_data.get("actor_detail") or {}).get("id")
if not receiver_id or not source_project_id or not field:
return None
if field not in EXTERNAL_CONTOUR_NOTIFICATION_FIELDS:
return None
if str(receiver_id) == str(actor_id):
return None
source_project = Project.objects.filter(
pk=source_project_id,
workspace_id=issue.workspace_id,
archived_at__isnull=True,
).first()
if not source_project:
return None
issue_comment = (
IssueComment.objects.filter(
id=issue_activity_data.get("issue_comment"),
issue_id=issue.id,
project_id=issue.project_id,
workspace_id=issue.workspace_id,
).first()
if issue_activity_data.get("issue_comment")
else None
)
return Notification(
workspace=source_project.workspace,
project=source_project,
sender=f"in_app:external_contours:{field}",
triggered_by_id=actor_id,
receiver_id=receiver_id,
entity_identifier=issue.id,
entity_name="issue",
title=issue_activity_data.get("comment") or "External contour updated",
data={
"issue": {
"id": str(contour_request.id),
"name": str(issue.name),
"identifier": str(issue.project.identifier),
"sequence_id": issue.sequence_id,
"state_name": issue.state.name if issue.state else None,
"state_group": issue.state.group if issue.state else None,
"project_id": str(source_project.id),
"workspace_slug": str(source_project.workspace.slug),
"external_contour_request_id": str(contour_request.id),
"target_issue_id": str(issue.id),
"target_project_id": str(issue.project_id),
"target_project_name": str(issue.project.name),
"source_project_id": str(source_project.id),
"source_project_name": str(source_project.name),
},
"issue_activity": {
"id": str(issue_activity_data.get("id")),
"verb": str(issue_activity_data.get("verb")),
"field": str(field),
"actor": str(actor_id),
"new_value": str(issue_activity_data.get("new_value")),
"old_value": str(issue_activity_data.get("old_value")),
"issue_comment": str(issue_comment.comment_stripped if issue_comment is not None else ""),
"old_identifier": (
str(issue_activity_data.get("old_identifier")) if issue_activity_data.get("old_identifier") else None
),
"new_identifier": (
str(issue_activity_data.get("new_identifier")) if issue_activity_data.get("new_identifier") else None
),
},
},
)