Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions docs/expert-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,13 @@ Revision учитывает определения/значения кейсов
Нехватка кандидатов блокирует применение; план действует 15 минут.
- Все новые записи организатора и сохранение оценок используют одну блокировку
строки программы. Конкурирующая запись отклоняется целиком с 409.
- N — минимум полных оценок, а не максимум. Изменять его можно в любое время;
готовность пересчитывается, оценки и лишние назначения сохраняются.
- N — число оценок для готовности проекта и лимит новых назначений экспертов.
Изменять его можно в любое время; готовность пересчитывается, уже сохранённые
оценки и лишние назначения сохраняются. При превышении нового N можно снимать
только назначения без оценки; новые назначения разрешены, только если итоговый
состав не превышает N. Замена эксперта без оценки в заполненном составе разрешена.
Одиночное, массовое, старое ручное API и применение автоматического плана
проверяют лимит под общей блокировкой программы; массовое назначение атомарно.
- Управляемая форма оценки сохраняется целиком: целые баллы и Да/нет обязательны,
комментарий необязателен. 0 и False — допустимые ответы. Дедлайн, назначение,
сданное решение и запрет оценки своей команды проверяются сервером.
Expand Down
140 changes: 140 additions & 0 deletions project_rates/tests/test_case_distribution.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions project_rates/tests/test_workspace_concurrency.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "[email protected]"
Expand Down
28 changes: 26 additions & 2 deletions project_rates/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"]}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
Loading