ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: guest-доступ Operational Core

This commit is contained in:
DCCONSTRUCTIONS 2026-05-12 19:36:01 +03:00
parent d0e2f423e6
commit 87e1857f53
12 changed files with 70 additions and 42 deletions

View File

@ -0,0 +1,15 @@
import os
def nodedc_env_flag(name):
return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"}
def nodedc_guest_read_all_issues_enabled():
return nodedc_env_flag("PLANE_NODEDC_GUEST_READ_ALL_ISSUES") or nodedc_env_flag(
"PLANE_NODEDC_ACCESS_ENFORCEMENT"
)
def should_limit_guest_to_own_issues(project):
return not project.guest_view_all_features and not nodedc_guest_read_all_issues_enabled()

View File

@ -34,7 +34,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
model = FileAsset model = FileAsset
parser_classes = (MultiPartParser, FormParser) parser_classes = (MultiPartParser, FormParser)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, issue_id): def post(self, request, slug, project_id, issue_id):
serializer = IssueAttachmentSerializer(data=request.data) serializer = IssueAttachmentSerializer(data=request.data)
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)
@ -104,7 +104,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
serializer_class = IssueAttachmentSerializer serializer_class = IssueAttachmentSerializer
model = FileAsset model = FileAsset
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def post(self, request, slug, project_id, issue_id): def post(self, request, slug, project_id, issue_id):
name = request.data.get("name") name = request.data.get("name")
type = request.data.get("type", False) type = request.data.get("type", False)
@ -209,7 +209,7 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
serializer = IssueAttachmentSerializer(issue_attachments, many=True) serializer = IssueAttachmentSerializer(issue_attachments, many=True)
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def patch(self, request, slug, project_id, issue_id, pk): def patch(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id) issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
serializer = IssueAttachmentSerializer(issue_attachment) serializer = IssueAttachmentSerializer(issue_attachment)

View File

@ -33,6 +33,10 @@ from rest_framework.response import Response
# Module imports # Module imports
from plane.app.permissions import ROLE, allow_permission from plane.app.permissions import ROLE, allow_permission
from plane.app.nodedc_access import (
nodedc_guest_read_all_issues_enabled,
should_limit_guest_to_own_issues,
)
from plane.app.realtime.issue_events import publish_issue_event_on_commit from plane.app.realtime.issue_events import publish_issue_event_on_commit
from plane.app.serializers import ( from plane.app.serializers import (
IssueCreateSerializer, IssueCreateSerializer,
@ -333,7 +337,7 @@ class IssueViewSet(BaseViewSet):
role=5, role=5,
is_active=True, is_active=True,
).exists() ).exists()
and not project.guest_view_all_features and should_limit_guest_to_own_issues(project)
): ):
issue_queryset = issue_queryset.filter(created_by=request.user) issue_queryset = issue_queryset.filter(created_by=request.user)
filtered_issue_queryset = filtered_issue_queryset.filter(created_by=request.user) filtered_issue_queryset = filtered_issue_queryset.filter(created_by=request.user)
@ -641,7 +645,7 @@ class IssueViewSet(BaseViewSet):
role=5, role=5,
is_active=True, is_active=True,
).exists() ).exists()
and not project.guest_view_all_features and should_limit_guest_to_own_issues(project)
and not issue.created_by == request.user and not issue.created_by == request.user
): ):
return Response( return Response(
@ -972,7 +976,7 @@ class IssuePaginatedViewSet(BaseViewSet):
role=5, role=5,
is_active=True, is_active=True,
) )
if project_member.exists() and not project.guest_view_all_features: if project_member.exists() and should_limit_guest_to_own_issues(project):
base_queryset = base_queryset.filter(created_by=request.user) base_queryset = base_queryset.filter(created_by=request.user)
queryset = queryset.filter(created_by=request.user) queryset = queryset.filter(created_by=request.user)
@ -1093,8 +1097,17 @@ class IssueDetailEndpoint(BaseAPIView):
def get(self, request, slug, project_id): def get(self, request, slug, project_id):
filters = issue_filters(request.query_params, "GET") filters = issue_filters(request.query_params, "GET")
# check for the project member role, if the role is 5 then check for the guest_view_all_features guest_read_filter = Q(
# if it is true then show all the issues else show only the issues created by the user project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role=ROLE.GUEST.value,
)
if not nodedc_guest_read_all_issues_enabled():
guest_read_filter = guest_read_filter & (
Q(project__guest_view_all_features=True)
| Q(project__guest_view_all_features=False, created_by=self.request.user)
)
permission_subquery = ( permission_subquery = (
Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id, id=OuterRef("id")) Issue.issue_objects.filter(workspace__slug=slug, project_id=project_id, id=OuterRef("id"))
.filter( .filter(
@ -1103,19 +1116,7 @@ class IssueDetailEndpoint(BaseAPIView):
project__project_projectmember__is_active=True, project__project_projectmember__is_active=True,
project__project_projectmember__role__gt=ROLE.GUEST.value, project__project_projectmember__role__gt=ROLE.GUEST.value,
) )
| Q( | guest_read_filter
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role=ROLE.GUEST.value,
project__guest_view_all_features=True,
)
| Q(
project__project_projectmember__member=self.request.user,
project__project_projectmember__is_active=True,
project__project_projectmember__role=ROLE.GUEST.value,
project__guest_view_all_features=False,
created_by=self.request.user,
)
) )
.values("id") .values("id")
) )
@ -1418,7 +1419,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
role=5, role=5,
is_active=True, is_active=True,
).exists() ).exists()
and not project.guest_view_all_features and should_limit_guest_to_own_issues(project)
and not issue.created_by == request.user and not issue.created_by == request.user
): ):
return Response( return Response(

View File

@ -60,7 +60,7 @@ class IssueCommentViewSet(BaseViewSet):
.distinct() .distinct()
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, issue_id): def create(self, request, slug, project_id, issue_id):
project = Project.objects.get(pk=project_id) project = Project.objects.get(pk=project_id)
issue = Issue.objects.get(pk=issue_id) issue = Issue.objects.get(pk=issue_id)
@ -180,7 +180,7 @@ class CommentReactionViewSet(BaseViewSet):
.distinct() .distinct()
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, comment_id): def create(self, request, slug, project_id, comment_id):
try: try:
serializer = CommentReactionSerializer(data=request.data) serializer = CommentReactionSerializer(data=request.data)
@ -209,7 +209,7 @@ class CommentReactionViewSet(BaseViewSet):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, comment_id, reaction_code): def destroy(self, request, slug, project_id, comment_id, reaction_code):
comment_reaction = CommentReaction.objects.get( comment_reaction = CommentReaction.objects.get(
workspace__slug=slug, workspace__slug=slug,

View File

@ -42,7 +42,7 @@ class IssueReactionViewSet(BaseViewSet):
.distinct() .distinct()
) )
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def create(self, request, slug, project_id, issue_id): def create(self, request, slug, project_id, issue_id):
serializer = IssueReactionSerializer(data=request.data) serializer = IssueReactionSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
@ -61,7 +61,7 @@ class IssueReactionViewSet(BaseViewSet):
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def destroy(self, request, slug, project_id, issue_id, reaction_code): def destroy(self, request, slug, project_id, issue_id, reaction_code):
issue_reaction = IssueReaction.objects.get( issue_reaction = IssueReaction.objects.get(
workspace__slug=slug, workspace__slug=slug,

View File

@ -20,6 +20,7 @@ from plane.app.serializers import (
IssueDescriptionVersionDetailSerializer, IssueDescriptionVersionDetailSerializer,
) )
from plane.app.permissions import allow_permission, ROLE from plane.app.permissions import allow_permission, ROLE
from plane.app.nodedc_access import should_limit_guest_to_own_issues
from plane.utils.global_paginator import paginate from plane.utils.global_paginator import paginate
from plane.utils.timezone_converter import user_timezone_converter from plane.utils.timezone_converter import user_timezone_converter
@ -96,7 +97,7 @@ class WorkItemDescriptionVersionEndpoint(BaseAPIView):
role=ROLE.GUEST.value, role=ROLE.GUEST.value,
is_active=True, is_active=True,
).exists() ).exists()
and not project.guest_view_all_features and should_limit_guest_to_own_issues(project)
and not issue.created_by == request.user and not issue.created_by == request.user
): ):
return Response( return Response(

View File

@ -120,10 +120,14 @@ export const BaseCalendarRoot = observer(function BaseCalendarRoot(props: IBaseC
issueProjectId, issueProjectId,
updateIssue updateIssue
).catch((err) => { ).catch((err) => {
const message =
err?.detail === "You are not allowed to move this work item"
? "У вас нет прав перемещать эту карточку"
: "Не удалось выполнить действие";
setToast({ setToast({
title: "Error!", title: "Ошибка",
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
message: err?.detail ?? "Failed to perform this action", message,
}); });
}); });
}; };

View File

@ -103,7 +103,7 @@ export const BaseGanttRoot = observer(function BaseGanttRoot(props: IBaseGanttRo
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: t("toast.error"), title: t("toast.error"),
message: "Error while updating work item dates, Please try again Later", message: "Не удалось обновить даты карточки. Попробуйте позже.",
}); });
}), }),
[issues, projectId, workspaceSlug] [issues, projectId, workspaceSlug]

View File

@ -305,10 +305,10 @@ export const KanbanIssueBlock = observer(function KanbanIssueBlock(props: IssueB
else { else {
setToast({ setToast({
type: TOAST_TYPE.WARNING, type: TOAST_TYPE.WARNING,
title: "Cannot move work item", title: "Нельзя переместить карточку",
message: !canEditIssueProperties message: !canEditIssueProperties
? "You are not allowed to move this work item" ? "У вас нет прав перемещать эту карточку"
: "Drag and drop is disabled for the current grouping", : "Перетаскивание отключено для текущей группировки",
}); });
} }
}} }}

View File

@ -243,10 +243,10 @@ export const IssueBlock = observer(function IssueBlock(props: IssueBlockProps) {
if (!isDraggingAllowed) { if (!isDraggingAllowed) {
setToast({ setToast({
type: TOAST_TYPE.WARNING, type: TOAST_TYPE.WARNING,
title: "Cannot move work item", title: "Нельзя переместить карточку",
message: !canEditIssueProperties message: !canEditIssueProperties
? "You are not allowed to move this work item" ? "У вас нет прав перемещать эту карточку"
: "Drag and drop is disabled for the current grouping", : "Перетаскивание отключено для текущей группировки",
}); });
} }
}} }}

View File

@ -183,7 +183,7 @@ const getCycleColumns = (): IGroupByColumn[] | undefined => {
icon: <CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />, icon: <CycleGroupIcon cycleGroup={cycleStatus} className="h-3.5 w-3.5" />,
payload: { cycle_id: cycle.id }, payload: { cycle_id: cycle.id },
isDropDisabled, isDropDisabled,
dropErrorMessage: isDropDisabled ? "Work item cannot be moved to completed cycles" : undefined, dropErrorMessage: isDropDisabled ? "Карточку нельзя перенести в завершённый цикл" : undefined,
}); });
}); });
cycles.push({ cycles.push({

View File

@ -27,6 +27,13 @@ type DNDStoreType =
| EIssuesStoreType.EPIC | EIssuesStoreType.EPIC
| EIssuesStoreType.TEAM_PROJECT_WORK_ITEMS; | EIssuesStoreType.TEAM_PROJECT_WORK_ITEMS;
const resolveDragDropErrorMessage = (message?: string) => {
if (message === "You are not allowed to move this work item") return "У вас нет прав перемещать эту карточку";
if (message === "Work item cannot be moved to completed cycles") return "Карточку нельзя перенести в завершённый цикл";
if (message === "Failed to perform this action") return "Не удалось выполнить действие";
return message || "Не удалось выполнить действие";
};
export const useGroupIssuesDragNDrop = ( export const useGroupIssuesDragNDrop = (
storeType: DNDStoreType, storeType: DNDStoreType,
orderBy: TIssueOrderByOptions | undefined, orderBy: TIssueOrderByOptions | undefined,
@ -63,8 +70,8 @@ export const useGroupIssuesDragNDrop = (
) => { ) => {
const errorToastProps = { const errorToastProps = {
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: "Ошибка",
message: "Error while updating work item", message: "Не удалось обновить карточку",
}; };
const moduleKey = ISSUE_FILTER_DEFAULT_DATA["module"]; const moduleKey = ISSUE_FILTER_DEFAULT_DATA["module"];
const cycleKey = ISSUE_FILTER_DEFAULT_DATA["cycle"]; const cycleKey = ISSUE_FILTER_DEFAULT_DATA["cycle"];
@ -117,9 +124,9 @@ export const useGroupIssuesDragNDrop = (
orderBy !== "sort_order" orderBy !== "sort_order"
).catch((err) => { ).catch((err) => {
setToast({ setToast({
title: "Error!", title: "Ошибка",
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
message: err?.detail ?? "Failed to perform this action", message: resolveDragDropErrorMessage(err?.detail),
}); });
}); });
}; };