From 9496a845c93be2bcdf8375f1bbe3ec9ee079997e Mon Sep 17 00:00:00 2001 From: DarKylX Date: Fri, 23 Oct 2026 01:19:28 +0300 Subject: [PATCH] feat: edit allocation previews and vary balanced proposals --- project_rates/tests/test_case_distribution.py | 101 ++++++++++++++++++ project_rates/workspace.py | 32 +++++- project_rates/workspace_views.py | 11 ++ 3 files changed, 140 insertions(+), 4 deletions(-) diff --git a/project_rates/tests/test_case_distribution.py b/project_rates/tests/test_case_distribution.py index d487a0d0..b8570b46 100644 --- a/project_rates/tests/test_case_distribution.py +++ b/project_rates/tests/test_case_distribution.py @@ -16,6 +16,107 @@ class CaseDistributionTests(TestCase): + def test_seed_varies_equal_load_choices_without_changing_existing_assignments(self): + self.write(self.projects[:1], [self.universal]) + extra = [create_rate_expert(program=self.program) for _ in range(3)] + ids = [u.expert.pk for u in [self.universal, *extra]] + variants = set() + for seed in range(8): + response = self.plan( + expert_ids=ids, + project_ids=[self.projects[0].pk], + case_pools=[{"case_name": "Сбер", "expert_ids": ids}], + seed=seed, + ) + self.assertEqual(response.status_code, 200, response.data) + rows = response.data["assignments"] + self.assertEqual(len(rows), 1) # one existing + one proposed = two + self.assertNotEqual(rows[0]["expert_id"], self.universal.expert.pk) + variants.add(rows[0]["expert_id"]) + self.assertGreater(len(variants), 1) + self.assertEqual(ProjectExpertAssignment.objects.count(), 1) + + def test_override_is_preview_only_then_applies_with_the_signed_plan(self): + ids = [self.sber.expert.pk, self.universal.expert.pk] + response = self.plan( + assignment_overrides=[{"project_id": self.projects[0].pk, "expert_ids": ids}] + ) + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual( + { + r["expert_id"] + for r in response.data["assignments"] + if r["project_id"] == self.projects[0].pk + }, + set(ids), + ) + self.assertFalse(ProjectExpertAssignment.objects.exists()) + 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(), 8) + + def test_override_rejects_wrong_pool_own_team_duplicates_and_foreign_project(self): + own = self.project("Сбер", leader=self.universal) + foreign = create_rate_project() + rows = [ + [{"project_id": self.projects[0].pk, "expert_ids": [self.bank.expert.pk]}], + [{"project_id": own.pk, "expert_ids": [self.universal.expert.pk]}], + [{"project_id": foreign.pk, "expert_ids": []}], + [ + { + "project_id": self.projects[0].pk, + "expert_ids": [self.sber.expert.pk] * 2, + } + ], + [{"project_id": self.projects[0].pk, "expert_ids": []}] * 2, + ] + for overrides in rows: + with self.subTest(overrides=overrides): + self.assertEqual( + self.plan(assignment_overrides=overrides).status_code, 400 + ) + self.assertFalse(ProjectExpertAssignment.objects.exists()) + + def test_override_cannot_exceed_free_slots_or_repeat_an_existing_expert(self): + self.write(self.projects[:1], [self.universal]) + for ids in [ + [self.universal.expert.pk], + [self.sber.expert.pk, self.universal.expert.pk], + ]: + self.assertEqual( + self.plan( + assignment_overrides=[ + {"project_id": self.projects[0].pk, "expert_ids": ids} + ] + ).status_code, + 400, + ) + self.assertEqual(ProjectExpertAssignment.objects.count(), 1) + + def test_underfilled_override_blocks_apply_and_stale_override_is_rejected(self): + old = self.state()["revision"] + response = self.plan( + assignment_overrides=[{"project_id": self.projects[0].pk, "expert_ids": []}] + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.data["shortages"]) + self.assertEqual( + self.client.post( + self.root + "expert-allocation/apply/", + {"plan_token": response.data["plan_token"]}, + format="json", + ).status_code, + 409, + ) + self.write(self.projects[:1], [self.sber]) + self.assertEqual( + self.plan(revision=old, assignment_overrides=[]).status_code, 409 + ) + def test_inactive_existing_expert_can_be_kept_but_not_newly_assigned(self): self.assertEqual(self.write(self.projects[:1], [self.universal]).status_code, 200) type(self.universal).objects.filter(pk=self.universal.pk).update(is_active=False) diff --git a/project_rates/workspace.py b/project_rates/workspace.py index 8b50ad97..abf87c1f 100644 --- a/project_rates/workspace.py +++ b/project_rates/workspace.py @@ -809,6 +809,14 @@ def allocation(program, data): ).values_list("project_id", "user_id"): teams[user_id].add(project_id) own = {e.pk: teams[e.user_id] for e in experts} + overrides = {} + project_ids = {link.project_id for link in links} + for row in data.get("assignment_overrides", []): + pid, ids = row["project_id"], row["expert_ids"] + if pid not in project_ids or pid in overrides or len(set(ids)) != len(ids): + raise ValidationError("Проверьте проекты и экспертов в предпросмотре.") + overrides[pid] = set(ids) + seed = data.get("seed") additions, shortages = [], [] for link in links: needed = max(0, target(program) - counts[link.project_id]) @@ -820,8 +828,24 @@ def allocation(program, data): and eligible(scopes.get(e.pk), link.case_name) and (pools is None or e.pk in pools.get(link.case_name, set())) ] - candidates.sort(key=lambda e: (remaining[e.pk], e.pk)) - for expert in candidates[:needed]: + candidates.sort( + key=lambda e: ( + remaining[e.pk], + e.pk + if seed is None + else hashlib.sha256(f"{seed}:{link.project_id}:{e.pk}".encode()).digest(), + ) + ) + if link.project_id in overrides: + chosen = overrides[link.project_id] + if chosen - {e.pk for e in candidates} or len(chosen) > needed: + raise ValidationError( + "Выберите доступных экспертов в пределах числа оценок проекта." + ) + proposed = [e for e in candidates if e.pk in chosen] + else: + proposed = candidates[:needed] + for expert in proposed: additions.append( { "project_id": link.project_id, @@ -832,12 +856,12 @@ def allocation(program, data): } ) remaining[expert.pk] += 1 - if len(candidates) < needed: + if len(proposed) < needed: shortages.append( { "project_id": link.project_id, "project_name": link.project.name, - "missing": needed - len(candidates), + "missing": needed - len(proposed), "case_name": link.case_name, } ) diff --git a/project_rates/workspace_views.py b/project_rates/workspace_views.py index f458379c..e938d7fd 100644 --- a/project_rates/workspace_views.py +++ b/project_rates/workspace_views.py @@ -93,6 +93,13 @@ def validate(self, attrs): return attrs +class AllocationOverrideWrite(serializers.Serializer): + project_id = serializers.IntegerField(min_value=1) + expert_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), max_length=1000 + ) + + class AllocationWrite(serializers.Serializer): expert_ids = serializers.ListField( child=serializers.IntegerField(min_value=1), max_length=1000 @@ -102,6 +109,10 @@ class AllocationWrite(serializers.Serializer): ) case_pools = CasePoolWrite(many=True, required=False, max_length=1000) revision = serializers.CharField(max_length=64, required=False) + seed = serializers.IntegerField(min_value=0, max_value=2147483647, required=False) + assignment_overrides = AllocationOverrideWrite( + many=True, required=False, max_length=10000 + ) class QuotaWrite(serializers.Serializer):