ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: очистка stale assignees Operational Core

This commit is contained in:
DCCONSTRUCTIONS 2026-05-09 12:33:49 +03:00
parent fa4a533d29
commit 78deab1a23
10 changed files with 110 additions and 10 deletions

View File

@ -14,7 +14,7 @@ from drf_spectacular.utils import (
# Module imports # Module imports
from .base import BaseAPIView from .base import BaseAPIView
from plane.api.serializers import UserLiteSerializer, ProjectMemberSerializer from plane.api.serializers import UserLiteSerializer, ProjectMemberSerializer
from plane.db.models import User, Workspace, WorkspaceMember, ProjectMember from plane.db.models import IssueAssignee, User, Workspace, WorkspaceMember, ProjectMember
from plane.utils.permissions import ProjectMemberPermission, WorkSpaceAdminPermission, ProjectAdminPermission from plane.utils.permissions import ProjectMemberPermission, WorkSpaceAdminPermission, ProjectAdminPermission
from plane.utils.openapi import ( from plane.utils.openapi import (
WORKSPACE_SLUG_PARAMETER, WORKSPACE_SLUG_PARAMETER,
@ -205,6 +205,8 @@ class ProjectMemberDetailAPIEndpoint(ProjectMemberListCreateAPIEndpoint):
serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True, context={"slug": slug}) serializer = ProjectMemberSerializer(project_member, data=request.data, partial=True, context={"slug": slug})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
serializer.save() serializer.save()
if serializer.instance.is_active is False:
IssueAssignee.objects.filter(project_id=project_id, assignee_id=serializer.instance.member_id).delete()
return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.data, status=status.HTTP_200_OK)
@extend_schema( @extend_schema(
@ -219,4 +221,5 @@ class ProjectMemberDetailAPIEndpoint(ProjectMemberListCreateAPIEndpoint):
project_member = ProjectMember.objects.get(project_id=project_id, workspace__slug=slug, pk=pk) project_member = ProjectMember.objects.get(project_id=project_id, workspace__slug=slug, pk=pk)
project_member.is_active = False project_member.is_active = False
project_member.save() project_member.save()
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -570,7 +570,15 @@ class IssueViewSet(BaseViewSet):
Subquery( Subquery(
IssueAssignee.objects.filter( IssueAssignee.objects.filter(
issue_id=OuterRef("pk"), issue_id=OuterRef("pk"),
deleted_at__isnull=True,
assignee__is_active=True,
assignee__member_workspace__workspace_id=OuterRef("workspace_id"),
assignee__member_workspace__is_active=True,
assignee__member_workspace__is_banned=False,
assignee__member_workspace__deleted_at__isnull=True,
assignee__member_project__project_id=OuterRef("project_id"),
assignee__member_project__is_active=True, assignee__member_project__is_active=True,
assignee__member_project__deleted_at__isnull=True,
) )
.values("issue_id") .values("issue_id")
.annotate(arr=ArrayAgg("assignee_id", distinct=True)) .annotate(arr=ArrayAgg("assignee_id", distinct=True))
@ -676,7 +684,14 @@ class IssueViewSet(BaseViewSet):
distinct=True, distinct=True,
filter=Q( filter=Q(
~Q(assignees__id__isnull=True) ~Q(assignees__id__isnull=True)
& Q(assignees__is_active=True)
& Q(assignees__member_workspace__workspace_id=F("workspace_id"))
& Q(assignees__member_workspace__is_active=True)
& Q(assignees__member_workspace__is_banned=False)
& Q(assignees__member_workspace__deleted_at__isnull=True)
& Q(assignees__member_project__project_id=F("project_id"))
& Q(assignees__member_project__is_active=True) & Q(assignees__member_project__is_active=True)
& Q(assignees__member_project__deleted_at__isnull=True)
& Q(issue_assignee__deleted_at__isnull=True) & Q(issue_assignee__deleted_at__isnull=True)
), ),
), ),
@ -980,7 +995,15 @@ class IssuePaginatedViewSet(BaseViewSet):
Subquery( Subquery(
IssueAssignee.objects.filter( IssueAssignee.objects.filter(
issue_id=OuterRef("pk"), issue_id=OuterRef("pk"),
deleted_at__isnull=True,
assignee__is_active=True,
assignee__member_workspace__workspace_id=OuterRef("workspace_id"),
assignee__member_workspace__is_active=True,
assignee__member_workspace__is_banned=False,
assignee__member_workspace__deleted_at__isnull=True,
assignee__member_project__project_id=OuterRef("project_id"),
assignee__member_project__is_active=True, assignee__member_project__is_active=True,
assignee__member_project__deleted_at__isnull=True,
) )
.values("issue_id") .values("issue_id")
.annotate(arr=ArrayAgg("assignee_id", distinct=True)) .annotate(arr=ArrayAgg("assignee_id", distinct=True))
@ -1315,7 +1338,14 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
distinct=True, distinct=True,
filter=Q( filter=Q(
~Q(assignees__id__isnull=True) ~Q(assignees__id__isnull=True)
& Q(assignees__is_active=True)
& Q(assignees__member_workspace__workspace_id=F("workspace_id"))
& Q(assignees__member_workspace__is_active=True)
& Q(assignees__member_workspace__is_banned=False)
& Q(assignees__member_workspace__deleted_at__isnull=True)
& Q(assignees__member_project__project_id=F("project_id"))
& Q(assignees__member_project__is_active=True) & Q(assignees__member_project__is_active=True)
& Q(assignees__member_project__deleted_at__isnull=True)
& Q(issue_assignee__deleted_at__isnull=True) & Q(issue_assignee__deleted_at__isnull=True)
), ),
), ),

View File

@ -18,7 +18,7 @@ from plane.app.serializers import (
from plane.app.permissions import WorkspaceUserPermission from plane.app.permissions import WorkspaceUserPermission
from plane.db.models import Project, ProjectMember, ProjectUserProperty, WorkspaceMember from plane.db.models import IssueAssignee, Project, ProjectMember, ProjectUserProperty, WorkspaceMember
from plane.bgtasks.project_add_user_email_task import project_add_user_email from plane.bgtasks.project_add_user_email_task import project_add_user_email
from plane.utils.host import base_host from plane.utils.host import base_host
from plane.app.permissions.base import allow_permission, ROLE from plane.app.permissions.base import allow_permission, ROLE
@ -295,6 +295,7 @@ class ProjectMemberViewSet(BaseViewSet):
project_member.is_active = False project_member.is_active = False
project_member.save() project_member.save()
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST]) @allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
@ -323,6 +324,7 @@ class ProjectMemberViewSet(BaseViewSet):
# Deactivate the user # Deactivate the user
project_member.is_active = False project_member.is_active = False
project_member.save() project_member.save()
IssueAssignee.objects.filter(project_id=project_id, assignee_id=project_member.member_id).delete()
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)

View File

@ -21,7 +21,7 @@ from plane.app.serializers import (
WorkSpaceMemberSerializer, WorkSpaceMemberSerializer,
) )
from plane.app.views.base import BaseAPIView from plane.app.views.base import BaseAPIView
from plane.db.models import Project, ProjectMember, WorkspaceMember, DraftIssue from plane.db.models import IssueAssignee, Project, ProjectMember, WorkspaceMember, DraftIssue
from plane.utils.cache import invalidate_cache from plane.utils.cache import invalidate_cache
from .. import BaseViewSet from .. import BaseViewSet
@ -144,6 +144,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
_ = ProjectMember.objects.filter( _ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False, updated_at=timezone.now()) ).update(is_active=False, updated_at=timezone.now())
IssueAssignee.objects.filter(workspace__slug=slug, assignee_id=workspace_member.member_id).delete()
workspace_member.is_active = False workspace_member.is_active = False
workspace_member.save() workspace_member.save()
@ -198,6 +199,7 @@ class WorkSpaceMemberViewSet(BaseViewSet):
_ = ProjectMember.objects.filter( _ = ProjectMember.objects.filter(
workspace__slug=slug, member_id=workspace_member.member_id, is_active=True workspace__slug=slug, member_id=workspace_member.member_id, is_active=True
).update(is_active=False, updated_at=timezone.now()) ).update(is_active=False, updated_at=timezone.now())
IssueAssignee.objects.filter(workspace__slug=slug, assignee_id=workspace_member.member_id).delete()
# # Deactivate the user # # Deactivate the user
workspace_member.is_active = False workspace_member.is_active = False

View File

@ -9,7 +9,16 @@ from django.views import View
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized from plane.authentication.views.nodedc_logout import is_internal_logout_request_authorized
from plane.db.models import ExternalIdentityLink, Profile, Project, ProjectMember, User, Workspace, WorkspaceMember from plane.db.models import (
ExternalIdentityLink,
IssueAssignee,
Profile,
Project,
ProjectMember,
User,
Workspace,
WorkspaceMember,
)
OIDC_PROVIDER = "authentik" OIDC_PROVIDER = "authentik"
@ -384,6 +393,10 @@ class NodeDCInternalWorkspaceMembershipRemoveEndpoint(View):
member=user, member=user,
is_active=True, is_active=True,
).update(is_active=False) ).update(is_active=False)
IssueAssignee.objects.filter(
workspace=workspace,
assignee=user,
).delete()
membership.is_active = False membership.is_active = False
membership.save(update_fields=["is_active", "updated_at"]) membership.save(update_fields=["is_active", "updated_at"])
@ -534,6 +547,7 @@ class NodeDCInternalProjectMembershipRemoveEndpoint(View):
project_member.is_active = False project_member.is_active = False
project_member.save(update_fields=["is_active", "updated_at"]) project_member.save(update_fields=["is_active", "updated_at"])
IssueAssignee.objects.filter(project=project, assignee=user).delete()
return JsonResponse( return JsonResponse(
{ {

View File

@ -14,6 +14,7 @@ from django.utils.dateparse import parse_datetime
from plane.app.views.base import BaseAPIView from plane.app.views.base import BaseAPIView
from plane.license.api.permissions import InstanceAdminPermission from plane.license.api.permissions import InstanceAdminPermission
from plane.db.models import ( from plane.db.models import (
IssueAssignee,
Project, Project,
ProjectMember, ProjectMember,
Workspace, Workspace,
@ -278,6 +279,7 @@ class InstanceWorkSpaceMemberEndpoint(BaseAPIView):
member_id=workspace_member.member_id, member_id=workspace_member.member_id,
is_active=True, is_active=True,
).update(is_active=False, updated_at=timezone.now()) ).update(is_active=False, updated_at=timezone.now())
IssueAssignee.objects.filter(workspace_id=workspace_id, assignee_id=workspace_member.member_id).delete()
workspace_member.is_active = False workspace_member.is_active = False
workspace_member.save() workspace_member.save()
@ -332,6 +334,7 @@ class InstanceWorkSpaceMemberBanEndpoint(BaseAPIView):
member_id=workspace_member.member_id, member_id=workspace_member.member_id,
is_active=True, is_active=True,
).update(is_active=False, updated_at=timezone.now()) ).update(is_active=False, updated_at=timezone.now())
IssueAssignee.objects.filter(workspace_id=workspace_id, assignee_id=workspace_member.member_id).delete()
workspace_member.is_banned = True workspace_member.is_banned = True
workspace_member.banned_at = timezone.now() workspace_member.banned_at = timezone.now()

View File

@ -33,8 +33,20 @@ def issue_queryset_grouper(
"module_ids": "issue_module__module_id", "module_ids": "issue_module__module_id",
} }
valid_assignee_filter = (
Q(issue_assignee__deleted_at__isnull=True)
& Q(assignees__is_active=True)
& Q(assignees__member_workspace__workspace_id=F("workspace_id"))
& Q(assignees__member_workspace__is_active=True)
& Q(assignees__member_workspace__is_banned=False)
& Q(assignees__member_workspace__deleted_at__isnull=True)
& Q(assignees__member_project__project_id=F("project_id"))
& Q(assignees__member_project__is_active=True)
& Q(assignees__member_project__deleted_at__isnull=True)
)
GROUP_FILTER_MAPPER = { GROUP_FILTER_MAPPER = {
"assignees__id": Q(issue_assignee__deleted_at__isnull=True), "assignees__id": valid_assignee_filter,
"labels__id": Q(label_issue__deleted_at__isnull=True), "labels__id": Q(label_issue__deleted_at__isnull=True),
"issue_module__module_id": Q(issue_module__deleted_at__isnull=True), "issue_module__module_id": Q(issue_module__deleted_at__isnull=True),
} }
@ -46,7 +58,7 @@ def issue_queryset_grouper(
annotations_map = { annotations_map = {
"assignee_ids": ( "assignee_ids": (
"assignees__id", "assignees__id",
~Q(assignees__id__isnull=True) & Q(issue_assignee__deleted_at__isnull=True), ~Q(assignees__id__isnull=True) & valid_assignee_filter,
), ),
"label_ids": ( "label_ids": (
"labels__id", "labels__id",

View File

@ -625,7 +625,14 @@ class IssueRetrievePublicEndpoint(BaseAPIView):
distinct=True, distinct=True,
filter=Q( filter=Q(
~Q(assignees__id__isnull=True) ~Q(assignees__id__isnull=True)
& Q(assignees__is_active=True)
& Q(assignees__member_workspace__workspace_id=F("workspace_id"))
& Q(assignees__member_workspace__is_active=True)
& Q(assignees__member_workspace__is_banned=False)
& Q(assignees__member_workspace__deleted_at__isnull=True)
& Q(assignees__member_project__project_id=F("project_id"))
& Q(assignees__member_project__is_active=True) & Q(assignees__member_project__is_active=True)
& Q(assignees__member_project__deleted_at__isnull=True)
& Q(issue_assignee__deleted_at__isnull=True) & Q(issue_assignee__deleted_at__isnull=True)
), ),
), ),

View File

@ -5,7 +5,7 @@
# Django imports # Django imports
from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.aggregates import ArrayAgg
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.db.models import Q, UUIDField, Value, QuerySet, OuterRef, Subquery from django.db.models import F, Q, UUIDField, Value, QuerySet, OuterRef, Subquery
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
# Module imports # Module imports
@ -37,8 +37,20 @@ def issue_queryset_grouper(
"module_ids": "issue_module__module_id", "module_ids": "issue_module__module_id",
} }
valid_assignee_filter = (
Q(issue_assignee__deleted_at__isnull=True)
& Q(assignees__is_active=True)
& Q(assignees__member_workspace__workspace_id=F("workspace_id"))
& Q(assignees__member_workspace__is_active=True)
& Q(assignees__member_workspace__is_banned=False)
& Q(assignees__member_workspace__deleted_at__isnull=True)
& Q(assignees__member_project__project_id=F("project_id"))
& Q(assignees__member_project__is_active=True)
& Q(assignees__member_project__deleted_at__isnull=True)
)
GROUP_FILTER_MAPPER: Dict[str, Q] = { GROUP_FILTER_MAPPER: Dict[str, Q] = {
"assignees__id": Q(issue_assignee__deleted_at__isnull=True), "assignees__id": valid_assignee_filter,
"labels__id": Q(label_issue__deleted_at__isnull=True), "labels__id": Q(label_issue__deleted_at__isnull=True),
"issue_module__module_id": Q(issue_module__deleted_at__isnull=True), "issue_module__module_id": Q(issue_module__deleted_at__isnull=True),
} }
@ -51,6 +63,14 @@ def issue_queryset_grouper(
IssueAssignee.objects.filter( IssueAssignee.objects.filter(
issue_id=OuterRef("pk"), issue_id=OuterRef("pk"),
deleted_at__isnull=True, deleted_at__isnull=True,
assignee__is_active=True,
assignee__member_workspace__workspace_id=OuterRef("workspace_id"),
assignee__member_workspace__is_active=True,
assignee__member_workspace__is_banned=False,
assignee__member_workspace__deleted_at__isnull=True,
assignee__member_project__project_id=OuterRef("project_id"),
assignee__member_project__is_active=True,
assignee__member_project__deleted_at__isnull=True,
) )
.values("issue_id") .values("issue_id")
.annotate(arr=ArrayAgg("assignee_id", distinct=True)) .annotate(arr=ArrayAgg("assignee_id", distinct=True))

View File

@ -46,7 +46,10 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
const { t } = useTranslation(); const { t } = useTranslation();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { workspaceSlug: routerWorkspaceSlug } = useParams(); const { workspaceSlug: routerWorkspaceSlug } = useParams();
const { getUserDetails } = useMember(); const {
getUserDetails,
project: { getProjectMemberIds },
} = useMember();
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const { getStateById, getProjectStateIds } = useProjectState(); const { getStateById, getProjectStateIds } = useProjectState();
@ -86,7 +89,11 @@ export const InternalContourKanbanCard = observer(function InternalContourKanban
const checkerItemsTotal = issue.checker_items_count ?? 0; const checkerItemsTotal = issue.checker_items_count ?? 0;
const checkerItemsCompleted = issue.checker_items_completed_count ?? 0; const checkerItemsCompleted = issue.checker_items_completed_count ?? 0;
const hasCheckerProgress = checkerBlocksTotal > 0; const hasCheckerProgress = checkerBlocksTotal > 0;
const assigneeIds = issue.assignee_ids ?? []; const rawAssigneeIds = issue.assignee_ids ?? [];
const projectMemberIds = issue.project_id ? getProjectMemberIds(issue.project_id, true) : null;
const assigneeIds = projectMemberIds
? rawAssigneeIds.filter((assigneeId) => projectMemberIds.includes(assigneeId))
: rawAssigneeIds;
const visibleAssigneeIds = assigneeIds.slice(0, 3); const visibleAssigneeIds = assigneeIds.slice(0, 3);
const assigneeCount = assigneeIds.length; const assigneeCount = assigneeIds.length;
const assigneeStackWidthClass = const assigneeStackWidthClass =