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
15 changes: 15 additions & 0 deletions docs/expert-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/` явно принимает своё приглашение.
Expand Down
1 change: 1 addition & 0 deletions partner_programs/serializers/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions partner_programs/services/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
1 change: 1 addition & 0 deletions partner_programs/tests/test_manager_analytics_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
65 changes: 65 additions & 0 deletions project_rates/tests/test_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "[email protected]"
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("[email protected]", "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["[email protected]"]["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="[email protected]")
CustomUser.objects.filter(pk=two.pk).update(email="[email protected]")
self.invitation("[email protected]")
rows = self.client.get(self.root + "experts/").data["experts"]
row = next(row for row in rows if row["email"] == "[email protected]")
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"
Expand Down
19 changes: 14 additions & 5 deletions project_rates/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = [
{
Expand All @@ -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),
Expand All @@ -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
),
Expand Down Expand Up @@ -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),
Expand Down
Loading