From 2c854a5483da02fbd87bb7fdd703bbfa457a7e70 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 18 Oct 2026 23:33:32 +0300 Subject: [PATCH] feat: support case-scoped expert pools and project assignment --- docs/expert-workspace.md | 26 +- partner_programs/urls.py | 7 + .../0005_expertcasescope_and_more.py | 30 ++ project_rates/models.py | 21 ++ project_rates/tests/test_case_distribution.py | 328 ++++++++++++++++++ project_rates/workspace.py | 189 +++++++++- project_rates/workspace_views.py | 46 ++- 7 files changed, 640 insertions(+), 7 deletions(-) create mode 100644 project_rates/migrations/0005_expertcasescope_and_more.py create mode 100644 project_rates/tests/test_case_distribution.py diff --git a/docs/expert-workspace.md b/docs/expert-workspace.md index 8e896282..c6f00382 100644 --- a/docs/expert-workspace.md +++ b/docs/expert-workspace.md @@ -12,12 +12,14 @@ | Метод и путь относительно `/programs/{id}/management/` | Назначение | | --- | --- | | GET `experts/` | Состав, приглашения, нагрузка, N, нехватка и revision | +| PUT `experts/{expert_id}/cases/` | Кейсы эксперта `{revision, all_cases, case_names}` | +| POST `project-experts/` | Атомарное назначение `{revision, project_ids, expert_ids, mode}` | | POST `experts/` | Пригласить `{email}` | | POST `expert-invitations/{id}/resend/` | Новая ссылка, прежняя недействительна | | POST `expert-invitations/{id}/revoke/` | Отозвать непринятое приглашение | | GET `experts/{expert_id}/assignments/` | Проекты с assigned/protected/own_team | | PUT `experts/{expert_id}/assignments/` | Заменить набор `{revision, project_ids}` | -| POST `expert-allocation/` | Предпросмотр `{expert_ids, project_ids?}` | +| POST `expert-allocation/` | Предпросмотр `{expert_ids, project_ids?, case_pools?, revision?}` | | POST `expert-allocation/apply/` | Применить подписанный `{plan_token}` | | POST `evaluators/` | Предпросмотр `{required_evaluators}` | | PUT `evaluators/` | Подтвердить `{required_evaluators, revision}` | @@ -60,6 +62,24 @@ repeat_password, accept_policy, accept_age и атомарно создаёт а ## Правила +`GET experts/` также возвращает `cases`, сданные опубликованные `projects` с +экспертами/статусами оценок и `all_cases`/`case_names` для каждого участника состава. +Без отдельной записи `ExpertCaseScope` эксперт доступен во всех текущих и будущих +кейсах. Можно выбрать несколько кейсов; списки пересекаются. Эти правила одинаковы +для ручного и автоматического назначения. Смена состава кейсов не удаляет назначения. + +`project-experts/`: `mode=replace` меняет состав одного проекта с защитой оценок; +`mode=add` добавляет экспертов одному или нескольким проектам одного кейса без снятия +существующих. Весь запрос атомарен, включая проверку собственной команды, членства, +доступности кейса, сдачи/публикации и revision. Недоступного эксперта можно сохранить +в прежнем составе, но нельзя назначить новому проекту. + +`case_pools` — массив `{case_name, expert_ids}`, где null означает проекты без кейса. +Нагрузка общего эксперта учитывается один раз по всей программе. Без поля сохраняется +старый контракт общего выбора; ограничения кейсов действуют и в нём. Отсутствующий или +пустой состав нужного кейса приводит к нехватке и блокирует применение плана. +Revision учитывает определения/значения кейсов и доступность экспертов по кейсам. + - Pending не является экспертом и не участвует в распределении. - Новые назначения — только принявшим приглашение активным экспертам, на сданные проекты своей программы; собственная команда исключена. @@ -79,6 +99,10 @@ repeat_password, accept_policy, accept_age и атомарно создаёт а ## Совместимость и rollout +Для вкладок v2 нужна `project_rates.0005_expertcasescope_and_more` (новая таблица, +без изменения оценок и назначений). Сначала backend и миграция, затем Angular. +Существующие API-поля и endpoints сохранены; старый frontend совместим. + Добавлены `PartnerProgram.required_evaluators`, `ExpertInvitation` и nullable `CustomUser.birthday`: migrations partner_programs.0022, project_rates.0004, users.0065. Обычная регистрация продолжает требовать дату рождения; обычный diff --git a/partner_programs/urls.py b/partner_programs/urls.py index e86ccaea..c429f052 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -47,6 +47,8 @@ ExpertsView, InvitationActionView, ExpertAssignmentsView, + ExpertCaseScopeView, + ProjectExpertsView, AllocationView, AllocationApplyView, EvaluatorsView, @@ -56,6 +58,11 @@ urlpatterns = [ path("/management/experts/", ExpertsView.as_view()), + path( + "/management/experts//cases/", + ExpertCaseScopeView.as_view(), + ), + path("/management/project-experts/", ProjectExpertsView.as_view()), path( "/management/expert-invitations///", InvitationActionView.as_view(), diff --git a/project_rates/migrations/0005_expertcasescope_and_more.py b/project_rates/migrations/0005_expertcasescope_and_more.py new file mode 100644 index 00000000..23fd117b --- /dev/null +++ b/project_rates/migrations/0005_expertcasescope_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.11 on 2026-10-18 20:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0065_alter_customuser_birthday'), + ('partner_programs', '0022_partnerprogram_required_evaluators'), + ('project_rates', '0004_expertinvitation_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ExpertCaseScope', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('all_cases', models.BooleanField(default=True)), + ('case_names', models.JSONField(blank=True, default=list)), + ('expert', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='case_scopes', to='users.expert')), + ('partner_program', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expert_case_scopes', to='partner_programs.partnerprogram')), + ], + ), + migrations.AddConstraint( + model_name='expertcasescope', + constraint=models.UniqueConstraint(fields=('partner_program', 'expert'), name='unique_program_expert_case_scope'), + ), + ] diff --git a/project_rates/models.py b/project_rates/models.py index 0c583aa2..6a694a50 100644 --- a/project_rates/models.py +++ b/project_rates/models.py @@ -244,6 +244,27 @@ def delete(self, *args, **kwargs): return super().delete(*args, **kwargs) +class ExpertCaseScope(models.Model): + """Program-specific eligibility; no row means all current and future cases.""" + + partner_program = models.ForeignKey( + PartnerProgram, on_delete=models.CASCADE, related_name="expert_case_scopes" + ) + expert = models.ForeignKey( + "users.Expert", on_delete=models.CASCADE, related_name="case_scopes" + ) + all_cases = models.BooleanField(default=True) + case_names = models.JSONField(default=list, blank=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["partner_program", "expert"], + name="unique_program_expert_case_scope", + ) + ] + + class ExpertInvitation(models.Model): """Email invitation; only a digest of the bearer credential is stored.""" diff --git a/project_rates/tests/test_case_distribution.py b/project_rates/tests/test_case_distribution.py new file mode 100644 index 00000000..458edede --- /dev/null +++ b/project_rates/tests/test_case_distribution.py @@ -0,0 +1,328 @@ +"""Case eligibility, universal workload and atomic project-centric assignment.""" + +from django.test import TestCase +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramField, PartnerProgramFieldValue +from project_rates.models import ExpertCaseScope, ProjectExpertAssignment, ProjectScore +from project_rates.tests.helpers import ( + create_rate_program, + create_rate_user, + create_rate_expert, + create_rate_project, + link_project_to_program, + create_rate_criteria, +) + + +class CaseDistributionTests(TestCase): + 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) + self.assertEqual( + self.write( + self.projects[:1], [self.universal, self.sber], mode="replace" + ).status_code, + 200, + ) + self.assertEqual( + self.write(self.projects[1:2], [self.universal]).status_code, 400 + ) + + def setUp(self): + self.manager = create_rate_user() + self.program = create_rate_program( + required_evaluators=2, is_distributed_evaluation=True + ) + self.program.managers.add(self.manager) + self.client = APIClient() + self.client.force_authenticate(self.manager) + self.root = f"/programs/{self.program.pk}/management/" + self.field = PartnerProgramField.objects.create( + partner_program=self.program, + name="case", + label="Кейс", + field_type="select", + is_required=True, + show_filter=True, + options="Сбер|Т-Банк", + ) + self.universal = create_rate_expert(program=self.program) + self.sber = create_rate_expert(program=self.program) + self.bank = create_rate_expert(program=self.program) + self.projects = [ + self.project(name) for name in ["Сбер", "Сбер", "Т-Банк", "Т-Банк"] + ] + self.scope(self.sber, ["Сбер"]) + self.scope(self.bank, ["Т-Банк"]) + + def project(self, case, **kwargs): + project = create_rate_project(**kwargs) + self.program.users.add( + project.leader, through_defaults={"partner_program_data": {}} + ) + link = link_project_to_program(self.program, project) + PartnerProgramFieldValue.objects.create( + program_project=link, field=self.field, value_text=case + ) + type(link).objects.filter(pk=link.pk).update(submitted=True) + return project + + def scope(self, user, names): + ExpertCaseScope.objects.update_or_create( + partner_program=self.program, + expert=user.expert, + defaults={"all_cases": False, "case_names": names}, + ) + + def state(self): + response = self.client.get(self.root + "experts/") + self.assertEqual(response.status_code, 200, response.data) + return response.data + + def write(self, projects, experts, **overrides): + return self.client.post( + self.root + "project-experts/", + { + "revision": self.state()["revision"], + "project_ids": [p.pk for p in projects], + "expert_ids": [u.expert.pk for u in experts], + "mode": "add", + **overrides, + }, + format="json", + ) + + def plan(self, **overrides): + return self.client.post( + self.root + "expert-allocation/", + { + "expert_ids": [ + u.expert.pk for u in [self.sber, self.bank, self.universal] + ], + "case_pools": [ + { + "case_name": "Сбер", + "expert_ids": [self.sber.expert.pk, self.universal.expert.pk], + }, + { + "case_name": "Т-Банк", + "expert_ids": [self.bank.expert.pk, self.universal.expert.pk], + }, + ], + **overrides, + }, + format="json", + ) + + def test_workspace_contains_cases_scopes_and_project_assignments(self): + self.assertEqual(self.write(self.projects[:1], [self.sber]).status_code, 200) + state = self.state() + self.assertEqual(state["cases"], ["Сбер", "Т-Банк"]) + self.assertEqual(len(state["projects"]), 4) + row = next(e for e in state["experts"] if e["id"] == self.universal.expert.pk) + self.assertTrue(row["all_cases"]) + project = state["projects"][0] + self.assertEqual(project["case_name"], "Сбер") + self.assertFalse(project["experts"][0]["completed"]) + + def test_scope_save_supports_multiple_cases_and_rejects_foreign_case(self): + url = self.root + f"experts/{self.sber.expert.pk}/cases/" + data = { + "revision": self.state()["revision"], + "all_cases": False, + "case_names": ["Сбер", "Т-Банк"], + } + response = self.client.put(url, data, format="json") + self.assertEqual(response.status_code, 200, response.data) + self.assertEqual( + ExpertCaseScope.objects.get(expert=self.sber.expert).case_names, + ["Сбер", "Т-Банк"], + ) + data.update(revision=self.state()["revision"], case_names=["Чужой кейс"]) + self.assertEqual(self.client.put(url, data, format="json").status_code, 400) + + def test_manual_and_legacy_assignment_reject_expert_from_other_case(self): + self.assertEqual(self.write(self.projects[:1], [self.bank]).status_code, 400) + response = self.client.put( + self.root + f"experts/{self.bank.expert.pk}/assignments/", + {"revision": self.state()["revision"], "project_ids": [self.projects[0].pk]}, + format="json", + ) + self.assertEqual(response.status_code, 400) + self.assertFalse(ProjectExpertAssignment.objects.exists()) + + def test_bulk_add_preserves_existing_experts_and_is_idempotent(self): + self.assertEqual(self.write(self.projects[:1], [self.universal]).status_code, 200) + self.assertEqual(self.write(self.projects[:2], [self.sber]).status_code, 200) + self.assertEqual(self.write(self.projects[:2], [self.sber]).status_code, 200) + self.assertEqual(ProjectExpertAssignment.objects.count(), 3) + + def test_bulk_different_cases_rejected_without_partial_writes(self): + self.assertEqual( + self.write( + [self.projects[0], self.projects[2]], [self.universal] + ).status_code, + 400, + ) + self.assertFalse(ProjectExpertAssignment.objects.exists()) + + def test_own_team_prevents_entire_bulk_write(self): + project = self.project("Сбер", leader=self.universal) + self.assertEqual( + self.write([self.projects[0], project], [self.universal]).status_code, 400 + ) + self.assertFalse(ProjectExpertAssignment.objects.exists()) + + def test_replacement_protects_saved_scores_but_allows_removing_unscored(self): + self.assertEqual( + self.write(self.projects[:1], [self.sber, self.universal]).status_code, 200 + ) + criterion = create_rate_criteria(self.program) + ProjectScore.objects.create( + criteria=criterion, project=self.projects[0], user=self.sber, value="1" + ) + result = self.write(self.projects[:1], [self.universal], mode="replace") + self.assertEqual(result.status_code, 409) + self.assertEqual(ProjectExpertAssignment.objects.count(), 2) + self.assertEqual( + self.write(self.projects[:1], [self.sber], mode="replace").status_code, 200 + ) + self.assertEqual(ProjectExpertAssignment.objects.count(), 1) + + 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 + self.scope(self.universal, ["Сбер"]) + result = self.client.post( + self.root + "expert-allocation/apply/", + {"plan_token": plan["plan_token"]}, + format="json", + ) + self.assertEqual(result.status_code, 409) + self.assertEqual(ProjectExpertAssignment.objects.count(), 1) + + def test_changed_project_case_invalidates_plan(self): + plan = self.plan().data + PartnerProgramFieldValue.objects.filter( + program_project__project=self.projects[0] + ).update(value_text="Т-Банк") + result = self.client.post( + self.root + "expert-allocation/apply/", + {"plan_token": plan["plan_token"]}, + format="json", + ) + self.assertEqual(result.status_code, 409) + self.assertFalse(ProjectExpertAssignment.objects.exists()) + + def test_overlapping_pools_count_universal_workload_once(self): + result = self.plan() + self.assertEqual(result.status_code, 200, result.data) + plan = result.data + self.assertFalse(plan["shortages"]) + self.assertEqual(len(plan["assignments"]), 8) + universal = [ + r for r in plan["load"] if r["expert_id"] == self.universal.expert.pk + ] + self.assertEqual(len(universal), 1) + self.assertEqual(universal[0]["after"], 4) + for row in plan["assignments"]: + if row["case_name"] == "Сбер": + self.assertNotEqual(row["expert_id"], self.bank.expert.pk) + else: + self.assertNotEqual(row["expert_id"], self.sber.expert.pk) + self.assertFalse(ProjectExpertAssignment.objects.exists()) + applied = self.client.post( + self.root + "expert-allocation/apply/", + {"plan_token": plan["plan_token"]}, + format="json", + ) + self.assertEqual(applied.status_code, 200, applied.data) + self.assertEqual(ProjectExpertAssignment.objects.count(), 8) + self.assertEqual( + len( + { + (a.project_id, a.expert_id) + for a in ProjectExpertAssignment.objects.all() + } + ), + 8, + ) + + def test_shortage_when_pool_is_empty_blocks_all_application(self): + result = self.plan(case_pools=[{"case_name": "Сбер", "expert_ids": []}]) + self.assertEqual(result.status_code, 200, result.data) + self.assertEqual(len(result.data["shortages"]), 4) + applied = self.client.post( + self.root + "expert-allocation/apply/", + {"plan_token": result.data["plan_token"]}, + format="json", + ) + self.assertEqual(applied.status_code, 409) + self.assertFalse(ProjectExpertAssignment.objects.exists()) + + def test_reject_pool_outside_eligibility_and_nonmember(self): + response = self.plan( + case_pools=[{"case_name": "Сбер", "expert_ids": [self.bank.expert.pk]}] + ) + self.assertEqual(response.status_code, 400) + outsider = create_rate_expert() + self.assertEqual(self.write(self.projects[:1], [outsider]).status_code, 400) + + def test_stale_project_write_preserves_assignments(self): + old = self.state()["revision"] + self.write(self.projects[:1], [self.sber]) + response = self.write(self.projects[:1], [self.universal], revision=old) + self.assertEqual(response.status_code, 409) + self.assertEqual(ProjectExpertAssignment.objects.count(), 1) + + def test_scope_and_assignment_endpoints_require_program_manager(self): + self.client.force_authenticate(create_rate_user()) + self.assertEqual(self.client.get(self.root + "experts/").status_code, 403) + self.assertEqual( + self.client.post( + self.root + "project-experts/", + {"revision": "x", "project_ids": [self.projects[0].pk], "expert_ids": []}, + format="json", + ).status_code, + 403, + ) + self.assertEqual( + self.client.put( + self.root + f"experts/{self.sber.expert.pk}/cases/", + {"revision": "x", "all_cases": True, "case_names": []}, + format="json", + ).status_code, + 403, + ) + + def test_draft_unsubmitted_and_foreign_projects_cannot_be_assigned(self): + for update in [{"draft": True}, {"draft": False}]: + type(self.projects[0]).objects.filter(pk=self.projects[0].pk).update(**update) + if not update["draft"]: + self.program.program_projects.filter(project=self.projects[0]).update( + submitted=False + ) + self.assertEqual( + self.write(self.projects[:1], [self.universal]).status_code, 400 + ) + self.assertEqual( + self.write([create_rate_project()], [self.universal]).status_code, 400 + ) + + def test_scope_from_other_program_does_not_restrict_expert_here(self): + other = create_rate_program() + ExpertCaseScope.objects.create( + partner_program=other, + expert=self.universal.expert, + all_cases=False, + case_names=[], + ) + self.assertEqual(self.write(self.projects[:1], [self.universal]).status_code, 200) + + def test_scopes_survive_refresh_and_default_all_covers_new_cases(self): + self.field.options += "|Новый" + self.field.save() + project = self.project("Новый") + self.assertEqual(self.write([project], [self.universal]).status_code, 200) + self.assertEqual(self.write([project], [self.sber]).status_code, 400) diff --git a/project_rates/workspace.py b/project_rates/workspace.py index f90e2f6a..06afe075 100644 --- a/project_rates/workspace.py +++ b/project_rates/workspace.py @@ -18,7 +18,12 @@ from partner_programs.services.case_analytics import project_case_links from partner_programs.services.case_fields import get_program_case_field from partner_programs.services.management import managed_program, person_name -from project_rates.models import ExpertInvitation, ProjectExpertAssignment, ProjectScore +from project_rates.models import ( + ExpertCaseScope, + ExpertInvitation, + ProjectExpertAssignment, + ProjectScore, +) from projects.models import Collaborator from users.models import CustomUser, Expert @@ -94,6 +99,17 @@ def revision(program): .values_list("pk", "project_id", "user_id", "criteria_id", "value") ), list(program.criterias.order_by("pk").values_list("pk", "type")), + list( + program.expert_case_scopes.order_by("expert_id").values_list( + "expert_id", "all_cases", "case_names" + ) + ), + list(program.fields.filter(name="case").values_list("pk", "options")), + list( + project_case_links(program, field=get_program_case_field(program)) + .order_by("project_id") + .values_list("project_id", "case_name") + ), ] return digest(json.dumps(data, ensure_ascii=False, default=str)) @@ -206,6 +222,8 @@ def workspace_data(program): ): users[account.invitation_email].append(account) members = expert_rows(program) + scopes = {s.expert_id: s for s in program.expert_case_scopes.all()} + valid_cases = {None, *program_cases(program)} rows = [ { "id": e.pk, @@ -217,6 +235,12 @@ def workspace_data(program): "assigned": counts[e.pk], "completed": completed[e.pk], "remaining": counts[e.pk] - completed[e.pk], + "all_cases": e.pk not in scopes or scopes[e.pk].all_cases, + "case_names": [ + name for name in scopes[e.pk].case_names if name in valid_cases + ] + if e.pk in scopes + else [], } for e in members ] @@ -256,9 +280,132 @@ def workspace_data(program): ), "ready_projects": sum(complete_project[p] >= target(program) for p in submitted), "experts": rows, + "cases": program_cases(program), + "projects": distribution_projects(program, assignments), } +def program_cases(program): + field = get_program_case_field(program) + return field.get_options_list() if field else [] + + +def eligible(scope, case_name): + return scope is None or scope.all_cases or case_name in scope.case_names + + +def distribution_projects(program, assignments=None): + assignments = assignment_snapshot(program) if assignments is None else assignments + protected = set( + ProjectScore.objects.filter(criteria__partner_program=program).values_list( + "project_id", "user_id" + ) + ) + teams = defaultdict(list) + for pid, uid in Collaborator.objects.filter( + project_id__in=PartnerProgramProject.objects.filter( + partner_program=program + ).values("project_id") + ).values_list("project_id", "user_id"): + teams[pid].append(uid) + by_project = defaultdict(list) + for assignment in assignments: + by_project[assignment.project_id].append( + { + "expert_id": assignment.expert_id, + "name": person_name(assignment.expert.user), + "email": assignment.expert.user.email, + "completed": bool(assignment.is_completed), + "protected": (assignment.project_id, assignment.expert.user_id) + in protected, + } + ) + links = project_case_links(program, field=get_program_case_field(program)) + return [ + { + "id": link.project_id, + "name": link.project.name, + "case_name": link.case_name, + "leader_id": link.project.leader_id, + "team_user_ids": teams[link.project_id], + "experts": by_project[link.project_id], + } + for link in links.filter(submitted=True, project__draft=False) + .select_related("project") + .order_by("project_id") + ] + + +@transaction.atomic +def save_case_scope(program_id, user, expert_id, data): + program = managed_program(program_id, user, lock=True) + check_revision(program, data["revision"]) + expert = get_object_or_404(program.experts.filter(user__is_active=True), pk=expert_id) + names = data["case_names"] + if any(name is not None and name not in program_cases(program) for name in names): + raise ValidationError("Выберите кейсы этой программы.") + ExpertCaseScope.objects.update_or_create( + partner_program=program, + expert=expert, + defaults={ + "all_cases": data["all_cases"], + "case_names": list(dict.fromkeys(names)), + }, + ) + return workspace_data(program) + + +@transaction.atomic +def save_project_experts(program_id, user, data): + program = managed_program(program_id, user, lock=True) + check_revision(program, data["revision"]) + 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)} + existing = {(a.project_id, a.expert_id): a for a in assignment_snapshot(program)} + unavailable = wanted - experts.keys() + keep_historical = all((pid, eid) in existing for pid in ids for eid in unavailable) + if not ids or ids - projects.keys() or not keep_historical: + raise ValidationError( + "Выберите сданные проекты и активных экспертов этой программы." + ) + if len(ids) > 1 and len({projects[p]["case_name"] for p in ids}) > 1: + raise ValidationError("Для массового назначения выберите проекты одного кейса.") + replace = data["mode"] == "replace" + if replace and len(ids) != 1: + raise ValidationError("Замена состава доступна только для одного проекта.") + scopes = {s.expert_id: s for s in program.expert_case_scopes.all()} + additions, removals = [], [] + for project_id in ids: + project = projects[project_id] + for expert_id in wanted: + if (project_id, expert_id) in existing: + continue + expert = experts[expert_id] + if not eligible(scopes.get(expert_id), project["case_name"]): + raise ValidationError("Эксперт не выбран для кейса этого проекта.") + if expert.user_id in {project["leader_id"], *project["team_user_ids"]}: + raise ValidationError("Эксперт не может оценивать проект своей команды.") + additions.append( + ProjectExpertAssignment( + partner_program=program, project_id=project_id, expert_id=expert_id + ) + ) + if replace: + for (pid, eid), assignment in existing.items(): + if pid == project_id and eid not in wanted: + if assignment.has_scores(): + raise WorkspaceConflict( + "scores_protected", + "Нельзя снять эксперта с сохранённой оценкой.", + ) + removals.append(assignment.pk) + enable_workspace(program) + program.project_expert_assignments.filter(pk__in=removals).delete() + ProjectExpertAssignment.objects.bulk_create(additions, ignore_conflicts=True) + return workspace_data(program) + + def token_invitation(token, *, lock=False, allow_accepted=False): if not isinstance(token, str) or not 20 <= len(token) <= 100: from django.http import Http404 @@ -433,6 +580,7 @@ def assignment_options(program, expert_id): ).values_list("project_id", flat=True) ) own = own_projects(expert.user_id, [p.project_id for p in links]) + scope = program.expert_case_scopes.filter(expert=expert).first() return { "revision": revision(program), "projects": [ @@ -448,6 +596,7 @@ def assignment_options(program, expert_id): and assignments[p.project_id].is_completed ), "own_team": p.project_id in own, + "eligible": eligible(scope, p.case_name), } for p in links ], @@ -468,7 +617,12 @@ def save_assignments(program_id, user, expert_id, data): ) for project_id in wanted - existing: option = options.get(project_id) - if not option or not option["submitted"] or option["own_team"]: + if ( + not option + or not option["submitted"] + or option["own_team"] + or not option["eligible"] + ): raise ValidationError( "Можно назначить только сданный проект этой программы, кроме своей команды." ) @@ -498,10 +652,30 @@ def save_assignments(program_id, user, expert_id, data): def allocation(program, data): experts = expert_rows(program) selected = set(data["expert_ids"]) - if not selected or selected - {e.pk for e in experts}: + if (not selected and "case_pools" not in data) or selected - {e.pk for e in experts}: raise ValidationError("Выберите принявших приглашение экспертов этой программы.") + if "revision" in data: + check_revision(program, data["revision"]) + scopes = {s.expert_id: s for s in program.expert_case_scopes.all()} + pools = None + if "case_pools" in data: + pools = {} + for pool in data["case_pools"]: + name, members = pool["case_name"], set(pool["expert_ids"]) + if name in pools or (name is not None and name not in program_cases(program)): + raise ValidationError("Проверьте список кейсов распределения.") + if members - selected or any( + not eligible(scopes.get(eid), name) for eid in members + ): + raise ValidationError("Выберите подходящих экспертов для каждого кейса.") + pools[name] = members experts = [e for e in experts if e.pk in selected] - submitted = submitted_links(program) + submitted = list( + project_case_links(program, field=get_program_case_field(program)) + .filter(submitted=True) + .select_related("project") + .order_by("project_id") + ) excluded = unavailable_projects(submitted) if "project_ids" not in data else [] links = [link for link in submitted if not link.project.draft] if "project_ids" in data: @@ -529,7 +703,10 @@ def allocation(program, data): candidates = [ e for e in experts - if (link.project_id, e.pk) not in present and link.project_id not in own[e.pk] + if (link.project_id, e.pk) not in present + and link.project_id not in own[e.pk] + 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]: @@ -539,6 +716,7 @@ def allocation(program, data): "project_name": link.project.name, "expert_id": expert.pk, "expert_name": person_name(expert.user), + "case_name": link.case_name, } ) remaining[expert.pk] += 1 @@ -548,6 +726,7 @@ def allocation(program, data): "project_id": link.project_id, "project_name": link.project.name, "missing": needed - len(candidates), + "case_name": link.case_name, } ) rev = revision(program) diff --git a/project_rates/workspace_views.py b/project_rates/workspace_views.py index 0a654999..a5a13d9e 100644 --- a/project_rates/workspace_views.py +++ b/project_rates/workspace_views.py @@ -36,13 +36,39 @@ class AssignmentWrite(RevisionWrite): ) +class CasePoolWrite(serializers.Serializer): + case_name = serializers.CharField(allow_null=True, max_length=1000) + expert_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), max_length=1000 + ) + + +class CaseScopeWrite(RevisionWrite): + all_cases = serializers.BooleanField() + case_names = serializers.ListField( + child=serializers.CharField(allow_null=True, max_length=1000), max_length=1000 + ) + + +class ProjectExpertsWrite(RevisionWrite): + project_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), allow_empty=False, max_length=10000 + ) + expert_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), max_length=1000 + ) + mode = serializers.ChoiceField(choices=["add", "replace"], default="add") + + class AllocationWrite(serializers.Serializer): expert_ids = serializers.ListField( - child=serializers.IntegerField(min_value=1), allow_empty=False, max_length=1000 + child=serializers.IntegerField(min_value=1), max_length=1000 ) project_ids = serializers.ListField( child=serializers.IntegerField(min_value=1), required=False, max_length=10000 ) + case_pools = CasePoolWrite(many=True, required=False, max_length=1000) + revision = serializers.CharField(max_length=64, required=False) class QuotaWrite(serializers.Serializer): @@ -102,6 +128,24 @@ def put(self, request, pk, expert_id): return Response(service.save_assignments(pk, request.user, expert_id, data)) +class ExpertCaseScopeView(PrivateView): + def put(self, request, pk, expert_id): + return Response( + service.save_case_scope( + pk, request.user, expert_id, validated(CaseScopeWrite, request.data) + ) + ) + + +class ProjectExpertsView(PrivateView): + def post(self, request, pk): + return Response( + service.save_project_experts( + pk, request.user, validated(ProjectExpertsWrite, request.data) + ) + ) + + class AllocationView(PrivateView): @transaction.atomic def post(self, request, pk):