From dbd56677bef732114f220383e5eaa725592f988b Mon Sep 17 00:00:00 2001 From: DarKylX Date: Wed, 21 Oct 2026 00:13:13 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=D0=BC=D0=B0=D1=81=D1=81=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=B5=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BC=D0=BC=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- partner_programs/management_views.py | 92 +++++ partner_programs/serializers/management.py | 18 +- partner_programs/services/management.py | 5 + .../services/participant_removal.py | 181 +++++++++ .../services/participants_excel.py | 2 +- .../tests/test_management_bulk.py | 354 ++++++++++++++++++ partner_programs/urls.py | 23 ++ project_rates/workspace.py | 45 +++ project_rates/workspace_views.py | 33 ++ 9 files changed, 751 insertions(+), 2 deletions(-) create mode 100644 partner_programs/services/participant_removal.py create mode 100644 partner_programs/tests/test_management_bulk.py diff --git a/partner_programs/management_views.py b/partner_programs/management_views.py index 2168a648..36775563 100644 --- a/partner_programs/management_views.py +++ b/partner_programs/management_views.py @@ -16,6 +16,8 @@ ManagementProjectQuery, ManagementSettingsSerializer, ManagementMaterialWrite, + ManagementIdsWrite, + ManagementRemovalWrite, ) from partner_programs.services.case_fields import get_program_case_field from partner_programs.services.case_analytics import project_case_links @@ -103,6 +105,27 @@ def get(self, request, pk): class ManagementParticipantsExportView(ManagementBase): + def post(self, request, pk): + from partner_programs.services.participants_excel import export_participants + + program = managed_program(pk, request.user) + body = ManagementIdsWrite(data=request.data) + body.is_valid(raise_exception=True) + ids = body.validated_data["ids"] + rows = participant_rows(program).filter(pk__in=ids) + if rows.count() != len(ids): + return conflict( + "selection_changed", "Состав участников изменился. Обновите выбор." + ) + response = HttpResponse( + export_participants(rows), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + response[ + "Content-Disposition" + ] = f'attachment; filename="program-{pk}-participants.xlsx"' + return response + def get(self, request, pk): from partner_programs.services.participants_excel import export_participants @@ -122,6 +145,75 @@ def get(self, request, pk): return response +class ManagementSelectionView(ManagementBase): + def get(self, request, pk, kind): + program = managed_program(pk, request.user) + field = get_program_case_field(program) + schema = ( + ManagementParticipantQuery + if kind == "participants" + else ManagementProjectQuery + ) + query = schema( + data=request.query_params, + context={"case_options": field.get_options_list() if field else []}, + ) + query.is_valid(raise_exception=True) + if kind == "participants": + ids = list( + participant_rows( + program, query.validated_data["search"], query.validated_data + ).values_list("pk", flat=True) + ) + return Response({"ids": ids}) + rows = management_project_rows(program, query.validated_data, field) + projects = list( + rows.values("id", "project_id", "submitted", "case_name", "project__draft") + ) + return Response( + { + "ids": [p["id"] for p in projects], + "projects": [ + { + "id": p["id"], + "project_id": p["project_id"], + "submitted": p["submitted"], + "case_name": p["case_name"], + "draft": p["project__draft"], + } + for p in projects + ], + } + ) + + +class ManagementParticipantsRemovalPreviewView(ManagementBase): + def post(self, request, pk): + from partner_programs.services.participant_removal import preview_removal + + managed_program(pk, request.user) + body = ManagementIdsWrite(data=request.data) + body.is_valid(raise_exception=True) + return Response(preview_removal(pk, request.user, body.validated_data["ids"])) + + +class ManagementParticipantsRemovalView(ManagementBase): + def post(self, request, pk): + from partner_programs.services.participant_removal import remove_participants + + managed_program(pk, request.user) + body = ManagementRemovalWrite(data=request.data) + body.is_valid(raise_exception=True) + return Response( + remove_participants( + pk, + request.user, + body.validated_data["ids"], + body.validated_data["preview_token"], + ) + ) + + class ManagementProjectsView(ManagementBase): def get(self, request, pk): program = managed_program(pk, request.user) diff --git a/partner_programs/serializers/management.py b/partner_programs/serializers/management.py index ee5fd7e6..bec9c4b6 100644 --- a/partner_programs/serializers/management.py +++ b/partner_programs/serializers/management.py @@ -64,7 +64,7 @@ class ManagementListQuery(StrictFieldsMixin, serializers.Serializer): search = serializers.CharField( required=False, allow_blank=True, max_length=200, default="" ) - limit = serializers.IntegerField(min_value=1, max_value=100, default=25) + limit = serializers.IntegerField(min_value=1, max_value=100, default=20) offset = serializers.IntegerField(min_value=0, default=0) @@ -102,6 +102,9 @@ def validate(self, attrs): class ManagementProjectQuery(ManagementListQuery): + evaluation = serializers.ChoiceField( + choices=("all", "evaluated", "not_evaluated"), default="all" + ) submission = serializers.ChoiceField( choices=("all", "submitted", "not_submitted"), default="all" ) @@ -123,6 +126,19 @@ def validate(self, attrs): return attrs +class ManagementIdsWrite(StrictFieldsMixin, serializers.Serializer): + ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), allow_empty=False, max_length=10000 + ) + + def validate_ids(self, values): + return sorted(set(values)) + + +class ManagementRemovalWrite(ManagementIdsWrite): + preview_token = serializers.CharField(max_length=200000) + + BASIC_FIELDS = ( "name", "city", diff --git a/partner_programs/services/management.py b/partner_programs/services/management.py index 1226a622..67532c5c 100644 --- a/partner_programs/services/management.py +++ b/partner_programs/services/management.py @@ -152,6 +152,10 @@ def management_project_rows(program, query, field): rows = rows.filter(project__name__icontains=query["search"]) if query["submission"] != "all": rows = rows.filter(submitted=query["submission"] == "submitted") + if query.get("evaluation", "all") == "evaluated": + rows = rows.filter(status="evaluated") + elif query.get("evaluation") == "not_evaluated": + rows = rows.exclude(status="evaluated") if query["case_scope"] == "selected": rows = rows.filter(case_name=query["case_name"]) elif query["case_scope"] == "without_case": @@ -170,6 +174,7 @@ def project_data(link, program): "name": person_name(link.project.leader), }, "submitted": link.submitted, + "draft": link.project.draft, "evaluation_status": status, "assignments_total": link.assignments_total, "assignments_completed": link.assignments_completed, diff --git a/partner_programs/services/participant_removal.py b/partner_programs/services/participant_removal.py new file mode 100644 index 00000000..9bb2ecb1 --- /dev/null +++ b/partner_programs/services/participant_removal.py @@ -0,0 +1,181 @@ +"""Удаление регистрации организатором с явным подтверждением заявок лидеров.""" + +import logging + +from django.core import signing +from django.db import transaction +from rest_framework.exceptions import APIException + +from partner_programs.models import PartnerProgram, PartnerProgramProject +from partner_programs.services.management import managed_program, participant_rows +from project_rates.models import ProjectExpertAssignment, ProjectScore +from projects.models import Project + +logger = logging.getLogger(__name__) +SALT = "management-participant-removal-v1" + + +class RemovalConflict(APIException): + status_code = 409 + + def __init__( + self, + code="selection_changed", + detail="Состав участников или заявки изменились. Обновите список и повторите удаление.", + ): + super().__init__({"code": code, "detail": detail}) + + +def selected_profiles(program, ids, *, lock=False): + rows = participant_rows(program).filter(pk__in=ids).order_by("pk") + if lock: + rows = rows.select_for_update(of=("self",)) + profiles = list(rows) + if len(profiles) != len(set(ids)): + raise RemovalConflict() + return profiles + + +def applications(program, profiles): + return list( + PartnerProgramProject.objects.filter( + partner_program=program, project__leader_id__in=[p.user_id for p in profiles] + ) + .select_related("project") + .order_by("pk") + ) + + +def snapshot(program, profiles, links): + projects = [link.project_id for link in links] + return { + "profiles": [[p.pk, p.user_id] for p in profiles], + "applications": [ + [ + link.pk, + link.project_id, + link.project.leader_id, + link.submitted, + link.datetime_updated.isoformat(), + ] + for link in links + ], + "assignments": list( + ProjectExpertAssignment.objects.filter( + partner_program=program, project_id__in=projects + ) + .order_by("pk") + .values_list("pk", "project_id", "expert_id") + ), + "scores": list( + ProjectScore.objects.filter( + criteria__partner_program=program, project_id__in=projects + ) + .order_by("pk") + .values_list("pk", "project_id", "criteria_id", "user_id", "value") + ), + } + + +def normalized_snapshot(program, profiles, 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"]] + 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) + token = signing.dumps( + { + "program": program.pk, + "actor": actor.pk, + "ids": sorted(set(ids)), + "state": state, + }, + salt=SALT, + compress=True, + ) + return { + "preview_token": token, + "participant_count": len(profiles), + "applications": [ + { + "id": link.pk, + "project_id": link.project_id, + "name": link.project.name, + "submitted": link.submitted, + } + for link in links + ], + "assignments_count": len(state["assignments"]), + } + + +@transaction.atomic +def remove_participants(program_id, actor, ids, token): + program = managed_program(program_id, actor) + try: + proof = signing.loads(token, salt=SALT, max_age=900) + except signing.BadSignature: + raise RemovalConflict( + "preview_expired", "Подтверждение устарело. Откройте удаление еще раз." + ) + ids = sorted(set(ids)) + if ( + proof.get("program") != program.pk + or proof.get("actor") != actor.pk + or proof.get("ids") != ids + ): + 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) + ) + # Тот же порядок, что у lock_team: сначала Project, затем программы и профили. + # Все проекты блокируются до программ, чтобы массовая операция не меняла порядок. + list(Project.objects.select_for_update().filter(pk__in=project_ids).order_by("pk")) + program_ids = set( + PartnerProgramProject.objects.filter(project_id__in=project_ids).values_list( + "partner_program_id", flat=True + ) + ) | {program.pk} + list( + PartnerProgram.objects.select_for_update() + .filter(pk__in=program_ids) + .order_by("pk") + ) + 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 + ) != proof.get("state"): + raise RemovalConflict() + link_ids = [link.pk for link in links] + ProjectExpertAssignment.objects.filter( + partner_program=program, project_id__in=project_ids + ).delete() + # Отдельная команда организатора с подписанным preview. Обычный model.delete + # по-прежнему запрещает участнику удалять сданную заявку. Здесь удаляется + # только program link и его поля; Project, Collaborator и 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.pk, + actor.pk, + ids, + link_ids, + ) + return {"removed": len(profiles), "removed_applications": len(link_ids)} diff --git a/partner_programs/services/participants_excel.py b/partner_programs/services/participants_excel.py index aa7ed87b..aa41e0bc 100644 --- a/partner_programs/services/participants_excel.py +++ b/partner_programs/services/participants_excel.py @@ -21,7 +21,7 @@ def export_participants(rows): "Участник", "Почта", "Регион", - "В программе с (МСК)", + "Дата регистрации (МСК)", "Последняя активность (МСК)", ] for index, width in enumerate((6, 32, 38, 28, 24, 28), 1): diff --git a/partner_programs/tests/test_management_bulk.py b/partner_programs/tests/test_management_bulk.py new file mode 100644 index 00000000..d65b2e40 --- /dev/null +++ b/partner_programs/tests/test_management_bulk.py @@ -0,0 +1,354 @@ +from datetime import timedelta +from io import BytesIO +from concurrent.futures import ThreadPoolExecutor +from threading import Barrier + +from django.db import close_old_connections +from django.test import TestCase, TransactionTestCase +from django.utils import timezone +from openpyxl import load_workbook +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_program_project, + create_project, + create_user, +) +from project_rates.models import ( + Criteria, + ExpertInvitation, + ProjectExpertAssignment, + ProjectScore, +) +from project_rates.tests.helpers import create_rate_expert +from project_rates.workspace import revision +from projects.models import Collaborator, Project + + +class ParticipantRemovalConcurrencyTests(TransactionTestCase): + def test_same_preview_can_remove_registration_only_once(self): + from partner_programs.services.participant_removal import ( + RemovalConflict, + preview_removal, + remove_participants, + ) + + manager = create_user() + program = create_partner_program() + program.managers.add(manager) + member = create_program_member(program) + preview = preview_removal(program.pk, manager, [member.pk]) + barrier = Barrier(2) + + def remove(): + close_old_connections() + try: + barrier.wait(timeout=5) + try: + result = remove_participants( + program.pk, manager, [member.pk], preview["preview_token"] + ) + return result["removed"] + except RemovalConflict: + return "conflict" + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(lambda _: remove(), range(2))) + self.assertCountEqual(results, [1, "conflict"]) + self.assertFalse(PartnerProgramUserProfile.objects.filter(pk=member.pk).exists()) + self.assertTrue(member.user.__class__.objects.filter(pk=member.user_id).exists()) + + +class ManagementBulkTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.manager = create_user() + cls.program = create_partner_program( + is_competitive=True, is_distributed_evaluation=True, required_evaluators=1 + ) + cls.program.managers.add(cls.manager) + cls.foreign = create_partner_program() + cls.leader = create_program_member(cls.program) + cls.member = create_program_member(cls.program) + cls.foreign_member = create_program_member(cls.foreign, user=cls.member.user) + cls.project = create_project(leader=cls.leader.user) + Collaborator.objects.create(project=cls.project, user=cls.member.user) + cls.link = create_program_project( + cls.program, project=cls.project, submitted=True + ) + cls.foreign_link = create_program_project(cls.foreign, project=cls.project) + cls.expert = create_rate_expert(program=cls.program) + Criteria.objects.filter(partner_program=cls.program).delete() + cls.criterion = Criteria.objects.create( + partner_program=cls.program, + name="Балл", + type="int", + min_value=0, + max_value=10, + ) + cls.assignment = ProjectExpertAssignment.objects.create( + partner_program=cls.program, project=cls.project, expert=cls.expert.expert + ) + cls.score = ProjectScore.objects.create( + criteria=cls.criterion, project=cls.project, user=cls.expert, value="0" + ) + cls.invitation = ExpertInvitation.objects.create( + partner_program=cls.program, + email="bulk-invite@example.test", + token_digest="a" * 64, + expires_at=timezone.now() + timedelta(days=2), + ) + cls.root = f"/programs/{cls.program.pk}/management/" + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.manager) + + def post(self, suffix, data): + return self.client.post(self.root + suffix + "/", data, format="json") + + def preview(self, ids): + response = self.post("participants/remove-preview", {"ids": ids}) + self.assertEqual(response.status_code, 200, response.data) + return response.data + + def test_default_twenty_and_select_all_uses_filtered_full_snapshot(self): + for index in range(23): + create_program_member( + self.program, user=create_user(first_name="Выборка", city="Москва") + ) + page = self.client.get(self.root + "participants/", {"search": "Выборка"}) + self.assertEqual(page.data["count"], 23) + self.assertEqual(len(page.data["results"]), 20) + selection = self.client.get( + self.root + "participants/selection/", + {"search": "Выборка", "limit": 1, "offset": 20}, + ) + self.assertEqual(len(selection.data["ids"]), 23) + self.assertNotIn(self.foreign_member.pk, selection.data["ids"]) + last = self.client.get( + self.root + "participants/", {"search": "Выборка", "offset": 20} + ) + self.assertEqual(len(last.data["results"]), 3) + + def test_project_filter_and_selection_share_status_and_link_id_mapping(self): + incomplete = create_program_project(self.program, submitted=True) + not_submitted = create_program_project(self.program) + done = self.client.get(self.root + "projects/", {"evaluation": "evaluated"}) + self.assertEqual([row["id"] for row in done.data["results"]], [self.link.pk]) + pending = self.client.get( + self.root + "projects/", {"evaluation": "not_evaluated"} + ) + self.assertEqual( + {row["id"] for row in pending.data["results"]}, + {incomplete.pk, not_submitted.pk}, + ) + selection = self.client.get( + self.root + "projects/selection/", + {"evaluation": "evaluated", "limit": 1, "offset": 200}, + ) + self.assertEqual(selection.data["ids"], [self.link.pk]) + self.assertEqual(selection.data["projects"][0]["project_id"], self.project.pk) + self.assertEqual( + self.client.get(self.root + "projects/", {"evaluation": "bad"}).status_code, + 400, + ) + + def test_selected_export_preserves_activity_date_and_uses_registration_header(self): + activity = timezone.now().replace(microsecond=0) + type(self.member.user).objects.filter(pk=self.member.user_id).update( + last_activity=activity + ) + response = self.post("participants/export", {"ids": [self.member.pk]}) + self.assertEqual(response.status_code, 200) + sheet = load_workbook(BytesIO(response.content)).active + self.assertEqual(sheet.max_row, 2) + self.assertEqual(sheet.cell(1, 5).value, "Дата регистрации (МСК)") + self.assertEqual(sheet.cell(2, 6).data_type, "d") + self.assertEqual( + self.post( + "participants/export", {"ids": [self.member.pk, self.foreign_member.pk]} + ).status_code, + 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( + PartnerProgramUserProfile.objects.filter(pk=self.member.pk).exists() + ) + self.assertTrue( + PartnerProgramUserProfile.objects.filter(pk=self.foreign_member.pk).exists() + ) + self.assertTrue(Project.objects.filter(pk=self.project.pk).exists()) + self.assertTrue( + Collaborator.objects.filter( + project=self.project, user=self.member.user + ).exists() + ) + self.assertTrue( + type(self.member.user).objects.filter(pk=self.member.user_id).exists() + ) + self.assertTrue(PartnerProgramProject.objects.filter(pk=self.link.pk).exists()) + + def test_leader_preview_then_removal_unlinks_submitted_application_only(self): + preview = self.preview([self.leader.pk]) + self.assertEqual([row["id"] for row in preview["applications"]], [self.link.pk]) + self.assertEqual(preview["assignments_count"], 1) + response = self.post( + "participants/remove", + {"ids": [self.leader.pk], "preview_token": preview["preview_token"]}, + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertFalse(PartnerProgramProject.objects.filter(pk=self.link.pk).exists()) + self.assertTrue( + PartnerProgramProject.objects.filter(pk=self.foreign_link.pk).exists() + ) + self.assertTrue(Project.objects.filter(pk=self.project.pk).exists()) + self.assertTrue(ProjectScore.objects.filter(pk=self.score.pk).exists()) + self.assertFalse( + ProjectExpertAssignment.objects.filter(pk=self.assignment.pk).exists() + ) + self.assertEqual(self.client.get(self.root + "projects/").data["count"], 0) + + def test_changed_rating_or_selection_requires_fresh_preview_without_partial_removal( + self, + ): + preview = self.preview([self.leader.pk, self.member.pk]) + self.score.value = "7" + self.score.save() + response = self.post( + "participants/remove", + { + "ids": [self.leader.pk, self.member.pk], + "preview_token": preview["preview_token"], + }, + ) + self.assertEqual(response.status_code, 409) + self.assertTrue( + PartnerProgramUserProfile.objects.filter(pk=self.leader.pk).exists() + ) + self.assertTrue( + PartnerProgramUserProfile.objects.filter(pk=self.member.pk).exists() + ) + self.assertTrue(PartnerProgramProject.objects.filter(pk=self.link.pk).exists()) + self.assertEqual( + self.post( + "participants/remove", + {"ids": [self.member.pk], "preview_token": preview["preview_token"]}, + ).status_code, + 409, + ) + + def test_preview_is_required_signed_and_program_actor_scoped(self): + self.assertEqual( + self.post("participants/remove", {"ids": [self.member.pk]}).status_code, 400 + ) + self.assertEqual( + self.post( + "participants/remove", + {"ids": [self.member.pk], "preview_token": "forged"}, + ).status_code, + 409, + ) + self.assertEqual( + self.post( + "participants/remove-preview", {"ids": [self.foreign_member.pk]} + ).status_code, + 409, + ) + for user, expected in [(self.member.user, 403), (None, 401)]: + self.client.force_authenticate(user) + for suffix in [ + "participants/remove-preview", + "participants/remove", + "participants/export", + "experts/bulk", + ]: + self.assertEqual( + self.post(suffix, {"ids": [self.member.pk]}).status_code, expected + ) + for suffix in ["participants/selection/", "projects/selection/"]: + self.assertEqual( + self.client.get(self.root + suffix).status_code, expected + ) + + def test_bulk_expert_cases_and_revoke_validate_revision_and_scope(self): + old_revision = revision(self.program) + payload = { + "action": "cases", + "revision": old_revision, + "expert_ids": [self.expert.expert.pk], + "invitation_ids": [self.invitation.pk], + "all_cases": True, + "case_names": [], + } + response = self.post("experts/bulk", payload) + self.assertEqual(response.status_code, 200, response.data) + self.assertTrue( + ProjectExpertAssignment.objects.filter(pk=self.assignment.pk).exists() + ) + self.assertEqual(self.post("experts/bulk", payload).status_code, 409) + self.assertEqual( + self.post( + "experts/bulk", + {**payload, "action": "revoke", "revision": revision(self.program)}, + ).status_code, + 400, + ) + response = self.post( + "experts/bulk", + { + "action": "revoke", + "revision": revision(self.program), + "invitation_ids": [self.invitation.pk], + }, + ) + self.assertEqual(response.status_code, 200, response.data) + self.invitation.refresh_from_db() + self.assertEqual(self.invitation.status, "revoked") + + def test_bulk_resend_is_all_or_nothing_and_only_enqueues_after_commit(self): + invitation = ExpertInvitation.objects.create( + partner_program=self.program, + email="second-bulk@example.test", + token_digest="b" * 64, + expires_at=timezone.now() + timedelta(days=1), + ) + with self.captureOnCommitCallbacks(execute=False) as callbacks: + response = self.post( + "experts/bulk", + { + "action": "resend", + "revision": revision(self.program), + "invitation_ids": [self.invitation.pk, invitation.pk], + }, + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(len(callbacks), 2) + self.invitation.refresh_from_db() + before = self.invitation.token_digest + with self.captureOnCommitCallbacks(execute=False) as callbacks: + response = self.post( + "experts/bulk", + { + "action": "resend", + "revision": revision(self.program), + "invitation_ids": [self.invitation.pk, 999999], + }, + ) + self.assertEqual(response.status_code, 400) + self.assertEqual(callbacks, []) + self.invitation.refresh_from_db() + self.assertEqual(self.invitation.token_digest, before) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index 83adedbf..c51f3f73 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -51,6 +51,9 @@ ManagementCriteriaView, ManagementParticipantsView, ManagementParticipantsExportView, + ManagementSelectionView, + ManagementParticipantsRemovalPreviewView, + ManagementParticipantsRemovalView, ManagementProjectsView, ManagementTeamView, ManagementEvaluationView, @@ -61,6 +64,7 @@ from project_rates.workspace_views import ( ExpertsView, + ExpertsBulkView, InvitationActionView, ExpertAssignmentsView, ExpertCaseScopeView, @@ -112,6 +116,7 @@ name="management-results-export", ), path("/management/experts/", ExpertsView.as_view()), + path("/management/experts/bulk/", ExpertsBulkView.as_view()), path( "/management/experts//cases/", ExpertCaseScopeView.as_view(), @@ -139,6 +144,24 @@ name="management-project-form", ), path("/management/", ManagementView.as_view(), name="management"), + path( + "/management/participants/selection/", + ManagementSelectionView.as_view(), + {"kind": "participants"}, + ), + path( + "/management/participants/remove-preview/", + ManagementParticipantsRemovalPreviewView.as_view(), + ), + path( + "/management/participants/remove/", + ManagementParticipantsRemovalView.as_view(), + ), + path( + "/management/projects/selection/", + ManagementSelectionView.as_view(), + {"kind": "projects"}, + ), path( "/management/participants/export/", ManagementParticipantsExportView.as_view(), diff --git a/project_rates/workspace.py b/project_rates/workspace.py index 81f26586..8b50ad97 100644 --- a/project_rates/workspace.py +++ b/project_rates/workspace.py @@ -376,6 +376,51 @@ def save_case_scope(program_id, user, expert_id, data): return workspace_data(program) +@transaction.atomic +def bulk_experts(program_id, user, data): + program = managed_program(program_id, user, lock=True) + check_revision(program, data["revision"]) + expert_ids, invitation_ids = set(data["expert_ids"]), set(data["invitation_ids"]) + experts = list( + program.experts.filter(pk__in=expert_ids, user__is_active=True).order_by("pk") + ) + invitations = list( + program.expert_invitations.select_for_update() + .filter(pk__in=invitation_ids) + .order_by("pk") + ) + if len(experts) != len(expert_ids) or len(invitations) != len(invitation_ids): + raise ValidationError( + "Выбор содержит экспертов другой программы или удаленные записи." + ) + if any(i.status == "accepted" for i in invitations): + raise WorkspaceConflict( + "already_accepted", "Одно из приглашений уже принято. Обновите список." + ) + action = data["action"] + if action == "cases": + scope = validated_case_scope(program, data, require_selection=True) + for expert in experts: + ExpertCaseScope.objects.update_or_create( + partner_program=program, expert=expert, defaults=scope + ) + for invitation in invitations: + invitation.all_cases, invitation.case_names = ( + scope["all_cases"], + scope["case_names"], + ) + invitation.save(update_fields=["all_cases", "case_names", "updated_at"]) + elif expert_ids: + raise ValidationError("Выберите только приглашения.") + elif action == "revoke": + for invitation in invitations: + revoke(program_id, user, invitation.pk) + else: + for invitation in invitations: + invite(program_id, user, invitation.email, invitation_id=invitation.pk) + return workspace_data(program) + + def validated_case_scope(program, data, *, require_selection=False): names = data["case_names"] if any(name is not None and name not in program_cases(program) for name in names): diff --git a/project_rates/workspace_views.py b/project_rates/workspace_views.py index 64c933a1..f458379c 100644 --- a/project_rates/workspace_views.py +++ b/project_rates/workspace_views.py @@ -70,6 +70,29 @@ class ProjectExpertsWrite(RevisionWrite): mode = serializers.ChoiceField(choices=["add", "replace"], default="add") +class ExpertsBulkWrite(CaseScopeWrite): + action = serializers.ChoiceField(choices=("cases", "resend", "revoke")) + expert_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), max_length=10000, default=list + ) + invitation_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), max_length=10000, default=list + ) + all_cases = serializers.BooleanField(default=True) + case_names = serializers.ListField( + child=serializers.CharField(allow_null=True, max_length=1000), + max_length=1000, + default=list, + ) + + def validate(self, attrs): + if not attrs["expert_ids"] and not attrs["invitation_ids"]: + raise serializers.ValidationError("Выберите экспертов.") + if attrs["action"] != "cases" and attrs["expert_ids"]: + raise serializers.ValidationError("Выберите только приглашения.") + return attrs + + class AllocationWrite(serializers.Serializer): expert_ids = serializers.ListField( child=serializers.IntegerField(min_value=1), max_length=1000 @@ -118,6 +141,16 @@ def post(self, request, pk): ) +class ExpertsBulkView(PrivateView): + def post(self, request, pk): + managed_program(pk, request.user) + return Response( + service.bulk_experts( + pk, request.user, validated(ExpertsBulkWrite, request.data) + ) + ) + + class InvitationActionView(PrivateView): def post(self, request, pk, invitation_id, action): if action == "cases": From d1ed8cc521dc082b9122a9cd98388cb6d33b640d Mon Sep 17 00:00:00 2001 From: DarKylX Date: Wed, 21 Oct 2026 00:19:32 +0300 Subject: [PATCH 2/2] =?UTF-8?q?test:=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=8F=D1=82=D1=8C=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B8=D0=B2=D0=B0=D0=B5=D0=BC=D0=BE=D0=B9=20=D0=A1?= =?UTF-8?q?=D0=A3=D0=91=D0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- partner_programs/tests/test_management_bulk.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/partner_programs/tests/test_management_bulk.py b/partner_programs/tests/test_management_bulk.py index d65b2e40..e29ea270 100644 --- a/partner_programs/tests/test_management_bulk.py +++ b/partner_programs/tests/test_management_bulk.py @@ -4,7 +4,7 @@ from threading import Barrier from django.db import close_old_connections -from django.test import TestCase, TransactionTestCase +from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature from django.utils import timezone from openpyxl import load_workbook from rest_framework.test import APIClient @@ -29,6 +29,7 @@ class ParticipantRemovalConcurrencyTests(TransactionTestCase): + @skipUnlessDBFeature("has_select_for_update") def test_same_preview_can_remove_registration_only_once(self): from partner_programs.services.participant_removal import ( RemovalConflict,