ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: зеркалирование комментариев, вложений и активности внешнего контура

This commit is contained in:
DCCONSTRUCTIONS
2026-04-19 08:53:41 +03:00
parent 8195c3fc80
commit 61a8625a5c
16 changed files with 525 additions and 13 deletions
@@ -10,7 +10,7 @@ from .project import ProjectLiteSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
from plane.app.serializers.issue import LabelSerializer
from plane.db.models import IntakeIssue, Issue, Label, Project
from plane.db.models import FileAsset, IntakeIssue, Issue, IssueActivity, IssueComment, Label, Project
class ExternalContourIssuePayloadSerializer(serializers.Serializer):
@@ -95,8 +95,54 @@ class ExternalContourIssueSerializer(BaseSerializer):
]
class ExternalContourMirroredAttachmentSerializer(BaseSerializer):
uploaded_by = serializers.SerializerMethodField()
download_url = serializers.SerializerMethodField()
class Meta:
model = FileAsset
fields = ["id", "attributes", "asset_url", "download_url", "updated_at", "uploaded_by"]
read_only_fields = fields
def get_uploaded_by(self, obj):
user = obj.updated_by or obj.created_by
return getattr(user, "display_name", None)
def get_download_url(self, obj):
workspace_slug = self.context.get("workspace_slug")
source_project_id = self.context.get("source_project_id")
request_id = self.context.get("request_id")
if not workspace_slug or not source_project_id or not request_id:
return None
return (
f"/api/workspaces/{workspace_slug}/projects/{source_project_id}/external-contours/"
f"{request_id}/attachments/{obj.id}/"
)
class ExternalContourMirroredCommentSerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueComment
fields = ["id", "comment_html", "created_at", "updated_at", "edited_at", "parent_id", "actor_detail"]
read_only_fields = fields
class ExternalContourMirroredActivitySerializer(BaseSerializer):
actor_detail = UserLiteSerializer(read_only=True, source="actor")
class Meta:
model = IssueActivity
fields = ["id", "verb", "field", "old_value", "new_value", "comment", "created_at", "actor_detail"]
read_only_fields = fields
class ExternalContourRequestSerializer(BaseSerializer):
issue = ExternalContourIssueSerializer(read_only=True)
mirrored_activity = serializers.SerializerMethodField()
mirrored_attachments = serializers.SerializerMethodField()
mirrored_comments = serializers.SerializerMethodField()
source_project_id = serializers.SerializerMethodField()
source_project_name = serializers.SerializerMethodField()
source_decision = serializers.SerializerMethodField()
@@ -117,6 +163,9 @@ class ExternalContourRequestSerializer(BaseSerializer):
"updated_at",
"created_by",
"issue",
"mirrored_activity",
"mirrored_attachments",
"mirrored_comments",
"source_project_id",
"source_project_name",
"source_decision",
@@ -134,6 +183,53 @@ class ExternalContourRequestSerializer(BaseSerializer):
def get_source_project_id(self, obj):
return obj.extra.get("source_project_id")
def get_mirrored_activity(self, obj):
if not self.context.get("include_mirror_data") or not obj.issue_id:
return []
activity = (
IssueActivity.objects.filter(issue_id=obj.issue_id)
.exclude(field__in=["comment", "vote", "reaction", "draft"])
.select_related("actor")
.order_by("-created_at")[:50]
)
return ExternalContourMirroredActivitySerializer(activity, many=True).data
def get_mirrored_attachments(self, obj):
if not self.context.get("include_mirror_data") or not obj.issue_id:
return []
attachments = (
FileAsset.objects.filter(
issue_id=obj.issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
is_deleted=False,
)
.select_related("created_by", "updated_by")
.order_by("-updated_at")
)
return ExternalContourMirroredAttachmentSerializer(
attachments,
many=True,
context={
"workspace_slug": self.context.get("workspace_slug"),
"source_project_id": self.context.get("source_project_id"),
"request_id": str(obj.id),
},
).data
def get_mirrored_comments(self, obj):
if not self.context.get("include_mirror_data") or not obj.issue_id:
return []
comments = (
IssueComment.objects.filter(issue_id=obj.issue_id)
.select_related("actor")
.order_by("created_at")
)
return ExternalContourMirroredCommentSerializer(comments, many=True).data
def get_source_project_name(self, obj):
return obj.extra.get("source_project_name")
@@ -5,6 +5,7 @@
from django.urls import path
from plane.api.views import (
ExternalContourAttachmentDownloadAPIEndpoint,
ExternalContourDetailAPIEndpoint,
ExternalContourDecisionAPIEndpoint,
ExternalContourListCreateAPIEndpoint,
@@ -38,4 +39,9 @@ urlpatterns = [
ExternalContourDecisionAPIEndpoint.as_view(http_method_names=["post"]),
name="external-contour-decision",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/attachments/<uuid:attachment_id>/",
ExternalContourAttachmentDownloadAPIEndpoint.as_view(http_method_names=["get"]),
name="external-contour-attachment-download",
),
]
@@ -56,6 +56,7 @@ from .intake import (
IntakeIssueDetailAPIEndpoint,
)
from .external_contours import (
ExternalContourAttachmentDownloadEndpoint as ExternalContourAttachmentDownloadAPIEndpoint,
ExternalContourListCreateEndpoint as ExternalContourListCreateAPIEndpoint,
ExternalContourDetailEndpoint as ExternalContourDetailAPIEndpoint,
ExternalContourDecisionEndpoint as ExternalContourDecisionAPIEndpoint,
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status
@@ -17,8 +18,9 @@ from plane.api.serializers import (
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission
from .base import BaseAPIView
from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
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
class ExternalContourListCreateEndpoint(BaseAPIView):
@@ -269,7 +271,14 @@ class ExternalContourDetailEndpoint(BaseAPIView):
def get(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestSerializer(contour_request)
serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -349,6 +358,42 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
.get(pk=contour_request.id)
.get(pk=contour_request.id),
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
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__project", "workspace")
def get(self, request, slug, project_id, request_id, attachment_id):
contour_request = get_object_or_404(self.get_queryset())
attachment = get_object_or_404(
FileAsset,
pk=attachment_id,
issue_id=contour_request.issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
is_deleted=False,
)
storage = S3Storage(request=request)
presigned_url = storage.generate_presigned_url(
object_name=attachment.asset.name,
disposition="attachment",
filename=attachment.attributes.get("name"),
)
return HttpResponseRedirect(presigned_url)
@@ -5,6 +5,7 @@
from django.urls import path
from plane.app.views import (
ExternalContourAttachmentDownloadEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourListCreateEndpoint,
@@ -39,4 +40,9 @@ 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>/attachments/<uuid:attachment_id>/",
ExternalContourAttachmentDownloadEndpoint.as_view(http_method_names=["get"]),
name="external-contour-attachment-download",
),
]
@@ -225,6 +225,7 @@ from .notification.base import (
from .exporter.base import ExportIssuesEndpoint
from .external_contours import (
ExternalContourAttachmentDownloadEndpoint,
ExternalContourListCreateEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status
@@ -17,8 +18,9 @@ from plane.api.serializers import (
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission
from plane.app.views.base import BaseAPIView
from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
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
class ExternalContourListCreateEndpoint(BaseAPIView):
@@ -269,7 +271,14 @@ class ExternalContourDetailEndpoint(BaseAPIView):
def get(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestSerializer(contour_request)
serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -349,6 +358,42 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
.get(pk=contour_request.id)
.get(pk=contour_request.id),
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
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__project", "workspace")
def get(self, request, slug, project_id, request_id, attachment_id):
contour_request = get_object_or_404(self.get_queryset())
attachment = get_object_or_404(
FileAsset,
pk=attachment_id,
issue_id=contour_request.issue_id,
entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT,
is_uploaded=True,
is_deleted=False,
)
storage = S3Storage(request=request)
presigned_url = storage.generate_presigned_url(
object_name=attachment.asset.name,
disposition="attachment",
filename=attachment.attributes.get("name"),
)
return HttpResponseRedirect(presigned_url)