From 6eefad54546ca83c8e12f6073baf18d563e56f61 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Wed, 21 Oct 2026 13:37:34 +0300 Subject: [PATCH] fix: remove program participants from application copy teams --- chats/consumers/chat.py | 55 ++- chats/consumers/event_types/ProjectEvent.py | 34 +- partner_programs/serializers/management.py | 10 + .../services/participant_removal.py | 167 ++++++-- ...est_management_application_team_removal.py | 357 ++++++++++++++++++ .../tests/test_management_bulk.py | 16 +- 6 files changed, 585 insertions(+), 54 deletions(-) create mode 100644 partner_programs/tests/test_management_application_team_removal.py diff --git a/chats/consumers/chat.py b/chats/consumers/chat.py index 71a354a4..a93d615f 100644 --- a/chats/consumers/chat.py +++ b/chats/consumers/chat.py @@ -8,6 +8,7 @@ from channels.generic.websocket import AsyncJsonWebsocketConsumer from django.conf import settings from django.core.cache import cache +from django.db.models import Q from django.utils import timezone from chats.exceptions import ChatException @@ -25,7 +26,7 @@ from users.models import CustomUser from chats.consumers.event_types import DirectEvent, ProjectEvent from chats.utils import get_chat_and_user_ids_from_content -from chats.models import DirectChat +from chats.models import DirectChat, ProjectChat @database_sync_to_async @@ -35,6 +36,19 @@ def get_user_project_ids(user_id: int) -> list[int]: ) +@database_sync_to_async +def can_access_project_chat(user_id, chat_id): + try: + chat_id = int(chat_id) + except (TypeError, ValueError): + return False + return ( + ProjectChat.objects.filter(pk=chat_id) + .filter(Q(project__leader_id=user_id) | Q(project__collaborator__user_id=user_id)) + .exists() + ) + + class ChatConsumer(AsyncJsonWebsocketConsumer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -88,10 +102,7 @@ async def connect(self): ) # Confirm selected subprotocol so browser clients finish handshake. subprotocol = None - if ( - self.scope.get("subprotocols") - and len(self.scope["subprotocols"]) >= 1 - ): + if self.scope.get("subprotocols") and len(self.scope["subprotocols"]) >= 1: subprotocol = self.scope["subprotocols"][0] await self.accept(subprotocol=subprotocol) @@ -138,6 +149,11 @@ async def receive_json(self, content, **kwargs): if event.content["chat_type"] == ChatType.DIRECT: self.event = DirectEvent(self.user, self.channel_layer, self.channel_name) elif event.content["chat_type"] == ChatType.PROJECT: + if not await can_access_project_chat( + self.user.pk, event.content.get("chat_id") + ): + await self.send_json({"error": "У вас нет доступа к чату проекта."}) + return self.event = ProjectEvent( self.user, self.channel_layer, self.channel_name ) @@ -188,6 +204,9 @@ async def __process_typing_event(self, event: Event, room_name: str): }, } + if event.content["chat_type"] == ChatType.PROJECT: + event_data["_project_chat_id"] = event.content["chat_id"] + if event.content["chat_type"] == ChatType.DIRECT: # fixme: need to move this to func chat_id, other_user = await get_chat_and_user_ids_from_content( @@ -217,17 +236,33 @@ async def __process_typing_event(self, event: Event, room_name: str): event_data, ) + async def _send_chat_event(self, event): + chat_id = event.get("_project_chat_id") + if chat_id is not None: + if not self.user or not await can_access_project_chat(self.user.pk, chat_id): + room_name = f"{EventGroupType.CHATS_RELATED}_{chat_id}" + await self.channel_layer.group_discard(room_name, self.channel_name) + self.joined_rooms.discard(room_name) + return + # Служебный контекст нужен для проверки уже открытых сокетов, + # клиентский формат сообщений остается прежним. + await self.send( + json.dumps( + {key: value for key, value in event.items() if key != "_project_chat_id"} + ) + ) + async def message_read(self, event: Event): - await self.send(json.dumps(event)) + await self._send_chat_event(event) async def user_typing(self, event: Event): - await self.send(json.dumps(event)) + await self._send_chat_event(event) async def new_message(self, event: Event): - await self.send(json.dumps(event)) + await self._send_chat_event(event) async def delete_message(self, event: Event): - await self.send(json.dumps(event)) + await self._send_chat_event(event) async def set_online(self, event: Event): await self.send(json.dumps(event)) @@ -236,7 +271,7 @@ async def set_offline(self, event: Event): await self.send(json.dumps(event)) async def edit_message(self, event: Event): - await self.send(json.dumps(event)) + await self._send_chat_event(event) async def __process_general_event(self, event: Event, room_name: str): cache_key = get_user_online_cache_key(self.user) diff --git a/chats/consumers/event_types/ProjectEvent.py b/chats/consumers/event_types/ProjectEvent.py index 47ba6cae..96461184 100644 --- a/chats/consumers/event_types/ProjectEvent.py +++ b/chats/consumers/event_types/ProjectEvent.py @@ -66,9 +66,9 @@ async def process_new_message_event(self, event: Event, room_name: str): await match_files_and_messages(event.content["file_urls"], messages) serialized_message = await orm_get( - ProjectChatMessage.objects.select_related("author", "reply_to__author").prefetch_related( - "file_to_message__file" - ), + ProjectChatMessage.objects.select_related( + "author", "reply_to__author" + ).prefetch_related("file_to_message__file"), pk=msg.pk, ) message_data = ProjectChatMessageListSerializer(serialized_message).data @@ -77,7 +77,11 @@ async def process_new_message_event(self, event: Event, room_name: str): "message": message_data, } - event_data = {"type": EventType.NEW_MESSAGE, "content": content} + event_data = { + "type": EventType.NEW_MESSAGE, + "content": content, + "_project_chat_id": chat_id, + } await self.channel_layer.group_send(room_name, event_data) @@ -111,6 +115,7 @@ async def process_read_message_event(self, event: Event, room_name: str): "user_id": self.user.id, "message_id": event.content["message_id"], }, + "_project_chat_id": event.content["chat_id"], }, ) @@ -130,7 +135,9 @@ async def process_delete_message_event(self, event: Event, room_name: str): f"User {self.user.id} is not in project chat {chat_id}" ) - message = await orm_get(ProjectChatMessage.objects, pk=event.content["message_id"]) + message = await orm_get( + ProjectChatMessage.objects, pk=event.content["message_id"] + ) if self.user.id != message.author_id: raise UserIsNotAuthor(f"User {self.user.id} is not author {chat_id}") @@ -142,6 +149,7 @@ async def process_delete_message_event(self, event: Event, room_name: str): { "type": EventType.DELETE_MESSAGE, "content": {"message_id": event.content["message_id"]}, + "_project_chat_id": event.content["chat_id"], }, ) @@ -161,7 +169,9 @@ async def process_edit_message_event(self, event, room_name): f"User {self.user.id} is not in project chat {chat_id}" ) - message = await orm_get(ProjectChatMessage.objects, pk=event.content["message_id"]) + message = await orm_get( + ProjectChatMessage.objects, pk=event.content["message_id"] + ) if message.author_id != self.user.id: raise UserNotMessageAuthorException( @@ -172,9 +182,9 @@ async def process_edit_message_event(self, event, room_name): await orm_save(message, update_fields=["text", "is_edited"]) serialized_message = await orm_get( - ProjectChatMessage.objects.select_related("author", "reply_to__author").prefetch_related( - "file_to_message__file" - ), + ProjectChatMessage.objects.select_related( + "author", "reply_to__author" + ).prefetch_related("file_to_message__file"), pk=message.pk, ) message_data = ProjectChatMessageListSerializer(serialized_message).data @@ -183,6 +193,10 @@ async def process_edit_message_event(self, event, room_name): "message": message_data, } - event_data = {"type": EventType.EDIT_MESSAGE, "content": content} + event_data = { + "type": EventType.EDIT_MESSAGE, + "content": content, + "_project_chat_id": chat_id, + } await self.channel_layer.group_send(room_name, event_data) diff --git a/partner_programs/serializers/management.py b/partner_programs/serializers/management.py index 44f86b8a..fa8e01de 100644 --- a/partner_programs/serializers/management.py +++ b/partner_programs/serializers/management.py @@ -142,6 +142,16 @@ def validate_ids(self, values): class ManagementRemovalWrite(ManagementIdsWrite): preview_token = serializers.CharField(max_length=200000) + confirmation_version = serializers.IntegerField(required=False) + + def validate(self, attrs): + if attrs.get("confirmation_version") != 2: + raise serializers.ValidationError( + { + "detail": "Обновите страницу и заново подтвердите удаление участников из команд заявок." + } + ) + return attrs BASIC_FIELDS = ( diff --git a/partner_programs/services/participant_removal.py b/partner_programs/services/participant_removal.py index 912f7cdf..3fe93abb 100644 --- a/partner_programs/services/participant_removal.py +++ b/partner_programs/services/participant_removal.py @@ -1,18 +1,25 @@ -"""Удаление регистрации организатором с явным подтверждением заявок лидеров.""" +"""Удаление регистрации и членства в командах заявок с подписанным preview.""" import logging +from collections import Counter from django.core import signing from django.db import transaction +from django.db.models import Q from rest_framework.exceptions import APIException +from invites.models import Invite from partner_programs.models import PartnerProgram, PartnerProgramProject -from partner_programs.services.management import managed_program, participant_rows +from partner_programs.services.management import ( + managed_program, + participant_rows, + person_name, +) from project_rates.models import ProjectExpertAssignment, ProjectScore -from projects.models import Project +from projects.models import Collaborator, Project logger = logging.getLogger(__name__) -SALT = "management-participant-removal-v1" +SALT = "management-participant-removal-v2" class RemovalConflict(APIException): @@ -46,8 +53,45 @@ def applications(program, profiles): ) -def snapshot(program, profiles, links): - projects = [link.project_id for link in links] +def application_effects(program, profiles): + links = applications(program, profiles) + user_ids = [p.user_id for p in profiles] + member_projects = Collaborator.objects.filter(user_id__in=user_ids).values( + "project_id" + ) + invitation_projects = ( + Invite.objects.filter(user_id__in=user_ids) + .filter(Q(program_link__partner_program=program) | Q(program_link__isnull=True)) + .values("project_id") + ) + team_links = list( + PartnerProgramProject.objects.filter(partner_program=program) + .filter(Q(project_id__in=member_projects) | Q(project_id__in=invitation_projects)) + .exclude(project_id__in=[link.project_id for link in links]) + .select_related("project") + .order_by("pk") + ) + if ( + PartnerProgramProject.objects.filter( + project_id__in=[link.project_id for link in team_links] + ) + .exclude(partner_program=program) + .exists() + ): + raise RemovalConflict( + "shared_application_team", + "Команда одной из заявок общая для нескольких программ. " + "Удаление отменено, чтобы не изменить другую программу.", + ) + return links, team_links + + +def affected_project_ids(links, team_links): + return {link.project_id for link in [*links, *team_links]} + + +def snapshot(program, profiles, links, team_links): + projects = affected_project_ids(links, team_links) return { "profiles": [[p.pk, p.user_id] for p in profiles], "applications": [ @@ -60,6 +104,37 @@ def snapshot(program, profiles, links): ] for link in links ], + "team_applications": [ + [ + link.pk, + link.project_id, + link.project.leader_id, + link.submitted, + link.datetime_updated.isoformat(), + ] + for link in team_links + ], + "project_links": list( + PartnerProgramProject.objects.filter(project_id__in=projects) + .order_by("pk") + .values_list("pk", "project_id", "partner_program_id") + ), + "members": list( + Collaborator.objects.filter(project_id__in=projects) + .order_by("pk") + .values_list("pk", "project_id", "user_id") + ), + "invites": [ + [ + row.pk, + row.project_id, + row.user_id, + row.program_link_id, + row.is_accepted, + row.datetime_updated.isoformat(), + ] + for row in Invite.objects.filter(project_id__in=projects).order_by("pk") + ], "assignments": list( ProjectExpertAssignment.objects.filter( partner_program=program, project_id__in=projects @@ -77,19 +152,19 @@ def snapshot(program, profiles, links): } -def normalized_snapshot(program, profiles, links): +def normalized_snapshot(program, profiles, links, team_links): # JSON-signing сохраняет tuples как lists; оба прохода сравнивают одну форму. - state = snapshot(program, profiles, links) - state["assignments"] = [list(row) for row in state["assignments"]] - state["scores"] = [list(row) for row in state["scores"]] + state = snapshot(program, profiles, links, team_links) + for key in ("assignments", "scores", "project_links", "members"): + state[key] = [list(row) for row in state[key]] return state def preview_removal(program_id, actor, ids): program = managed_program(program_id, actor) profiles = selected_profiles(program, ids) - links = applications(program, profiles) - state = normalized_snapshot(program, profiles, links) + links, team_links = application_effects(program, profiles) + state = normalized_snapshot(program, profiles, links, team_links) token = signing.dumps( { "program": program.pk, @@ -100,7 +175,24 @@ def preview_removal(program_id, actor, ids): salt=SALT, compress=True, ) + user_ids = {p.user_id for p in profiles} + team_project_ids = {link.project_id for link in team_links} + team_members = {project_id: [] for project_id in team_project_ids} + for member in ( + Collaborator.objects.filter(project_id__in=team_project_ids, user_id__in=user_ids) + .select_related("user") + .order_by("pk") + ): + team_members[member.project_id].append( + {"id": member.user_id, "name": person_name(member.user)} + ) + invitation_counts = Counter( + row[1] + for row in state["invites"] + if row[1] in team_project_ids and row[2] in user_ids + ) return { + "confirmation_version": 2, "preview_token": token, "participant_count": len(profiles), "applications": [ @@ -112,7 +204,20 @@ def preview_removal(program_id, actor, ids): } for link in links ], - "assignments_count": len(state["assignments"]), + "assignments_count": ProjectExpertAssignment.objects.filter( + partner_program=program, project_id__in=[link.project_id for link in links] + ).count(), + "team_changes": [ + { + "id": link.pk, + "project_id": link.project_id, + "name": link.project.name, + "submitted": link.submitted, + "members": team_members[link.project_id], + "invitations_count": invitation_counts[link.project_id], + } + for link in team_links + ], } @@ -134,11 +239,8 @@ def remove_participants(program_id, actor, ids, token): raise RemovalConflict() profiles = selected_profiles(program, ids) user_ids = [p.user_id for p in profiles] - project_ids = set( - PartnerProgramProject.objects.filter( - partner_program=program, project__leader_id__in=user_ids - ).values_list("project_id", flat=True) - ) + links, team_links = application_effects(program, profiles) + project_ids = affected_project_ids(links, team_links) # Тот же порядок, что у lock_team: сначала Project, затем программы и профили. # Все проекты блокируются до программ, чтобы массовая операция не меняла порядок. # FK-проверка новой оценки берет KEY SHARE проекта при commit. Разрешаем ее, @@ -160,28 +262,43 @@ def remove_participants(program_id, actor, ids, token): ) program = managed_program(program_id, actor) profiles = selected_profiles(program, ids, lock=True) - links = applications(program, profiles) - if {link.project_id for link in links} != project_ids or normalized_snapshot( - program, profiles, links + links, team_links = application_effects(program, profiles) + if affected_project_ids(links, team_links) != project_ids or normalized_snapshot( + program, profiles, links, team_links ) != proof.get("state"): raise RemovalConflict() link_ids = [link.pk for link in links] + team_project_ids = [link.project_id for link in team_links] + removed_members, _ = Collaborator.objects.filter( + project_id__in=team_project_ids, user_id__in=user_ids + ).delete() + # Обработанные Invite тоже дают READ в приватный проект. Их нельзя оставлять + # после исключения или дать повторно принять старое pending-приглашение. + Invite.objects.filter( + Q(project_id__in=team_project_ids, user_id__in=user_ids) + | Q(program_link_id__in=link_ids) + ).delete() ProjectExpertAssignment.objects.filter( - partner_program=program, project_id__in=project_ids + partner_program=program, project_id__in=[link.project_id for link in links] ).delete() # Отдельная команда организатора с подписанным preview. Обычный model.delete # по-прежнему запрещает участнику удалять сданную заявку. Здесь удаляется - # только program link и его поля; Project, Collaborator и ProjectScore целы. + # program link и его поля; сами Project и ProjectScore сохраняются. PartnerProgramProject.objects.filter( partner_program=program, pk__in=link_ids ).delete() for profile in profiles: profile.delete() logger.info( - "Program registration removal program=%s actor=%s profiles=%s applications=%s", + "Program registration removal program=%s actor=%s profiles=%s applications=%s team_projects=%s", program.pk, actor.pk, ids, link_ids, + team_project_ids, ) - return {"removed": len(profiles), "removed_applications": len(link_ids)} + return { + "removed": len(profiles), + "removed_applications": len(link_ids), + "removed_team_members": removed_members, + } diff --git a/partner_programs/tests/test_management_application_team_removal.py b/partner_programs/tests/test_management_application_team_removal.py new file mode 100644 index 00000000..0accf1dc --- /dev/null +++ b/partner_programs/tests/test_management_application_team_removal.py @@ -0,0 +1,357 @@ +from asgiref.sync import async_to_sync +from channels.testing import WebsocketCommunicator +from django.core import signing +from django.test import TestCase, TransactionTestCase, override_settings +from rest_framework.test import APIClient + +from chats.consumers import ChatConsumer +from chats.models import ProjectChat +from invites.models import Invite +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile +from partner_programs.services.participant_removal import ( + preview_removal, + remove_participants, +) +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_project, + create_user, +) +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore +from project_rates.tests.helpers import create_rate_expert +from projects.models import Collaborator, Project + + +class ApplicationTeamFixture: + def seed(self): + self.manager = create_user() + self.program = create_partner_program( + is_competitive=True, is_distributed_evaluation=True, legacy_team_min_size=1 + ) + self.program.managers.add(self.manager) + self.leader = create_program_member(self.program) + self.member = create_program_member(self.program) + self.foreign = create_partner_program() + self.foreign_member = create_program_member(self.foreign, user=self.member.user) + create_program_member(self.foreign, user=self.leader.user) + self.source = create_project( + leader=self.leader.user, name="Исходный проект", draft=False, is_public=True + ) + Collaborator.objects.create(project=self.source, user=self.member.user) + self.source_link = PartnerProgramProject.objects.create( + partner_program=self.foreign, project=self.source + ) + self.source_invite = Invite.objects.create( + project=self.source, user=self.member.user, is_accepted=True + ) + self.client = APIClient() + self.client.force_authenticate(self.leader.user) + response = self.client.post( + "/projects/assign-to-program/", + {"project_id": self.source.pk, "partner_program_id": self.program.pk}, + format="json", + ) + self.assertEqual(response.status_code, 201, response.data) + self.project = Project.objects.get(pk=response.data["new_project_id"]) + self.link = PartnerProgramProject.objects.get(pk=response.data["program_link_id"]) + self.assertNotEqual(self.source.pk, self.project.pk) + self.project.draft = False + self.project.save(update_fields=["draft"]) + self.invite = Invite.objects.create( + project=self.project, + program_link=self.link, + user=self.member.user, + is_accepted=True, + ) + self.root = f"/programs/{self.program.pk}/management/participants/" + self.client.force_authenticate(self.manager) + + def preview(self, ids=None): + response = self.client.post( + self.root + "remove-preview/", {"ids": ids or [self.member.pk]}, format="json" + ) + self.assertEqual(response.status_code, 200, response.data) + return response.data + + def remove(self, preview, ids=None, version=2): + return self.client.post( + self.root + "remove/", + { + "ids": ids or [self.member.pk], + "preview_token": preview["preview_token"], + "confirmation_version": version, + }, + format="json", + ) + + +class ApplicationTeamRemovalTests(ApplicationTeamFixture, TestCase): + def setUp(self): + self.seed() + + def test_pending_invitation_without_team_membership_is_revoked(self): + Collaborator.objects.filter(project=self.project, user=self.member.user).delete() + Invite.objects.filter(pk=self.invite.pk).delete() + self.invite = Invite.objects.create( + project=self.project, program_link=self.link, user=self.member.user + ) + preview = self.preview() + self.assertEqual(preview["team_changes"][0]["members"], []) + self.assertEqual(preview["team_changes"][0]["invitations_count"], 1) + response = self.remove(preview) + self.assertEqual(response.status_code, 200, response.data) + self.assertFalse(Invite.objects.filter(pk=self.invite.pk).exists()) + self.client.force_authenticate(self.member.user) + self.assertEqual( + self.client.get(f"/projects/{self.project.pk}/").status_code, 403 + ) + self.assertTrue(Invite.objects.filter(pk=self.source_invite.pk).exists()) + + def test_real_duplicate_loses_member_but_source_and_other_program_are_unchanged(self): + preview = self.preview() + self.assertEqual(preview["confirmation_version"], 2) + self.assertEqual(preview["applications"], []) + self.assertEqual(preview["team_changes"][0]["project_id"], self.project.pk) + self.assertEqual( + preview["team_changes"][0]["members"][0]["id"], self.member.user_id + ) + self.assertEqual(preview["team_changes"][0]["invitations_count"], 1) + response = self.remove(preview) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["removed_team_members"], 1) + self.assertFalse( + PartnerProgramUserProfile.objects.filter(pk=self.member.pk).exists() + ) + self.assertFalse( + Collaborator.objects.filter( + project=self.project, user=self.member.user + ).exists() + ) + self.assertFalse(Invite.objects.filter(pk=self.invite.pk).exists()) + self.assertTrue( + Collaborator.objects.filter( + project=self.source, user=self.member.user + ).exists() + ) + self.assertTrue(Invite.objects.filter(pk=self.source_invite.pk).exists()) + self.assertTrue( + PartnerProgramUserProfile.objects.filter(pk=self.foreign_member.pk).exists() + ) + self.assertTrue(PartnerProgramProject.objects.filter(pk=self.link.pk).exists()) + self.assertTrue( + PartnerProgramProject.objects.filter(pk=self.source_link.pk).exists() + ) + self.assertTrue( + type(self.member.user).objects.filter(pk=self.member.user_id).exists() + ) + self.client.force_authenticate(self.member.user) + self.assertEqual( + self.client.get(f"/projects/{self.project.pk}/").status_code, 403 + ) + self.assertEqual(self.client.get(f"/projects/{self.source.pk}/").status_code, 200) + self.assertEqual( + self.client.get( + f"/chats/projects/{ProjectChat.objects.get(project=self.project).pk}/" + ).status_code, + 403, + ) + self.assertEqual( + self.client.get( + f"/chats/projects/{ProjectChat.objects.get(project=self.source).pk}/" + ).status_code, + 200, + ) + + def test_remaining_registered_team_can_submit_after_removal(self): + self.assertEqual(self.remove(self.preview()).status_code, 200) + self.client.force_authenticate(self.leader.user) + response = self.client.post( + f"/programs/partner-program-projects/{self.link.pk}/submit/" + ) + self.assertEqual(response.status_code, 200, response.data) + self.link.refresh_from_db() + self.assertTrue(self.link.submitted) + + def test_existing_minimum_team_size_still_applies(self): + self.program.legacy_team_min_size = 2 + self.program.save(update_fields=["legacy_team_min_size"]) + self.assertEqual(self.remove(self.preview()).status_code, 200) + self.client.force_authenticate(self.leader.user) + response = self.client.post( + f"/programs/partner-program-projects/{self.link.pk}/submit/" + ) + self.assertEqual(response.status_code, 409, response.data) + self.assertEqual(response.data["code"], "team_min_size") + + def test_submitted_application_scores_and_assignments_are_preserved(self): + self.link.submitted = True + self.link.save(update_fields=["submitted"]) + expert = create_rate_expert(program=self.program) + criterion = Criteria.objects.create( + partner_program=self.program, + name="Балл", + type="int", + min_value=0, + max_value=10, + ) + assignment = ProjectExpertAssignment.objects.create( + partner_program=self.program, project=self.project, expert=expert.expert + ) + score = ProjectScore.objects.create( + project=self.project, criteria=criterion, user=expert, value="7" + ) + preview = self.preview() + self.assertTrue(preview["team_changes"][0]["submitted"]) + self.assertEqual(self.remove(preview).status_code, 200) + self.assertTrue(ProjectScore.objects.filter(pk=score.pk, value="7").exists()) + self.assertTrue(ProjectExpertAssignment.objects.filter(pk=assignment.pk).exists()) + self.link.refresh_from_db() + self.assertTrue(self.link.submitted) + + def test_changed_team_or_invitation_requires_fresh_preview(self): + preview = self.preview() + Invite.objects.filter(pk=self.invite.pk).delete() + response = self.remove(preview) + self.assertEqual(response.status_code, 409) + self.assertTrue( + Collaborator.objects.filter( + project=self.project, user=self.member.user + ).exists() + ) + self.assertTrue( + PartnerProgramUserProfile.objects.filter(pk=self.member.pk).exists() + ) + preview = self.preview() + newcomer = create_program_member(self.program) + Collaborator.objects.create(project=self.project, user=newcomer.user) + self.assertEqual(self.remove(preview).status_code, 409) + + def test_shared_link_added_after_preview_aborts_whole_batch(self): + preview = self.preview() + PartnerProgramProject.objects.create( + partner_program=self.foreign, project=self.project + ) + self.assertEqual(self.remove(preview).status_code, 409) + self.assertTrue( + PartnerProgramUserProfile.objects.filter(pk=self.member.pk).exists() + ) + self.assertTrue( + Collaborator.objects.filter( + project=self.project, user=self.member.user + ).exists() + ) + + def test_old_client_and_old_confirmation_cannot_remove_team_members(self): + preview = self.preview() + old_client = self.client.post( + self.root + "remove/", + {"ids": [self.member.pk], "preview_token": preview["preview_token"]}, + format="json", + ) + self.assertEqual(old_client.status_code, 400) + self.assertEqual(self.remove(preview, version=1).status_code, 400) + old_token = signing.dumps( + { + "program": self.program.pk, + "actor": self.manager.pk, + "ids": [self.member.pk], + }, + salt="management-participant-removal-v1", + ) + self.assertEqual(self.remove({"preview_token": old_token}).status_code, 409) + self.assertTrue( + Collaborator.objects.filter( + project=self.project, user=self.member.user + ).exists() + ) + self.assertTrue( + PartnerProgramUserProfile.objects.filter(pk=self.member.pk).exists() + ) + + def test_leader_application_unlinks_even_with_scoped_invites_and_preserves_project( + self, + ): + self.assertEqual( + self.remove(self.preview([self.leader.pk]), [self.leader.pk]).status_code, 200 + ) + self.assertFalse(PartnerProgramProject.objects.filter(pk=self.link.pk).exists()) + self.assertFalse(Invite.objects.filter(pk=self.invite.pk).exists()) + self.assertTrue(Project.objects.filter(pk=self.project.pk).exists()) + self.assertTrue( + Collaborator.objects.filter( + project=self.source, user=self.member.user + ).exists() + ) + + +@override_settings(RUNNING_TESTS=False) +class ApplicationChatRemovalTests(ApplicationTeamFixture, TransactionTestCase): + def setUp(self): + self.seed() + + def test_open_socket_cannot_receive_or_type_in_removed_application_but_keeps_source( + self, + ): + from channels.db import database_sync_to_async + + app_chat = ProjectChat.objects.get(project=self.project) + source_chat = ProjectChat.objects.get(project=self.source) + + async def scenario(): + member = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") + member.scope["user"] = self.member.user + leader = WebsocketCommunicator(ChatConsumer.as_asgi(), "/ws/chat/") + leader.scope["user"] = self.leader.user + try: + self.assertTrue((await member.connect(timeout=5))[0]) + self.assertTrue((await leader.connect(timeout=5))[0]) + await member.receive_json_from(timeout=5) + preview = await database_sync_to_async(preview_removal)( + self.program.pk, self.manager, [self.member.pk] + ) + await database_sync_to_async(remove_participants)( + self.program.pk, + self.manager, + [self.member.pk], + preview["preview_token"], + ) + + def message(chat_id): + return { + "type": "new_message", + "content": { + "chat_type": "project", + "chat_id": str(chat_id), + "text": "Тест", + "reply_to": None, + "file_urls": [], + }, + } + + await leader.send_json_to(message(app_chat.pk)) + event = await leader.receive_json_from(timeout=5) + self.assertNotIn("_project_chat_id", event) + self.assertTrue(await member.receive_nothing(timeout=0.2)) + await member.send_json_to( + { + "type": "user_typing", + "content": {"chat_type": "project", "chat_id": str(app_chat.pk)}, + } + ) + self.assertIn("error", await member.receive_json_from(timeout=5)) + self.assertTrue(await leader.receive_nothing(timeout=0.2)) + await leader.send_json_to(message(source_chat.pk)) + await leader.receive_json_from(timeout=5) + self.assertEqual( + (await member.receive_json_from(timeout=5))["content"]["chat_id"], + str(source_chat.pk), + ) + finally: + import asyncio + + await asyncio.gather( + member.disconnect(), leader.disconnect(), return_exceptions=True + ) + + async_to_sync(scenario)() diff --git a/partner_programs/tests/test_management_bulk.py b/partner_programs/tests/test_management_bulk.py index 256ca695..3f26239d 100644 --- a/partner_programs/tests/test_management_bulk.py +++ b/partner_programs/tests/test_management_bulk.py @@ -187,6 +187,8 @@ def setUp(self): self.client.force_authenticate(self.manager) def post(self, suffix, data): + if suffix == "participants/remove": + data = {"confirmation_version": 2, **data} return self.client.post(self.root + suffix + "/", data, format="json") def preview(self, ids): @@ -254,15 +256,11 @@ def test_selected_export_preserves_activity_date_and_uses_registration_header(se 409, ) - def test_member_removal_preserves_shared_team_project_account_and_other_program(self): - preview = self.preview([self.member.pk]) - self.assertEqual(preview["applications"], []) - response = self.post( - "participants/remove", - {"ids": [self.member.pk], "preview_token": preview["preview_token"]}, - ) - self.assertEqual(response.status_code, 200, response.data) - self.assertFalse( + def test_shared_project_team_cannot_be_changed_for_one_program(self): + response = self.post("participants/remove-preview", {"ids": [self.member.pk]}) + self.assertEqual(response.status_code, 409, response.data) + self.assertEqual(response.data["code"], "shared_application_team") + self.assertTrue( PartnerProgramUserProfile.objects.filter(pk=self.member.pk).exists() ) self.assertTrue(