From 5b7df8ad1d0dee81336c0aeccf9311daa19477ca Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 18 Oct 2026 14:25:00 +0300 Subject: [PATCH 1/2] fix: resolve invited expert to unique active email account --- docs/expert-workspace.md | 4 +++- project_rates/tests/test_workspace.py | 27 +++++++++++++++++++++++++++ project_rates/workspace.py | 12 ++++++++++-- project_rates/workspace_views.py | 11 ++++------- 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/docs/expert-workspace.md b/docs/expert-workspace.md index 508dae7d..d9e6e185 100644 --- a/docs/expert-workspace.md +++ b/docs/expert-workspace.md @@ -32,7 +32,9 @@ Старое поле сохранено для совместимости и не является N нового режима. Имя приглашённого берётся только из аккаунта с соответствующим email, без учёта -регистра. При неоднозначных legacy-аккаунтах имя/аватар не угадываются. +регистра. При наличии неподтверждённого legacy-дубля используется единственный +активный аккаунт; тот же аккаунт определяет login_email в приглашении. При нескольких +активных совпадениях имя/аватар не угадываются. Записи аккаунтов не изменяются. `delivery_status` — отправка письма (`queued`/`sent`/`failed`), а `status` — состояние самого приглашения. `sent` означает успешную передачу почтовому backend, не принятие приглашения и не подтверждение доставки во входящие. diff --git a/project_rates/tests/test_workspace.py b/project_rates/tests/test_workspace.py index 76252832..bef65b7a 100644 --- a/project_rates/tests/test_workspace.py +++ b/project_rates/tests/test_workspace.py @@ -63,6 +63,33 @@ def test_ambiguous_legacy_email_does_not_guess_invited_person(self): self.assertIsNone(row["name"]) self.assertIsNone(row["avatar"]) + def test_inactive_lowercase_duplicate_does_not_hide_active_invited_account(self): + active = create_rate_user(prefix="active-invited") + inactive = create_rate_user(prefix="inactive-invited") + CustomUser.objects.filter(pk=active.pk).update( + email="Play_Station399@example.com", first_name="Yaya", last_name="Aya" + ) + CustomUser.objects.filter(pk=inactive.pk).update( + email="play_station399@example.com", + first_name="Fedor", + last_name="Evseev", + is_active=False, + ) + invitation, token = self.invitation("play_station399@example.com") + rows = self.client.get(self.root + "experts/").data["experts"] + row = next(row for row in rows if row["email"] == invitation.email) + self.assertEqual(row["name"], "Yaya Aya") + self.client.force_authenticate(None) + preview = self.client.get(f"/evaluation/invitations/{token}/").data + self.assertTrue(preview["account_exists"]) + self.assertEqual(preview["login_email"], "Play_Station399@example.com") + login = self.post( + "/api/token/", {"email": preview["login_email"], "password": "pass"} + ) + self.assertEqual(login.status_code, 200) + inactive.refresh_from_db() + self.assertFalse(inactive.is_active) + def test_quota_and_analytics_share_required_count_and_missing_project_count(self): self.enable(3) self.program.max_project_rates = 3 diff --git a/project_rates/workspace.py b/project_rates/workspace.py index 7ec0646c..b2173c49 100644 --- a/project_rates/workspace.py +++ b/project_rates/workspace.py @@ -164,6 +164,15 @@ def program_card(program): } +def invited_account(accounts): + # В legacy-базе регистр email мог породить неподтверждённый дубль аккаунта. + # Принять приглашение может только активный пользователь; выбираем его, + # если он единственный. Неоднозначные активные аккаунты не угадываем. + active = [account for account in accounts if account.is_active] + candidates = active or accounts + return candidates[0] if len(candidates) == 1 else None + + def workspace_data(program): assignments = assignment_snapshot(program) counts = Counter(a.expert_id for a in assignments) @@ -196,8 +205,7 @@ def workspace_data(program): if invitation.email in joined_emails: continue matches = users.get(invitation.email.lower(), []) - # Старые аккаунты могут отличаться только регистром email: не выбираем чужое имя. - user = matches[0] if len(matches) == 1 else None + user = invited_account(matches) rows.append( { **invitation_data(invitation), diff --git a/project_rates/workspace_views.py b/project_rates/workspace_views.py index c7e71c5a..0a654999 100644 --- a/project_rates/workspace_views.py +++ b/project_rates/workspace_views.py @@ -197,17 +197,14 @@ class PublicInvitationView(PrivateView): def get(self, request, token): invitation = service.token_invitation(token, allow_accepted=True) - login_email = ( - CustomUser.objects.filter(email__iexact=invitation.email) - .values_list("email", flat=True) - .first() - ) + accounts = list(CustomUser.objects.filter(email__iexact=invitation.email)) + account = service.invited_account(accounts) return Response( { **service.invitation_data(invitation), "program": service.program_card(invitation.partner_program), - "account_exists": login_email is not None, - "login_email": login_email or invitation.email, + "account_exists": bool(accounts), + "login_email": account.email if account else invitation.email, "matches_account": bool( request.user.is_authenticated and request.user.email.lower() == invitation.email From 2640328c53fa78189a612cc67f8e8b4806509885 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Sun, 18 Oct 2026 14:31:56 +0300 Subject: [PATCH 2/2] fix: report submitted drafts excluded from expert allocation --- docs/expert-workspace.md | 4 +++ project_rates/tests/test_workspace.py | 30 +++++++++++++++++++++ project_rates/workspace.py | 39 ++++++++++++++++++--------- 3 files changed, 61 insertions(+), 12 deletions(-) diff --git a/docs/expert-workspace.md b/docs/expert-workspace.md index d9e6e185..8e896282 100644 --- a/docs/expert-workspace.md +++ b/docs/expert-workspace.md @@ -27,6 +27,10 @@ экспертов. Это отличается от `missing_assignments` (сумма незаполненных мест для экспертов по всем проектам) и `ready_projects` (достаточно полных оценок). Предпросмотр также возвращает `submitted_projects`. +Workspace и план распределения возвращают `unavailable_projects`: сданные связи, +у которых проект всё ещё draft (project_id/project_name/reason=draft). Они не +назначаются автоматически; публикация остаётся действием автора. Это пояснение +сохраняет прежний состав доступных к оценке проектов и не меняет сдачу/публикацию. В `/programs/{id}/manager-overview/` поле `evaluation_status.required_evaluators` задаёт тот же N; null означает legacy-режим со старым `max_evaluations_per_project`. Старое поле сохранено для совместимости и не является N нового режима. diff --git a/project_rates/tests/test_workspace.py b/project_rates/tests/test_workspace.py index bef65b7a..e66d7e92 100644 --- a/project_rates/tests/test_workspace.py +++ b/project_rates/tests/test_workspace.py @@ -133,6 +133,36 @@ def test_regular_profile_cannot_clear_birthday(self): self.manager.refresh_from_db() self.assertIsNotNone(self.manager.birthday) + def test_submitted_draft_is_explained_and_not_silently_distributed(self): + self.enable(1) + type(self.project).objects.filter(pk=self.project.pk).update(draft=True) + excluded = [ + { + "project_id": self.project.pk, + "project_name": self.project.name, + "reason": "draft", + } + ] + snapshot = self.client.get(self.root + "experts/").data + self.assertEqual(snapshot["submitted_projects"], 1) + self.assertEqual(snapshot["projects_missing_experts"], 1) + self.assertEqual(snapshot["unavailable_projects"], excluded) + plan = self.post( + self.root + "expert-allocation/", {"expert_ids": [self.expert.expert.pk]} + ).data + self.assertEqual(plan["assignments"], []) + self.assertEqual(plan["unavailable_projects"], excluded) + self.assertEqual(plan["shortages"], []) + self.project.refresh_from_db() + self.assertTrue(self.project.draft) + self.assertFalse(ProjectExpertAssignment.objects.exists()) + type(self.project).objects.filter(pk=self.project.pk).update(draft=False) + plan = self.post( + self.root + "expert-allocation/", {"expert_ids": [self.expert.expert.pk]} + ).data + self.assertEqual(plan["unavailable_projects"], []) + self.assertEqual(len(plan["assignments"]), 1) + @patch("project_rates.services.send_email.delay") def test_managed_scoring_respects_submission_own_team_and_deadline(self, send): self.enable(1) diff --git a/project_rates/workspace.py b/project_rates/workspace.py index b2173c49..f90e2f6a 100644 --- a/project_rates/workspace.py +++ b/project_rates/workspace.py @@ -173,6 +173,26 @@ def invited_account(accounts): return candidates[0] if len(candidates) == 1 else None +def submitted_links(program): + return list( + PartnerProgramProject.objects.filter(partner_program=program, submitted=True) + .select_related("project") + .order_by("project_id") + ) + + +def unavailable_projects(links): + return [ + { + "project_id": link.project_id, + "project_name": link.project.name, + "reason": "draft", + } + for link in links + if link.project.draft + ] + + def workspace_data(program): assignments = assignment_snapshot(program) counts = Counter(a.expert_id for a in assignments) @@ -218,11 +238,8 @@ def workspace_data(program): "remaining": None, } ) - submitted = list( - PartnerProgramProject.objects.filter(partner_program=program, submitted=True) - .values_list("project_id", flat=True) - .distinct() - ) + links = submitted_links(program) + submitted = {link.project_id for link in links} per_project = Counter(a.project_id for a in assignments) complete_project = Counter(a.project_id for a in assignments if a.is_completed) return { @@ -230,6 +247,7 @@ def workspace_data(program): "required_evaluators": target(program), "managed_evaluation": program.required_evaluators is not None, "submitted_projects": len(submitted), + "unavailable_projects": unavailable_projects(links), "projects_missing_experts": sum( per_project[p] < target(program) for p in submitted ), @@ -483,13 +501,9 @@ def allocation(program, data): if not selected or selected - {e.pk for e in experts}: raise ValidationError("Выберите принявших приглашение экспертов этой программы.") experts = [e for e in experts if e.pk in selected] - links = list( - PartnerProgramProject.objects.filter( - partner_program=program, submitted=True, project__draft=False - ) - .select_related("project") - .order_by("project_id") - ) + submitted = submitted_links(program) + 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: ids = set(data["project_ids"]) if ids - {p.project_id for p in links}: @@ -555,6 +569,7 @@ def allocation(program, data): "plan_token": token, "assignments": additions, "shortages": shortages, + "unavailable_projects": excluded, "load": [ { "expert_id": e.pk,