From a8c8e416d7ab9c295bf718326160dea5b86588f6 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Wed, 21 Oct 2026 00:49:07 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=BD=D0=B5=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BE=D1=82=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BA=D1=83=20=D0=BE=D1=86=D0=B5=D0=BD=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B8=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D0=B7=D0=B0=D1=8F=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/participant_removal.py | 8 +- .../tests/test_management_bulk.py | 82 ++++++++++++++++++- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/partner_programs/services/participant_removal.py b/partner_programs/services/participant_removal.py index 9bb2ecb1..912f7cdf 100644 --- a/partner_programs/services/participant_removal.py +++ b/partner_programs/services/participant_removal.py @@ -141,7 +141,13 @@ def remove_participants(program_id, actor, ids, token): ) # Тот же порядок, что у lock_team: сначала Project, затем программы и профили. # Все проекты блокируются до программ, чтобы массовая операция не меняла порядок. - list(Project.objects.select_for_update().filter(pk__in=project_ids).order_by("pk")) + # FK-проверка новой оценки берет KEY SHARE проекта при commit. Разрешаем ее, + # пока ожидаем программу: оценка завершится, а измененный preview даст 409. + list( + Project.objects.select_for_update(no_key=True) + .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 diff --git a/partner_programs/tests/test_management_bulk.py b/partner_programs/tests/test_management_bulk.py index e29ea270..256ca695 100644 --- a/partner_programs/tests/test_management_bulk.py +++ b/partner_programs/tests/test_management_bulk.py @@ -1,15 +1,19 @@ from datetime import timedelta from io import BytesIO from concurrent.futures import ThreadPoolExecutor -from threading import Barrier +from threading import Barrier, Event -from django.db import close_old_connections +from django.db import close_old_connections, connection, transaction from django.test import TestCase, TransactionTestCase, skipUnlessDBFeature 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.models import ( + PartnerProgram, + PartnerProgramProject, + PartnerProgramUserProfile, +) from partner_programs.tests.helpers import ( create_partner_program, create_program_member, @@ -29,6 +33,78 @@ class ParticipantRemovalConcurrencyTests(TransactionTestCase): + @skipUnlessDBFeature("has_select_for_no_key_update") + def test_new_score_commits_while_leader_removal_requires_fresh_preview(self): + from partner_programs.services.participant_removal import ( + RemovalConflict, + preview_removal, + remove_participants, + ) + + manager = create_user() + program = create_partner_program(is_competitive=True) + program.managers.add(manager) + leader = create_program_member(program) + project = create_project(leader=leader.user) + create_program_project(program, project=project, submitted=True) + expert = create_rate_expert(program=program) + criterion = Criteria.objects.create( + partner_program=program, + name="Результат", + type="int", + min_value=0, + max_value=10, + ) + preview = preview_removal(program.pk, manager, [leader.pk]) + program_locked, project_locked = Event(), Event() + + def score(): + close_old_connections() + try: + with transaction.atomic(): + with connection.cursor() as cursor: + cursor.execute("SET LOCAL lock_timeout = '3s'") + PartnerProgram.objects.select_for_update().get(pk=program.pk) + program_locked.set() + self.assertTrue(project_locked.wait(timeout=5)) + ProjectScore.objects.create( + project=project, criteria=criterion, user=expert, value="3" + ) + return "score committed" + finally: + close_old_connections() + + def remove(): + close_old_connections() + + def notice_lock(execute, sql, params, many, context): + result = execute(sql, params, many, context) + if 'FROM "projects_project"' in sql and " FOR " in sql: + project_locked.set() + return result + + try: + self.assertTrue(program_locked.wait(timeout=5)) + with connection.execute_wrapper(notice_lock): + try: + remove_participants( + program.pk, manager, [leader.pk], preview["preview_token"] + ) + return "removed" + except RemovalConflict: + return "fresh preview required" + finally: + close_old_connections() + + with ThreadPoolExecutor(max_workers=2) as pool: + scoring, removal = pool.submit(score), pool.submit(remove) + self.assertEqual(scoring.result(timeout=10), "score committed") + self.assertEqual(removal.result(timeout=10), "fresh preview required") + self.assertTrue(PartnerProgramUserProfile.objects.filter(pk=leader.pk).exists()) + self.assertTrue( + ProjectScore.objects.filter(project=project, user=expert).exists() + ) + @skipUnlessDBFeature("has_select_for_update") def test_same_preview_can_remove_registration_only_once(self): from partner_programs.services.participant_removal import (