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
8 changes: 7 additions & 1 deletion docs/expert-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,18 @@
экспертов. Это отличается от `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 нового режима.

Имя приглашённого берётся только из аккаунта с соответствующим email, без учёта
регистра. При неоднозначных legacy-аккаунтах имя/аватар не угадываются.
регистра. При наличии неподтверждённого legacy-дубля используется единственный
активный аккаунт; тот же аккаунт определяет login_email в приглашении. При нескольких
активных совпадениях имя/аватар не угадываются. Записи аккаунтов не изменяются.
`delivery_status` — отправка письма (`queued`/`sent`/`failed`), а `status` — состояние
самого приглашения. `sent` означает успешную передачу почтовому backend, не принятие
приглашения и не подтверждение доставки во входящие.
Expand Down
57 changes: 57 additions & 0 deletions project_rates/tests/test_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="[email protected]", first_name="Yaya", last_name="Aya"
)
CustomUser.objects.filter(pk=inactive.pk).update(
email="[email protected]",
first_name="Fedor",
last_name="Evseev",
is_active=False,
)
invitation, token = self.invitation("[email protected]")
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"], "[email protected]")
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
Expand Down Expand Up @@ -106,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)
Expand Down
51 changes: 37 additions & 14 deletions project_rates/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,35 @@ 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 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)
Expand Down Expand Up @@ -196,8 +225,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),
Expand All @@ -210,18 +238,16 @@ 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 {
"revision": revision(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
),
Expand Down Expand Up @@ -475,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}:
Expand Down Expand Up @@ -547,6 +569,7 @@ def allocation(program, data):
"plan_token": token,
"assignments": additions,
"shortages": shortages,
"unavailable_projects": excluded,
"load": [
{
"expert_id": e.pk,
Expand Down
11 changes: 4 additions & 7 deletions project_rates/workspace_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading