diff --git a/docs/expert-workspace.md b/docs/expert-workspace.md index 39d9033c..508dae7d 100644 --- a/docs/expert-workspace.md +++ b/docs/expert-workspace.md @@ -22,6 +22,21 @@ | POST `evaluators/` | Предпросмотр `{required_evaluators}` | | PUT `evaluators/` | Подтвердить `{required_evaluators, revision}` | +`GET experts/` и предпросмотр `POST evaluators/` возвращают +`projects_missing_experts` — число сданных проектов, для которых выбрано меньше N +экспертов. Это отличается от `missing_assignments` (сумма незаполненных мест для +экспертов по всем проектам) и `ready_projects` (достаточно полных оценок). +Предпросмотр также возвращает `submitted_projects`. +В `/programs/{id}/manager-overview/` поле `evaluation_status.required_evaluators` +задаёт тот же N; null означает legacy-режим со старым `max_evaluations_per_project`. +Старое поле сохранено для совместимости и не является N нового режима. + +Имя приглашённого берётся только из аккаунта с соответствующим email, без учёта +регистра. При неоднозначных legacy-аккаунтах имя/аватар не угадываются. +`delivery_status` — отправка письма (`queued`/`sent`/`failed`), а `status` — состояние +самого приглашения. `sent` означает успешную передачу почтовому backend, не принятие +приглашения и не подтверждение доставки во входящие. + `GET /evaluation/programs/` возвращает только программы пользователя, его счётчики assigned/completed/remaining и действующие приглашения на его email. `POST /evaluation/own-invitations/{id}/accept/` явно принимает своё приглашение. diff --git a/partner_programs/serializers/analytics.py b/partner_programs/serializers/analytics.py index e54e21da..e0eff449 100644 --- a/partner_programs/serializers/analytics.py +++ b/partner_programs/serializers/analytics.py @@ -54,6 +54,7 @@ class ProgramProjectEvaluationSerializer(serializers.Serializer): class ProgramEvaluationStatusSerializer(serializers.Serializer): mode = serializers.ChoiceField(choices=("open", "distributed")) + required_evaluators = serializers.IntegerField(min_value=1, allow_null=True) max_evaluations_per_project = serializers.IntegerField( min_value=1, allow_null=True, diff --git a/partner_programs/services/analytics.py b/partner_programs/services/analytics.py index cc49c499..83213d23 100644 --- a/partner_programs/services/analytics.py +++ b/partner_programs/services/analytics.py @@ -428,6 +428,7 @@ def build_program_manager_analytics(program) -> dict: "evaluation_status": { "mode": ("distributed" if program.is_distributed_evaluation else "open"), "max_evaluations_per_project": program.max_project_rates, + "required_evaluators": program.required_evaluators, "assignments": { "total": assignments["total"], "pending": assignments["pending"], diff --git a/partner_programs/tests/test_manager_analytics_api.py b/partner_programs/tests/test_manager_analytics_api.py index fd7c819b..7535f96a 100644 --- a/partner_programs/tests/test_manager_analytics_api.py +++ b/partner_programs/tests/test_manager_analytics_api.py @@ -337,6 +337,7 @@ def test_open_evaluation_uses_any_score_instead_of_max_project_rates(self): response.data["evaluation_status"], { "mode": "open", + "required_evaluators": None, "max_evaluations_per_project": 2, # Open project evaluation still needs any score, but the real # assignments also require the automatically created Comment. diff --git a/project_rates/tests/test_workspace.py b/project_rates/tests/test_workspace.py index b1d634c3..76252832 100644 --- a/project_rates/tests/test_workspace.py +++ b/project_rates/tests/test_workspace.py @@ -33,6 +33,71 @@ PASSWORD_HASHERS=["django.contrib.auth.hashers.MD5PasswordHasher"], ) class WorkspaceTests(TestCase): + def test_pending_names_match_only_invited_email_case_insensitively(self): + self.manager.first_name, self.manager.last_name = "Sender", "Organizer" + self.manager.save() + invited = create_rate_user(prefix="invited-person") + invited.email = "Play_Station399@example.com" + invited.first_name, invited.last_name = "Yaya", "Aya" + invited.save() + self.invitation(self.manager.email.lower(), "sender-invitation-token-0123456789") + self.invitation(invited.email.lower(), "other-invitation-token-0123456789") + self.invitation("new@example.com", "new-person-invitation-token-0123456789") + response = self.client.get(self.root + "experts/") + self.assertEqual(response.status_code, 200) + rows = {row["email"]: row for row in response.data["experts"]} + self.assertEqual(rows[invited.email.lower()]["name"], "Yaya Aya") + self.assertEqual(rows[self.manager.email.lower()]["name"], "Sender Organizer") + self.assertIsNone(rows["new@example.com"]["name"]) + invited.refresh_from_db() + self.assertEqual(invited.first_name, "Yaya") + + def test_ambiguous_legacy_email_does_not_guess_invited_person(self): + one = create_rate_user(prefix="one-invited") + two = create_rate_user(prefix="two-invited") + CustomUser.objects.filter(pk=one.pk).update(email="Legacy@example.com") + CustomUser.objects.filter(pk=two.pk).update(email="legacy@example.com") + self.invitation("legacy@example.com") + rows = self.client.get(self.root + "experts/").data["experts"] + row = next(row for row in rows if row["email"] == "legacy@example.com") + self.assertIsNone(row["name"]) + self.assertIsNone(row["avatar"]) + + def test_quota_and_analytics_share_required_count_and_missing_project_count(self): + self.enable(3) + self.program.max_project_rates = 3 + self.program.save() + other = create_rate_project() + self.program.users.add( + other.leader, through_defaults={"partner_program_data": {}} + ) + other_link = link_project_to_program(self.program, other) + other_link.submitted = True + other_link.save() + ProjectExpertAssignment.objects.create( + partner_program=self.program, project=self.project, expert=self.expert.expert + ) + before = self.client.get(self.root + "experts/").data + self.assertEqual(before["projects_missing_experts"], 2) + self.assertEqual(before["missing_assignments"], 5) + preview = self.post(self.root + "evaluators/", {"required_evaluators": 1}).data + self.assertEqual(preview["projects_missing_experts"], 1) + self.assertEqual(preview["submitted_projects"], 2) + self.assertEqual(preview["missing_assignments"], 1) + updated = self.client.put( + self.root + "evaluators/", + {"required_evaluators": 1, "revision": preview["revision"]}, + format="json", + ) + self.assertEqual(updated.status_code, 200) + self.assertEqual(updated.data["projects_missing_experts"], 1) + analytics = self.client.get(f"/programs/{self.program.pk}/manager-overview/") + self.assertEqual(analytics.status_code, 200) + self.assertEqual(analytics.data["evaluation_status"]["required_evaluators"], 1) + self.assertEqual( + analytics.data["evaluation_status"]["max_evaluations_per_project"], 3 + ) + def test_regular_profile_cannot_clear_birthday(self): response = self.client.patch( f"/auth/users/{self.manager.pk}/", {"birthday": None}, format="json" diff --git a/project_rates/workspace.py b/project_rates/workspace.py index 5c652a34..7ec0646c 100644 --- a/project_rates/workspace.py +++ b/project_rates/workspace.py @@ -8,6 +8,7 @@ from django.core import signing from django.db import transaction +from django.db.models.functions import Lower from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework.exceptions import APIException, PermissionDenied, ValidationError @@ -170,10 +171,11 @@ def workspace_data(program): invitations = list( program.expert_invitations.exclude(status="accepted").order_by("pk") ) - users = { - u.email.lower(): u - for u in CustomUser.objects.filter(email__in=[i.email for i in invitations]) - } + users = defaultdict(list) + for account in CustomUser.objects.annotate(invitation_email=Lower("email")).filter( + invitation_email__in=[i.email.lower() for i in invitations] + ): + users[account.invitation_email].append(account) members = expert_rows(program) rows = [ { @@ -193,7 +195,9 @@ def workspace_data(program): for invitation in invitations: if invitation.email in joined_emails: continue - user = users.get(invitation.email) + matches = users.get(invitation.email.lower(), []) + # Старые аккаунты могут отличаться только регистром email: не выбираем чужое имя. + user = matches[0] if len(matches) == 1 else None rows.append( { **invitation_data(invitation), @@ -218,6 +222,9 @@ def workspace_data(program): "required_evaluators": target(program), "managed_evaluation": program.required_evaluators is not None, "submitted_projects": len(submitted), + "projects_missing_experts": sum( + per_project[p] < target(program) for p in submitted + ), "missing_assignments": sum( max(0, target(program) - per_project[p]) for p in submitted ), @@ -591,6 +598,8 @@ def quota_preview(program, number): return { "revision": revision(program), "required_evaluators": number, + "submitted_projects": len(ids), + "projects_missing_experts": sum(counts[p] < number for p in ids), "ready_before": sum(completed[p] >= target(program) for p in ids), "ready_after": sum(completed[p] >= number for p in ids), "missing_assignments": sum(max(0, number - counts[p]) for p in ids),