From 1547c57726a4aedc57804dcd353fc186b02afc74 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 19 Oct 2026 00:29:04 +0300 Subject: [PATCH] fix: cap expert assignments and preserve saved evaluations --- docs/expert-workspace.md | 9 +- project_rates/tests/test_case_distribution.py | 140 ++++++++++++++++++ .../tests/test_workspace_concurrency.py | 44 ++++++ project_rates/workspace.py | 28 +++- 4 files changed, 217 insertions(+), 4 deletions(-) diff --git a/docs/expert-workspace.md b/docs/expert-workspace.md index c6f00382..7bac7034 100644 --- a/docs/expert-workspace.md +++ b/docs/expert-workspace.md @@ -90,8 +90,13 @@ Revision учитывает определения/значения кейсов Нехватка кандидатов блокирует применение; план действует 15 минут. - Все новые записи организатора и сохранение оценок используют одну блокировку строки программы. Конкурирующая запись отклоняется целиком с 409. -- N — минимум полных оценок, а не максимум. Изменять его можно в любое время; - готовность пересчитывается, оценки и лишние назначения сохраняются. +- N — число оценок для готовности проекта и лимит новых назначений экспертов. + Изменять его можно в любое время; готовность пересчитывается, уже сохранённые + оценки и лишние назначения сохраняются. При превышении нового N можно снимать + только назначения без оценки; новые назначения разрешены, только если итоговый + состав не превышает N. Замена эксперта без оценки в заполненном составе разрешена. + Одиночное, массовое, старое ручное API и применение автоматического плана + проверяют лимит под общей блокировкой программы; массовое назначение атомарно. - Управляемая форма оценки сохраняется целиком: целые баллы и Да/нет обязательны, комментарий необязателен. 0 и False — допустимые ответы. Дедлайн, назначение, сданное решение и запрет оценки своей команды проверяются сервером. diff --git a/project_rates/tests/test_case_distribution.py b/project_rates/tests/test_case_distribution.py index 458edede..d487a0d0 100644 --- a/project_rates/tests/test_case_distribution.py +++ b/project_rates/tests/test_case_distribution.py @@ -190,6 +190,146 @@ def test_replacement_protects_saved_scores_but_allows_removing_unscored(self): ) self.assertEqual(ProjectExpertAssignment.objects.count(), 1) + def test_manual_add_rejects_expert_beyond_quota(self): + self.assertEqual( + self.write(self.projects[:1], [self.sber, self.universal]).status_code, 200 + ) + extra = create_rate_expert(program=self.program) + response = self.write(self.projects[:1], [extra]) + self.assertEqual(response.status_code, 409, response.data) + self.assertEqual(response.data["code"], "expert_limit_reached") + self.assertEqual(ProjectExpertAssignment.objects.count(), 2) + + def test_replace_unscored_at_quota_preserves_other_experts_grade(self): + self.write(self.projects[:1], [self.sber, self.universal]) + score = ProjectScore.objects.create( + criteria=create_rate_criteria(self.program), + project=self.projects[0], + user=self.sber, + value="1", + ) + extra = create_rate_expert(program=self.program) + rejected = self.write( + self.projects[:1], [self.sber, self.universal, extra], mode="replace" + ) + self.assertEqual(rejected.status_code, 409, rejected.data) + result = self.write(self.projects[:1], [self.sber, extra], mode="replace") + self.assertEqual(result.status_code, 200, result.data) + self.assertEqual( + set(ProjectExpertAssignment.objects.values_list("expert_id", flat=True)), + {self.sber.expert.pk, extra.expert.pk}, + ) + score.refresh_from_db() + self.assertEqual(score.value, "1") + + def test_bulk_exceeding_one_projects_limit_is_fully_rolled_back(self): + self.write(self.projects[:1], [self.sber, self.universal]) + extra = create_rate_expert(program=self.program) + response = self.write(self.projects[:2], [extra]) + self.assertEqual(response.status_code, 409, response.data) + self.assertEqual(ProjectExpertAssignment.objects.count(), 2) + self.assertFalse( + ProjectExpertAssignment.objects.filter(project=self.projects[1]).exists() + ) + + def test_legacy_manual_endpoint_cannot_bypass_project_limit(self): + self.write(self.projects[:1], [self.sber, self.universal]) + extra = create_rate_expert(program=self.program) + response = self.client.put( + self.root + f"experts/{extra.expert.pk}/assignments/", + {"revision": self.state()["revision"], "project_ids": [self.projects[0].pk]}, + format="json", + ) + self.assertEqual(response.status_code, 409, response.data) + self.assertEqual(response.data["code"], "expert_limit_reached") + self.assertEqual(ProjectExpertAssignment.objects.count(), 2) + + def test_lower_quota_preserves_surplus_and_allows_only_unscored_removals(self): + self.write(self.projects[:1], [self.sber, self.universal]) + score = ProjectScore.objects.create( + criteria=create_rate_criteria(self.program), + project=self.projects[0], + user=self.sber, + value="1", + ) + response = self.client.put( + self.root + "evaluators/", + {"revision": self.state()["revision"], "required_evaluators": 1}, + format="json", + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(ProjectExpertAssignment.objects.count(), 2) + self.assertEqual( + self.write( + self.projects[:1], [self.sber, self.universal], mode="replace" + ).status_code, + 200, + ) + extra = create_rate_expert(program=self.program) + self.assertEqual( + self.write(self.projects[:1], [self.sber, extra], mode="replace").status_code, + 409, + ) + self.assertEqual( + self.write(self.projects[:1], [self.universal], mode="replace").status_code, + 409, + ) + self.assertEqual( + self.write(self.projects[:1], [self.sber], mode="replace").status_code, 200 + ) + self.assertEqual(ProjectExpertAssignment.objects.count(), 1) + self.assertTrue(ProjectScore.objects.filter(pk=score.pk).exists()) + + def test_historical_grade_counts_towards_limit_when_enabling_workspace(self): + self.program.required_evaluators = None + self.program.max_project_rates = 1 + self.program.save() + score = ProjectScore.objects.create( + criteria=create_rate_criteria(self.program), + project=self.projects[0], + user=self.sber, + value="1", + ) + response = self.write(self.projects[:1], [self.universal]) + self.assertEqual(response.status_code, 409, response.data) + self.program.refresh_from_db() + self.assertIsNone(self.program.required_evaluators) + self.assertFalse(ProjectExpertAssignment.objects.exists()) + self.assertTrue(ProjectScore.objects.filter(pk=score.pk).exists()) + + def test_auto_does_not_add_to_project_at_quota(self): + self.write(self.projects[:1], [self.sber, self.universal]) + response = self.plan(project_ids=[self.projects[0].pk]) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual(response.data["assignments"], []) + applied = self.client.post( + self.root + "expert-allocation/apply/", + {"plan_token": response.data["plan_token"]}, + format="json", + ) + self.assertEqual(applied.status_code, 200, applied.data) + self.assertEqual(ProjectExpertAssignment.objects.count(), 2) + + def test_auto_apply_rechecks_limit_after_restoring_historical_assignments(self): + self.program.required_evaluators = None + self.program.max_project_rates = 1 + self.program.save() + ProjectScore.objects.create( + criteria=create_rate_criteria(self.program), + project=self.projects[0], + user=self.sber, + value="1", + ) + response = self.plan(project_ids=[self.projects[0].pk]) + self.assertEqual(response.status_code, 200, response.data) + applied = self.client.post( + self.root + "expert-allocation/apply/", + {"plan_token": response.data["plan_token"]}, + format="json", + ) + self.assertEqual(applied.status_code, 409, applied.data) + self.assertFalse(ProjectExpertAssignment.objects.exists()) + def test_changed_case_scope_preserves_assignments_and_invalidates_plan(self): self.assertEqual(self.write(self.projects[:1], [self.sber]).status_code, 200) plan = self.plan().data diff --git a/project_rates/tests/test_workspace_concurrency.py b/project_rates/tests/test_workspace_concurrency.py index 0313f43f..49bd099e 100644 --- a/project_rates/tests/test_workspace_concurrency.py +++ b/project_rates/tests/test_workspace_concurrency.py @@ -28,6 +28,50 @@ def setUp(self): self.manager = create_rate_user() self.program.managers.add(self.manager) + def test_concurrent_project_and_legacy_assignment_cannot_overfill_last_place(self): + self.program.required_evaluators = 1 + self.program.save() + experts = [create_rate_expert(program=self.program) for _ in range(2)] + project = create_rate_project() + link = link_project_to_program(self.program, project) + type(link).objects.filter(pk=link.pk).update(submitted=True) + root = f"/programs/{self.program.pk}/management/" + rev = revision(self.program) + gate = Barrier(2) + + def assign(index): + connections.close_all() + try: + client = APIClient() + client.force_authenticate(self.manager) + gate.wait(timeout=10) + if index == 0: + return client.post( + root + "project-experts/", + { + "revision": rev, + "project_ids": [project.pk], + "expert_ids": [experts[index].expert.pk], + "mode": "add", + }, + format="json", + ).status_code + return client.put( + root + f"experts/{experts[index].expert.pk}/assignments/", + {"revision": rev, "project_ids": [project.pk]}, + format="json", + ).status_code + finally: + connections.close_all() + + with ThreadPoolExecutor(max_workers=2) as executor: + statuses = list(executor.map(assign, range(2))) + self.assertEqual(sorted(statuses), [200, 409]) + self.assertEqual( + ProjectExpertAssignment.objects.filter(partner_program=self.program).count(), + 1, + ) + def test_two_registration_submits_create_one_account(self): token = "concurrent-invitation-token-0123456789" email = "concurrent-expert@example.com" diff --git a/project_rates/workspace.py b/project_rates/workspace.py index 06afe075..5da112f6 100644 --- a/project_rates/workspace.py +++ b/project_rates/workspace.py @@ -122,6 +122,21 @@ def check_revision(program, expected): ) +def check_assignment_limit(program, additions, removals=()): + """Preserve historical surplus, but never create a new over-quota assignment.""" + present = set( + program.project_expert_assignments.values_list("project_id", "expert_id") + ) - set(removals) + new = set(additions) - present + counts = Counter(project_id for project_id, _ in present | new) + if any(counts[project_id] > target(program) for project_id, _ in new): + raise WorkspaceConflict( + "expert_limit_reached", + "Нельзя назначить больше экспертов, чем требуется оценок на проект. " + "Для замены снимите назначение эксперта без оценки.", + ) + + def enable_workspace(program): """Explicit organizer action opts in; preserve assignments behind historical scores.""" if program.required_evaluators is not None: @@ -359,6 +374,7 @@ def save_case_scope(program_id, user, expert_id, data): def save_project_experts(program_id, user, data): program = managed_program(program_id, user, lock=True) check_revision(program, data["revision"]) + enable_workspace(program) ids, wanted = set(data["project_ids"]), set(data["expert_ids"]) projects = {p["id"]: p for p in distribution_projects(program)} experts = {e.pk: e for e in expert_rows(program)} @@ -400,7 +416,11 @@ def save_project_experts(program_id, user, data): "Нельзя снять эксперта с сохранённой оценкой.", ) removals.append(assignment.pk) - enable_workspace(program) + check_assignment_limit( + program, + [(a.project_id, a.expert_id) for a in additions], + [(pid, eid) for (pid, eid), a in existing.items() if a.pk in removals], + ) program.project_expert_assignments.filter(pk__in=removals).delete() ProjectExpertAssignment.objects.bulk_create(additions, ignore_conflicts=True) return workspace_data(program) @@ -607,6 +627,7 @@ def assignment_options(program, expert_id): def save_assignments(program_id, user, expert_id, data): program = managed_program(program_id, user, lock=True) check_revision(program, data["revision"]) + enable_workspace(program) expert = get_object_or_404(program.experts.filter(user__is_active=True), pk=expert_id) wanted = set(data["project_ids"]) options = {p["id"]: p for p in assignment_options(program, expert.pk)["projects"]} @@ -635,7 +656,7 @@ def save_assignments(program_id, user, expert_id, data): raise WorkspaceConflict( "scores_protected", "Нельзя снять назначение с сохранёнными оценками." ) - enable_workspace(program) + check_assignment_limit(program, [(pid, expert.pk) for pid in wanted - existing]) program.project_expert_assignments.filter( expert=expert, project_id__in=existing - wanted ).delete() @@ -778,6 +799,9 @@ def apply_allocation(program_id, user, token): "expert_shortage", "Не хватает экспертов. Измените параметры распределения." ) enable_workspace(program) + check_assignment_limit( + program, [(row["project_id"], row["expert_id"]) for row in plan["additions"]] + ) ProjectExpertAssignment.objects.bulk_create( [ ProjectExpertAssignment(partner_program=program, **row)