diff --git a/docs/program-manager-analytics-api.md b/docs/program-manager-analytics-api.md index b071cb28..471a7c40 100644 --- a/docs/program-manager-analytics-api.md +++ b/docs/program-manager-analytics-api.md @@ -6,7 +6,9 @@ Endpoint доступен менеджерам указанной программы, staff и superuser. Для авторизованного пользователя без этих прав возвращается `403`, для неизвестной -программы — `404`. +программы — `404`, для anonymous — `401`. Тот же `can_manage_program` +используется обоими read-only drilldown endpoints ниже. Эксперт программы без +прав менеджера доступа не получает. POST/PATCH/PUT/DELETE не поддерживаются. ## Контракт @@ -54,7 +56,8 @@ Endpoint доступен менеджерам указанной програм }, "attention": { "participants_without_team": 1, - "projects_awaiting_evaluation": 1 + "projects_awaiting_evaluation": 1, + "delayed_experts": {"total": 0, "items": []} }, "activity": [ { @@ -91,8 +94,9 @@ Endpoint доступен менеджерам указанной програм `ProjectExpertAssignment`. Проект без назначений либо без выполненных назначений ожидает оценивания; проект с частью выполненных назначений имеет статус `partially_evaluated`; при выполнении всех назначений — `evaluated`. -- Назначение считается оценённым, если назначенный эксперт сохранил хотя бы - один `ProjectScore` этого проекта по критерию текущей программы. +- Назначение считается выполненным только после сдачи проекта и заполнения + **всех критериев текущей программы** назначенным экспертом. Наличие одной + оценки больше не означает завершение назначения (см. статусы ниже). - В открытом режиме `projects_awaiting_evaluation` включает только сданные проекты без оценки. В распределённом режиме он включает ожидающие и частично оценённые проекты. @@ -120,3 +124,209 @@ Endpoint доступен менеджерам указанной програм Для такой аналитики нужна отдельная модель кейса и явная внешняя связь `PartnerProgramProject` с выбранным кейсом либо утверждённое системное поле с гарантированным идентификатором. + +## Статусы назначений и проектов + +Источники истины: `ProjectExpertAssignment` (программа × проект × эксперт), +`ProjectScore` (критерий × пользователь эксперта × проект), `Criteria` программы, +`PartnerProgramProject.submitted` / `datetime_submitted` и дата создания назначения. +Модели, запись оценок и поведение сдачи проекта не меняются. + +`criteria_total` — число критериев программы. `criteria_scored` — число DISTINCT +критериев этой программы, по которым существует строка оценки именно этого +пользователя и проекта. Оценки другой программы/эксперта/проекта не учитываются. +Строка со значением `"0"` или пустым/nullable значением считается существующей +оценкой; аналитика не вводит новую валидацию `ProjectScore.value`. +Создаваемый текущим signal критерий «Комментарий» типа `str` также входит в +общее число критериев: исключения по названию или типу не вводятся. + +| Условие | status | +| --- | --- | +| Проект не сдан в этой программе | `not_ready` | +| Сдан, критериев нет или оценено 0 критериев | `pending` | +| Сдан, 0 < оценено < всего критериев | `in_progress` | +| Сдан, всего > 0 и оценено >= всего | `completed` | + +Старые имена полей `evaluation_status.assignments` сохраняются: +`total` — все реальные назначения, `evaluated` — только `completed`, +`pending` — `not_ready` + `pending` + `in_progress`. +Всегда `total = evaluated + pending`. + +Для сданного проекта в distributed-режиме: + +- `awaiting_evaluation`: нет назначений или ни одно не завершено; +- `partially_evaluated` («Частично оценено»): хотя бы один назначенный эксперт + завершил все критерии, но не все назначения завершены; +- `evaluated`: назначений больше нуля и все они завершены. + +Частичное заполнение критериев без завершённого эксперта само по себе не даёт +проекту статус «Частично оценено». В open-режиме прежняя семантика проекта +сохранена: первая оценка по критерию программы достаточна; фиктивные назначения +из оценок не создаются. + +## Список назначений + +`GET /programs//manager-overview/assignments/?scope=all` + +Ответ `200` — JSON-массив, без пагинационной обёртки; пустой результат `[]`. +Стабильная сортировка по `assignment_id` по возрастанию. +`scope` допускает только `all` (по умолчанию), `completed`, `pending`. +`pending` включает все незавершённые статусы, в том числе `not_ready`. +Неизвестное или пустое значение — `400` с ошибкой поля `scope`. +В open-режиме возвращаются только физически существующие назначения. + +Пример при времени запроса `2026-09-05T00:00:00Z`: + +```json +[ + { + "assignment_id": 17, + "expert": { + "expert_id": 4, + "user_id": 123, + "first_name": "Иван", + "last_name": "Иванов", + "full_name": "Иван Иванов", + "avatar": null + }, + "project": {"id": 55, "name": "Проект А"}, + "status": "in_progress", + "criteria_total": 3, + "criteria_scored": 1, + "assigned_at": "2026-09-03T10:00:00Z", + "project_submitted": true, + "project_submitted_at": "2026-09-03T12:00:00Z", + "waiting_since": "2026-09-03T12:00:00Z", + "waiting_seconds": 129600 + } +] +``` + +`expert` — явный allow-list, без email/телефона/auth-полей. `avatar` — URL или +`null`, `full_name` — имя и фамилия через пробел (пустые части пропускаются). +Даты — ISO 8601 в настроенной Django timezone; `Z` в примерах означает UTC. + +### Время ожидания + +Для submitted + non-completed: +`waiting_since = max(datetime_submitted, assignment.datetime_created)`; +`waiting_seconds = max(0, floor((now - waiting_since).total_seconds()))`. +В одном ответе используется один `now` для всех назначений. + +Для `not_ready`: `project_submitted_at`, `waiting_since`, `waiting_seconds` — +`null`. Для `completed` оба поля ожидания — `null`, дата сдачи сохранена. +Если у legacy-сданного проекта `datetime_submitted=null`, статус рассчитывается +обычно, но ожидание остаётся `null`: достоверного начала SLA нет. Такая запись +не объявляется просроченной. Дата создания проекта никогда не подставляется. +Будущая дата даёт 0 секунд ожидания и не создаёт просрочку. + +## Оценки назначения + +`GET /programs//manager-overview/assignments//scores/` + +Ответ `200` — **все поля элемента списка выше**, плюс массив `scores`. +Назначение ищется только внутри указанной программы; чужое/несуществующее — +`404`, даже если менеджер управляет обеими программами. + +Например к элементу `17` выше добавляется: + +```json +{ + "scores": [ + { + "criterion_id": 1, + "name": "Новизна", + "description": "Оцените новизну решения", + "type": "int", + "min_value": 0, + "max_value": 10, + "value": "0", + "is_scored": true + }, + { + "criterion_id": 2, + "name": "Реализуемость", + "description": null, + "type": "int", + "min_value": 0, + "max_value": 10, + "value": null, + "is_scored": false + }, + { + "criterion_id": 3, + "name": "Комментарий", + "description": null, + "type": "str", + "min_value": null, + "max_value": null, + "value": null, + "is_scored": false + } + ] +} +``` + +Возвращаются все критерии программы по возрастанию `criterion_id`. +`value` сохраняет строковый/nullable контракт модели без преобразования чисел +или обрезки пробелов. `is_scored` означает наличие строки `ProjectScore`: +он отличает отсутствие оценки от существующей строки с `value=null`. + +## Требует внимания: задержки экспертов + +`attention.delayed_experts = {"total": <число экспертов>, "items": [...]}`. +Существующие `participants_without_team` и `projects_awaiting_evaluation` +сохраняются. В open-режиме всегда `{"total": 0, "items": []}`. + +SLA учитывает только сданные, незавершённые назначения с известным наступившим +`waiting_since`: + +- `warning`: минимум 2 назначения ждут каждое >= 24 часов; +- `critical`: хотя бы 1 назначение ждёт >= 48 часов (имеет приоритет). + +Один проект, ожидающий 25 часов, не даёт предупреждение. Выполненные, +несданные, будущие и назначения другой программы не создают просрочку. +`assignments_total`, `completed`, `pending` включают все реальные назначения +эксперта текущей программы, включая несданные в `pending`. + +```json +{ + "total": 1, + "items": [ + { + "expert_id": 4, + "user_id": 123, + "first_name": "Иван", + "last_name": "Иванов", + "full_name": "Иван Иванов", + "avatar": null, + "assignments_total": 8, + "completed": 2, + "pending": 6, + "overdue_24h": 4, + "overdue_48h": 1, + "oldest_waiting_since": "2026-09-02T20:00:00Z", + "oldest_waiting_seconds": 187200, + "severity": "critical" + } + ] +} +``` + +Сортировка: critical перед warning, затем большее время ожидания, затем +`expert_id` по возрастанию. Старейшее ожидание берётся среди незавершённых +сданных назначений с известной датой, а не по дате создания проекта. + +## Производительность и границы + +Прогресс, дата сдачи и безопасные поля пользователя/проекта выбираются одним +SQL SELECT: связанные таблицы через JOIN, число критериев и DISTINCT-оценок — +через Subquery/Count; связь проекта с программой также через Subquery. +Нет запросов из сериализаторов и отдельных SQL-запросов в цикле назначений. +Сводка повторно использует тот же список для счётчиков проектов и SLA. +Score drilldown добавляет два фиксированных запроса (критерии и оценки пары). + +Regression query budget для manager с уже аутентифицированным request.user: +список — 3 SQL, overview — 10 SQL, scores — 5 SQL; не растёт при переходе +от 1 к 31 назначению. JWT/session-аутентификация может добавить свои запросы. +Проверяются SQLite и PostgreSQL; новых моделей, индексов и миграций нет. diff --git a/partner_programs/serializers/analytics.py b/partner_programs/serializers/analytics.py index 92ace270..83c4171b 100644 --- a/partner_programs/serializers/analytics.py +++ b/partner_programs/serializers/analytics.py @@ -60,9 +60,78 @@ class ProgramEvaluationStatusSerializer(serializers.Serializer): projects = ProgramProjectEvaluationSerializer() +class AssignmentExpertSerializer(serializers.Serializer): + expert_id = serializers.IntegerField() + user_id = serializers.IntegerField() + first_name = serializers.CharField(allow_blank=True) + last_name = serializers.CharField(allow_blank=True) + full_name = serializers.CharField(allow_blank=True) + avatar = serializers.URLField(allow_null=True) + + +class AssignmentProjectSerializer(serializers.Serializer): + id = serializers.IntegerField() + name = serializers.CharField() + + +class ProgramAssignmentScopeSerializer(serializers.Serializer): + scope = serializers.ChoiceField( + choices=("all", "completed", "pending"), default="all" + ) + + +class ProgramAssignmentSerializer(serializers.Serializer): + assignment_id = serializers.IntegerField() + expert = AssignmentExpertSerializer() + project = AssignmentProjectSerializer() + status = serializers.ChoiceField( + choices=("not_ready", "pending", "in_progress", "completed") + ) + criteria_total = serializers.IntegerField(min_value=0) + criteria_scored = serializers.IntegerField(min_value=0) + assigned_at = serializers.DateTimeField() + project_submitted = serializers.BooleanField() + project_submitted_at = serializers.DateTimeField(allow_null=True) + waiting_since = serializers.DateTimeField(allow_null=True) + waiting_seconds = serializers.IntegerField(min_value=0, allow_null=True) + + +class AssignmentCriterionSerializer(serializers.Serializer): + criterion_id = serializers.IntegerField() + name = serializers.CharField() + description = serializers.CharField(allow_null=True, allow_blank=True) + type = serializers.CharField() + min_value = serializers.FloatField(allow_null=True) + max_value = serializers.FloatField(allow_null=True) + value = serializers.CharField( + allow_null=True, allow_blank=True, trim_whitespace=False + ) + is_scored = serializers.BooleanField() + + +class ProgramAssignmentScoresSerializer(ProgramAssignmentSerializer): + scores = AssignmentCriterionSerializer(many=True) + + +class DelayedExpertSerializer(AssignmentExpertSerializer): + assignments_total = serializers.IntegerField(min_value=0) + completed = serializers.IntegerField(min_value=0) + pending = serializers.IntegerField(min_value=0) + overdue_24h = serializers.IntegerField(min_value=0) + overdue_48h = serializers.IntegerField(min_value=0) + oldest_waiting_since = serializers.DateTimeField() + oldest_waiting_seconds = serializers.IntegerField(min_value=0) + severity = serializers.ChoiceField(choices=("critical", "warning")) + + +class DelayedExpertsSerializer(AnalyticsTotalSerializer): + items = DelayedExpertSerializer(many=True) + + class ProgramAttentionSerializer(serializers.Serializer): participants_without_team = serializers.IntegerField(min_value=0) projects_awaiting_evaluation = serializers.IntegerField(min_value=0) + delayed_experts = DelayedExpertsSerializer() class ProgramActivityItemSerializer(serializers.Serializer): diff --git a/partner_programs/services/analytics.py b/partner_programs/services/analytics.py index 74494509..abc84f48 100644 --- a/partner_programs/services/analytics.py +++ b/partner_programs/services/analytics.py @@ -7,7 +7,10 @@ from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile from projects.models import Collaborator -from project_rates.models import ProjectExpertAssignment, ProjectScore +from partner_programs.services.assignment_analytics import ( + build_assignments, + build_delayed_experts, +) ACTIVITY_DAYS = 30 @@ -157,26 +160,16 @@ def _get_solution_metrics(program, assignments_by_project: dict) -> dict[str, in return metrics -def _get_assignment_metrics(program_id: int) -> tuple[dict[str, int], dict]: - score_exists = Exists( - ProjectScore.objects.filter( - project_id=OuterRef("project_id"), - user_id=OuterRef("expert__user_id"), - criteria__partner_program_id=program_id, - ) - ) - assignment_rows = ( - ProjectExpertAssignment.objects.filter(partner_program_id=program_id) - .annotate(has_score=score_exists) - .values_list("project_id", "has_score") - ) +def _get_assignment_metrics(assignments: list[dict]) -> tuple[dict[str, int], dict]: metrics = {"total": 0, "pending": 0, "evaluated": 0} by_project = defaultdict(lambda: {"total": 0, "evaluated": 0}) - for project_id, has_score in assignment_rows: + for assignment in assignments: + project_id = assignment["project"]["id"] + completed = assignment["status"] == "completed" metrics["total"] += 1 - metrics["evaluated" if has_score else "pending"] += 1 + metrics["evaluated" if completed else "pending"] += 1 by_project[project_id]["total"] += 1 - if has_score: + if completed: by_project[project_id]["evaluated"] += 1 return metrics, dict(by_project) @@ -226,7 +219,8 @@ def build_program_manager_analytics(program) -> dict: participants = _get_participant_metrics(program_id) regions = _get_regions(program_id) participant_regions = _get_participant_regions(program_id) - assignments, assignments_by_project = _get_assignment_metrics(program_id) + assignment_items = build_assignments(program_id) + assignments, assignments_by_project = _get_assignment_metrics(assignment_items) solutions = _get_solution_metrics(program, assignments_by_project) projects_awaiting_evaluation = ( @@ -275,6 +269,11 @@ def build_program_manager_analytics(program) -> dict: "attention": { "participants_without_team": participants["without_team"], "projects_awaiting_evaluation": projects_awaiting_evaluation, + "delayed_experts": ( + build_delayed_experts(assignment_items) + if program.is_distributed_evaluation + else {"total": 0, "items": []} + ), }, "activity": _get_activity(program_id), } diff --git a/partner_programs/services/assignment_analytics.py b/partner_programs/services/assignment_analytics.py new file mode 100644 index 00000000..aedd01df --- /dev/null +++ b/partner_programs/services/assignment_analytics.py @@ -0,0 +1,182 @@ +"""Read-only analytics of real expert assignments; no scoring/lifecycle writes.""" + +from django.db.models import BooleanField, Count, OuterRef, Subquery, Value +from django.db.models.functions import Coalesce +from django.utils import timezone + +from partner_programs.models import PartnerProgramProject +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore + + +def assignment_rows(program_id): + """One SELECT with indexed subqueries, not a query per serialized assignment.""" + criteria = ( + Criteria.objects.filter(partner_program_id=program_id) + .order_by() + .values("partner_program_id") + .annotate(total=Count("pk")) + ) + scores = ( + ProjectScore.objects.filter( + criteria__partner_program_id=program_id, + project_id=OuterRef("project_id"), + user_id=OuterRef("expert__user_id"), + ) + .order_by() + .values("project_id", "user_id") + .annotate(total=Count("criteria_id", distinct=True)) + ) + link = PartnerProgramProject.objects.filter( + partner_program_id=program_id, project_id=OuterRef("project_id") + ).order_by("pk") + return ( + ProjectExpertAssignment.objects.filter(partner_program_id=program_id) + .annotate( + criteria_total=Coalesce(Subquery(criteria.values("total")[:1]), 0), + criteria_scored=Coalesce(Subquery(scores.values("total")[:1]), 0), + project_submitted=Coalesce( + Subquery(link.values("submitted")[:1]), + Value(False), + output_field=BooleanField(), + ), + project_submitted_at=Subquery(link.values("datetime_submitted")[:1]), + ) + .order_by("pk") + .values( + "id", + "expert_id", + "expert__user_id", + "expert__user__first_name", + "expert__user__last_name", + "expert__user__avatar", + "project_id", + "project__name", + "datetime_created", + "criteria_total", + "criteria_scored", + "project_submitted", + "project_submitted_at", + ) + ) + + +def build_assignment(row, *, now): + total, scored = row["criteria_total"], row["criteria_scored"] + if not row["project_submitted"]: + status = "not_ready" + elif total > 0 and scored >= total: + status = "completed" + elif total > 0 and scored > 0: + status = "in_progress" + else: + status = "pending" + + submitted_at = row["project_submitted_at"] if row["project_submitted"] else None + waiting_since = None + waiting_seconds = None + # Missing historical submission timestamps cannot establish an SLA start. + if status not in ("not_ready", "completed") and submitted_at is not None: + waiting_since = max(submitted_at, row["datetime_created"]) + waiting_seconds = max(0, int((now - waiting_since).total_seconds())) + + first_name = row["expert__user__first_name"] + last_name = row["expert__user__last_name"] + return { + "assignment_id": row["id"], + "expert": { + "expert_id": row["expert_id"], + "user_id": row["expert__user_id"], + "first_name": first_name, + "last_name": last_name, + "full_name": " ".join(filter(None, (first_name, last_name))), + "avatar": row["expert__user__avatar"] or None, + }, + "project": {"id": row["project_id"], "name": row["project__name"]}, + "status": status, + "criteria_total": total, + "criteria_scored": scored, + "assigned_at": row["datetime_created"], + "project_submitted": row["project_submitted"], + "project_submitted_at": submitted_at, + "waiting_since": waiting_since, + "waiting_seconds": waiting_seconds, + } + + +def build_assignments(program_id): + now = timezone.now() + return [build_assignment(row, now=now) for row in assignment_rows(program_id)] + + +def build_assignment_scores(program_id, assignment): + scores = dict( + ProjectScore.objects.filter( + criteria__partner_program_id=program_id, + project_id=assignment["project"]["id"], + user_id=assignment["expert"]["user_id"], + ).values_list("criteria_id", "value") + ) + criteria = Criteria.objects.filter(partner_program_id=program_id).order_by("pk") + return [ + { + "criterion_id": criterion.pk, + "name": criterion.name, + "description": criterion.description, + "type": criterion.type, + "min_value": criterion.min_value, + "max_value": criterion.max_value, + "value": scores.get(criterion.pk), + "is_scored": criterion.pk in scores, + } + for criterion in criteria + ] + + +def build_delayed_experts(assignments): + experts = {} + for assignment in assignments: + expert = assignment["expert"] + item = experts.setdefault( + expert["expert_id"], + { + **expert, + "assignments_total": 0, + "completed": 0, + "pending": 0, + "overdue_24h": 0, + "overdue_48h": 0, + "oldest_waiting_since": None, + "oldest_waiting_seconds": None, + }, + ) + item["assignments_total"] += 1 + item["completed" if assignment["status"] == "completed" else "pending"] += 1 + seconds = assignment["waiting_seconds"] + if seconds is None: + continue + item["overdue_24h"] += int(seconds >= 24 * 3600) + item["overdue_48h"] += int(seconds >= 48 * 3600) + if seconds > 0 and ( + item["oldest_waiting_seconds"] is None + or seconds > item["oldest_waiting_seconds"] + ): + item["oldest_waiting_since"] = assignment["waiting_since"] + item["oldest_waiting_seconds"] = seconds + + items = [] + for item in experts.values(): + if item["overdue_48h"] >= 1: + item["severity"] = "critical" + elif item["overdue_24h"] >= 2: + item["severity"] = "warning" + else: + continue + items.append(item) + items.sort( + key=lambda item: ( + item["severity"] != "critical", + -item["oldest_waiting_seconds"], + item["expert_id"], + ) + ) + return {"total": len(items), "items": items} diff --git a/partner_programs/tests/test_assignment_analytics_api.py b/partner_programs/tests/test_assignment_analytics_api.py new file mode 100644 index 00000000..b8e2346b --- /dev/null +++ b/partner_programs/tests/test_assignment_analytics_api.py @@ -0,0 +1,541 @@ +from datetime import datetime, timedelta, timezone as datetime_timezone +from unittest.mock import patch + +from django.db import IntegrityError, connection, transaction +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramProject +from partner_programs.services.assignment_analytics import build_assignments +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_program_project, + create_user, +) +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore +from project_rates.tests.helpers import create_rate_expert + +NOW = datetime(2026, 9, 5, 12, tzinfo=datetime_timezone.utc) + + +class AssignmentAnalyticsTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.manager = create_user(prefix="assignment-manager") + cls.program = create_partner_program( + is_distributed_evaluation=True, max_project_rates=3 + ) + cls.program.managers.add(cls.manager) + cls.expert = create_rate_expert(program=cls.program) + # Program creation adds a Comment criterion; isolate exactly three here. + Criteria.objects.filter(partner_program=cls.program).delete() + cls.criteria = [ + Criteria.objects.create( + partner_program=cls.program, + name=f"Criterion {index}", + description="Description", + type="int", + min_value=0, + max_value=10, + ) + for index in range(3) + ] + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.manager) + self.url = reverse( + "partner_programs:manager-overview-assignments", + kwargs={"pk": self.program.pk}, + ) + self.overview_url = reverse( + "partner_programs:manager-overview", kwargs={"pk": self.program.pk} + ) + + def assignment(self, *, submitted=True, hours=25, expert=None, program=None): + program = program or self.program + expert = expert or self.expert + link = create_program_project(program, submitted=submitted) + assigned = ProjectExpertAssignment.objects.create( + partner_program=program, project=link.project, expert=expert.expert + ) + timestamp = NOW - timedelta(hours=hours) + ProjectExpertAssignment.objects.filter(pk=assigned.pk).update( + datetime_created=timestamp + ) + PartnerProgramProject.objects.filter(pk=link.pk).update( + datetime_submitted=timestamp if submitted else None + ) + return assigned + + def score(self, assignment, count=3): + for criterion in self.criteria[:count]: + ProjectScore.objects.get_or_create( + criteria=criterion, + project_id=assignment.project_id, + user_id=assignment.expert.user_id, + defaults={"value": "0"}, + ) + + def scores_url(self, assignment, *, program=None): + return reverse( + "partner_programs:manager-overview-assignment-scores", + kwargs={ + "pk": (program or self.program).pk, + "assignment_id": assignment.pk, + }, + ) + + def get(self, url=None, **params): + with patch("django.utils.timezone.now", return_value=NOW): + response = self.client.get(url or self.url, params) + self.assertEqual(response.status_code, 200, response.data) + return response.data + + def test_zero_one_two_three_criteria_scores_map_to_statuses(self): + assignment = self.assignment() + for count, expected in enumerate( + ("pending", "in_progress", "in_progress", "completed") + ): + with self.subTest(count=count): + self.score(assignment, count) + item = self.get()[0] + self.assertEqual(item["status"], expected) + self.assertEqual(item["criteria_total"], 3) + self.assertEqual(item["criteria_scored"], count) + + def test_zero_criteria_is_pending_not_completed(self): + Criteria.objects.filter(partner_program=self.program).delete() + self.assignment() + item = self.get()[0] + self.assertEqual(item["status"], "pending") + self.assertEqual(item["criteria_total"], 0) + + def test_unsubmitted_even_with_scores_is_not_ready(self): + assignment = self.assignment(submitted=False) + self.score(assignment) + item = self.get()[0] + self.assertEqual(item["status"], "not_ready") + self.assertFalse(item["project_submitted"]) + for field in ("project_submitted_at", "waiting_since", "waiting_seconds"): + self.assertIsNone(item[field]) + + def test_duplicate_criterion_score_is_rejected_and_cannot_inflate_progress(self): + assignment = self.assignment() + self.score(assignment, 1) + with self.assertRaises(IntegrityError), transaction.atomic(): + ProjectScore.objects.create( + criteria=self.criteria[0], + project=assignment.project, + user=self.expert, + value="8", + ) + self.assertEqual(self.get()[0]["criteria_scored"], 1) + + def test_unrelated_program_user_and_project_scores_are_ignored(self): + assignment = self.assignment() + other = self.assignment() + self.score(other) + other_expert = create_rate_expert(program=self.program) + other_criterion = Criteria.objects.create( + partner_program=create_partner_program(), name="Other", type="int" + ) + ProjectScore.objects.create( + criteria=other_criterion, + user=self.expert, + project=assignment.project, + value="8", + ) + ProjectScore.objects.create( + criteria=self.criteria[0], + user=other_expert, + project=assignment.project, + value="9", + ) + self.score(assignment, 1) + item = self.get()[0] + self.assertEqual(item["criteria_scored"], 1) + self.assertEqual(item["status"], "in_progress") + scores = self.get(self.scores_url(assignment))["scores"] + self.assertEqual([item["value"] for item in scores], ["0", None, None]) + + def test_summary_preserves_names_and_counts_only_complete_assignments(self): + self.assignment(submitted=False) + self.assignment() + self.score(self.assignment(), 1) + self.score(self.assignment()) + counts = self.get(self.overview_url)["evaluation_status"]["assignments"] + self.assertEqual(counts, {"total": 4, "pending": 3, "evaluated": 1}) + self.assertEqual(counts["total"], counts["pending"] + counts["evaluated"]) + + def test_project_transitions_require_completed_experts_not_partial_scores(self): + first = self.assignment() + assignments = [first] + for _ in range(2): + expert = create_rate_expert(program=self.program) + assignments.append( + ProjectExpertAssignment.objects.create( + partner_program=self.program, + project=first.project, + expert=expert.expert, + ) + ) + for assignment in assignments: + self.score(assignment, 1) + for completed, expected in enumerate( + ( + "awaiting_evaluation", + "partially_evaluated", + "partially_evaluated", + "evaluated", + ) + ): + with self.subTest(completed=completed): + for assignment in assignments[:completed]: + self.score(assignment) + data = self.get(self.overview_url) + statuses = data["evaluation_status"]["projects"] + self.assertEqual(statuses[expected], 1) + self.assertEqual(sum(statuses.values()), 2) # submitted + one status + self.assertEqual( + data["solution_funnel"]["evaluated"], int(completed == 3) + ) + + def test_submitted_project_without_assignments_is_awaiting(self): + create_program_project(self.program, submitted=True) + data = self.get(self.overview_url) + self.assertEqual(data["evaluation_status"]["projects"]["awaiting_evaluation"], 1) + + def test_open_mode_any_score_still_evaluates_project_without_fake_assignments(self): + self.program.is_distributed_evaluation = False + self.program.save(update_fields=["is_distributed_evaluation"]) + link = create_program_project(self.program, submitted=True) + ProjectScore.objects.create( + criteria=self.criteria[0], project=link.project, user=self.expert, value="1" + ) + data = self.get(self.overview_url) + self.assertEqual(data["evaluation_status"]["projects"]["evaluated"], 1) + self.assertEqual(data["evaluation_status"]["assignments"]["total"], 0) + self.assertEqual(self.get(), []) + + def test_waiting_starts_at_later_of_assignment_and_submission(self): + assignment = self.assignment() + for assigned_hours, submitted_hours in ((4, 2), (1, 4)): + with self.subTest(assigned=assigned_hours, submitted=submitted_hours): + assigned_at = NOW - timedelta(hours=assigned_hours) + submitted_at = NOW - timedelta(hours=submitted_hours) + ProjectExpertAssignment.objects.filter(pk=assignment.pk).update( + datetime_created=assigned_at + ) + PartnerProgramProject.objects.filter(project=assignment.project).update( + datetime_submitted=submitted_at + ) + with patch("django.utils.timezone.now", return_value=NOW): + item = build_assignments(self.program.pk)[0] + self.assertEqual(item["waiting_since"], max(assigned_at, submitted_at)) + self.assertEqual( + item["waiting_seconds"], min(assigned_hours, submitted_hours) * 3600 + ) + + def test_completed_assignment_has_no_waiting(self): + self.score(self.assignment(hours=100)) + item = self.get()[0] + self.assertIsNone(item["waiting_since"]) + self.assertIsNone(item["waiting_seconds"]) + + def test_missing_legacy_submission_timestamp_does_not_invent_waiting(self): + assignment = self.assignment(hours=100) + PartnerProgramProject.objects.filter(project=assignment.project).update( + datetime_submitted=None + ) + item = self.get()[0] + self.assertEqual(item["status"], "pending") + self.assertTrue(item["project_submitted"]) + self.assertIsNone(item["waiting_seconds"]) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"]["total"], 0 + ) + + def test_future_waiting_is_clamped_to_zero_and_not_delayed(self): + self.assignment(hours=-5) + self.assertEqual(self.get()[0]["waiting_seconds"], 0) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"]["total"], 0 + ) + + def test_sla_thresholds_and_boundaries(self): + cases = ( + (1, 23, None), + (2, 23, None), + (2, 25, "warning"), + (1, 49, "critical"), + (1, 24, None), + (2, 24, "warning"), + (1, 48, "critical"), + ) + for count, hours, severity in cases: + with self.subTest(count=count, hours=hours): + ProjectExpertAssignment.objects.all().delete() + for _ in range(count): + self.assignment(hours=hours) + delayed = self.get(self.overview_url)["attention"]["delayed_experts"] + self.assertEqual(delayed["total"], int(severity is not None)) + if severity is not None: + item = delayed["items"][0] + self.assertEqual(item["severity"], severity) + self.assertEqual(item["assignments_total"], count) + self.assertEqual(item["pending"], count) + self.assertEqual(item["completed"], 0) + self.assertEqual(item["overdue_24h"], count) + self.assertEqual(item["overdue_48h"], count if hours >= 48 else 0) + self.assertEqual(item["oldest_waiting_seconds"], hours * 3600) + + def test_completed_and_not_ready_do_not_trigger_sla(self): + self.score(self.assignment(hours=100)) + self.assignment(hours=100, submitted=False) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"], + {"total": 0, "items": []}, + ) + + def test_delayed_expert_totals_include_completed_and_not_ready(self): + self.score(self.assignment(hours=100)) + self.assignment(hours=100, submitted=False) + self.score(self.assignment(hours=49), 1) + item = self.get(self.overview_url)["attention"]["delayed_experts"]["items"][0] + self.assertEqual(item["assignments_total"], 3) + self.assertEqual(item["completed"], 1) + self.assertEqual(item["pending"], 2) + self.assertEqual(item["overdue_48h"], 1) + self.assertEqual(item["oldest_waiting_seconds"], 49 * 3600) + + def test_other_program_assignments_do_not_leak_into_sla_or_list(self): + other = create_partner_program(is_distributed_evaluation=True) + self.expert.expert.programs.add(other) + self.assignment(hours=100, program=other) + self.assertEqual(self.get(), []) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"]["total"], 0 + ) + + def test_open_mode_never_returns_delayed_experts(self): + self.assignment(hours=100) + self.program.is_distributed_evaluation = False + self.program.save(update_fields=["is_distributed_evaluation"]) + self.assertEqual( + self.get(self.overview_url)["attention"]["delayed_experts"], + {"total": 0, "items": []}, + ) + self.assertEqual(len(self.get()), 1) # Real assignments are still available. + + def test_delayed_experts_sort_by_severity_wait_and_id(self): + warning = create_rate_expert(program=self.program) + for _ in range(2): + self.assignment(expert=warning, hours=25) + self.assignment(hours=49) + oldest = create_rate_expert(program=self.program) + self.assignment(expert=oldest, hours=60) + items = self.get(self.overview_url)["attention"]["delayed_experts"]["items"] + self.assertEqual( + [item["user_id"] for item in items], [oldest.pk, self.expert.pk, warning.pk] + ) + + def test_manager_staff_superuser_access_to_both_endpoints(self): + assignment = self.assignment() + for user in ( + self.manager, + create_user(is_staff=True), + create_user(is_superuser=True), + ): + with self.subTest(user=user.pk): + self.client.force_authenticate(user) + self.get() + self.get(self.scores_url(assignment)) + + def test_participant_expert_and_other_manager_are_forbidden(self): + assignment = self.assignment() + participant = create_user() + create_program_member(self.program, user=participant) + other_manager = create_user() + create_partner_program().managers.add(other_manager) + for user in (participant, self.expert, other_manager): + with self.subTest(user=user.pk): + self.client.force_authenticate(user) + for url in (self.url, self.scores_url(assignment)): + self.assertEqual(self.client.get(url).status_code, 403) + + def test_anonymous_is_unauthorized(self): + assignment = self.assignment() + self.client.force_authenticate(None) + for url in (self.url, self.scores_url(assignment)): + self.assertEqual(self.client.get(url).status_code, 401) + + def test_endpoints_are_read_only(self): + assignment = self.assignment() + for url in (self.url, self.scores_url(assignment)): + for method in ( + self.client.post, + self.client.patch, + self.client.put, + self.client.delete, + ): + self.assertEqual(method(url, {}, format="json").status_code, 405) + + def test_missing_program_and_assignment_are_not_found(self): + for name, kwargs in ( + ("manager-overview-assignments", {"pk": 999999}), + ( + "manager-overview-assignment-scores", + {"pk": self.program.pk, "assignment_id": 999999}, + ), + ): + self.assertEqual( + self.client.get( + reverse("partner_programs:" + name, kwargs=kwargs) + ).status_code, + 404, + ) + + def test_other_program_assignment_is_not_found_even_for_both_programs_manager(self): + other = create_partner_program() + other.managers.add(self.manager) + self.expert.expert.programs.add(other) + assignment = self.assignment(program=other) + self.assertEqual(self.client.get(self.scores_url(assignment)).status_code, 404) + + def test_scopes_and_default_have_stable_assignment_id_order(self): + self.assignment(submitted=False) + self.assignment() + self.score(self.assignment(), 1) + complete = self.assignment() + self.score(complete) + all_items = self.get() + ids = [item["assignment_id"] for item in all_items] + self.assertEqual(ids, sorted(ids)) + self.assertEqual(self.get(scope="all"), all_items) + self.assertEqual( + [item["status"] for item in self.get(scope="pending")], + ["not_ready", "pending", "in_progress"], + ) + self.assertEqual( + [item["assignment_id"] for item in self.get(scope="completed")], [complete.pk] + ) + + def test_invalid_scopes_return_400(self): + for scope in ("", "unknown", "COMPLETED", "evaluated"): + self.assertEqual(self.client.get(self.url, {"scope": scope}).status_code, 400) + + def test_assignment_has_explicit_safe_contract(self): + assignment = self.assignment(hours=49) + item = self.get()[0] + self.assertEqual( + set(item), + { + "assignment_id", + "expert", + "project", + "status", + "criteria_total", + "criteria_scored", + "assigned_at", + "project_submitted", + "project_submitted_at", + "waiting_since", + "waiting_seconds", + }, + ) + self.assertEqual( + set(item["expert"]), + {"expert_id", "user_id", "first_name", "last_name", "full_name", "avatar"}, + ) + self.assertEqual( + item["project"], + {"id": assignment.project_id, "name": assignment.project.name}, + ) + self.assertEqual(item["expert"]["full_name"], "Rate User") + self.assertIsNone(item["expert"]["avatar"]) + delayed = self.get(self.overview_url)["attention"]["delayed_experts"]["items"][0] + for payload in (item, delayed, self.get(self.scores_url(assignment))): + self.assertNotIn("email", str(payload)) + self.assertNotIn("password", str(payload)) + self.assertNotIn("is_staff", str(payload)) + + def test_avatar_is_url_and_blank_names_do_not_break_overview(self): + self.expert.first_name = "" + self.expert.last_name = "" + self.expert.avatar = "https://example.test/avatar.png" + self.expert.save(update_fields=["first_name", "last_name", "avatar"]) + self.assignment(hours=49) + expert = self.get()[0]["expert"] + self.assertEqual(expert["full_name"], "") + self.assertEqual(expert["avatar"], self.expert.avatar) + self.get(self.overview_url) + + def test_score_detail_returns_all_criteria_with_progress_and_missing_values(self): + assignment = self.assignment() + for count in (0, 1, 3): + with self.subTest(count=count): + self.score(assignment, count) + detail = self.get(self.scores_url(assignment)) + self.assertEqual(detail["criteria_scored"], count) + self.assertEqual(detail["criteria_total"], 3) + scores = detail["scores"] + self.assertEqual( + [item["criterion_id"] for item in scores], + [item.pk for item in self.criteria], + ) + for index, item in enumerate(scores): + self.assertEqual( + set(item), + { + "criterion_id", + "name", + "description", + "type", + "min_value", + "max_value", + "value", + "is_scored", + }, + ) + self.assertEqual(item["is_scored"], index < count) + self.assertEqual(item["value"], "0" if index < count else None) + self.assertEqual(item["description"], "Description") + self.assertEqual(item["min_value"], 0) + self.assertEqual(item["max_value"], 10) + + def test_score_text_value_is_not_coerced_or_trimmed(self): + criterion = self.criteria[0] + criterion.type = "str" + criterion.save(update_fields=["type"]) + assignment = self.assignment() + ProjectScore.objects.create( + criteria=criterion, + project=assignment.project, + user=self.expert, + value=" text ", + ) + self.assertEqual( + self.get(self.scores_url(assignment))["scores"][0]["value"], " text " + ) + + def test_query_counts_are_constant_for_one_and_many_assignments(self): + first = self.assignment(hours=49) + self.score(first, 1) + urls = (self.url, self.overview_url, self.scores_url(first)) + counts = [] + for url in urls: + with CaptureQueriesContext(connection) as queries: + self.get(url) + counts.append(len(queries)) + self.assertEqual(counts, [3, 10, 5]) + for _ in range(30): + expert = create_rate_expert(program=self.program) + self.score(self.assignment(expert=expert, hours=49), 1) + for url, expected in zip(urls, counts): + with CaptureQueriesContext(connection) as queries: + self.get(url) + self.assertEqual(len(queries), expected) diff --git a/partner_programs/tests/test_manager_analytics_api.py b/partner_programs/tests/test_manager_analytics_api.py index f7030852..42ca88da 100644 --- a/partner_programs/tests/test_manager_analytics_api.py +++ b/partner_programs/tests/test_manager_analytics_api.py @@ -265,7 +265,9 @@ def test_open_evaluation_uses_any_score_instead_of_max_project_rates(self): { "mode": "open", "max_evaluations_per_project": 2, - "assignments": {"total": 5, "pending": 2, "evaluated": 3}, + # Open project evaluation still needs any score, but the real + # assignments also require the automatically created Comment. + "assignments": {"total": 5, "pending": 5, "evaluated": 0}, "projects": { "submitted": 3, "awaiting_evaluation": 1, @@ -339,7 +341,7 @@ def _create_distributed_evaluation(self, *, assignments: int, scores: int): self.program.save(update_fields=["is_distributed_evaluation"]) project = create_project(name="Distributed evaluation") create_program_project(self.program, project=project, submitted=True) - criteria = Criteria.objects.create( + Criteria.objects.create( name="Distributed impact", type="int", partner_program=self.program, @@ -358,12 +360,13 @@ def _create_distributed_evaluation(self, *, assignments: int, scores: int): expert=expert.expert, ) for expert in experts[:scores]: - ProjectScore.objects.create( - criteria=criteria, - user=expert, - project=project, - value="8", - ) + for criteria in self.program.criterias.all(): + ProjectScore.objects.create( + criteria=criteria, + user=expert, + project=project, + value="8", + ) def test_distributed_project_without_assignments_is_awaiting_evaluation(self): self._create_distributed_evaluation(assignments=0, scores=0) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index a9e14b0b..d8cd5c8a 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -9,6 +9,8 @@ PartnerProgramExportRatesAPIView, PartnerProgramList, ProgramManagerAnalyticsAPIView, + ProgramManagerAssignmentsAPIView, + ProgramManagerAssignmentScoresAPIView, PartnerProgramProjectApplyView, PartnerProgramProjectsAPIView, PartnerProgramProjectSubmitView, @@ -30,6 +32,16 @@ ProgramManagerAnalyticsAPIView.as_view(), name="manager-overview", ), + path( + "/manager-overview/assignments/", + ProgramManagerAssignmentsAPIView.as_view(), + name="manager-overview-assignments", + ), + path( + "/manager-overview/assignments//scores/", + ProgramManagerAssignmentScoresAPIView.as_view(), + name="manager-overview-assignment-scores", + ), path( "/acknowledge-welcome/", PartnerProgramWelcomeAcknowledgement.as_view(), diff --git a/partner_programs/views.py b/partner_programs/views.py index 001a94a0..e0f2bf2b 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -56,6 +56,17 @@ require_can_apply_project_to_program, ) from partner_programs.serializers import PartnerProgramFieldValueUpdateSerializer +from partner_programs.serializers.analytics import ( + ProgramAssignmentScopeSerializer, + ProgramAssignmentScoresSerializer, + ProgramAssignmentSerializer, +) +from partner_programs.services.assignment_analytics import ( + assignment_rows, + build_assignment, + build_assignment_scores, + build_assignments, +) from projects.models import Project from projects.serializers import ProjectListSerializer @@ -428,15 +439,21 @@ def get(self, request, pk): return Response(serializer.data) -class ProgramManagerAnalyticsAPIView(APIView): - """Aggregated program analytics for managers and administrators.""" - +class ProgramManagerAnalyticsAccessAPIView(APIView): permission_classes = [IsAuthenticated] - def get(self, request, pk: int): + def get_program(self, request, pk): program = get_object_or_404(PartnerProgram, pk=pk) if not can_manage_program(request.user, program): raise PermissionDenied("Недостаточно прав.") + return program + + +class ProgramManagerAnalyticsAPIView(ProgramManagerAnalyticsAccessAPIView): + """Aggregated program analytics for managers and administrators.""" + + def get(self, request, pk: int): + program = self.get_program(request, pk) serializer = ProgramManagerAnalyticsSerializer( data=build_program_manager_analytics(program) @@ -445,6 +462,31 @@ def get(self, request, pk: int): return Response(serializer.data) +class ProgramManagerAssignmentsAPIView(ProgramManagerAnalyticsAccessAPIView): + def get(self, request, pk): + program = self.get_program(request, pk) + query = ProgramAssignmentScopeSerializer(data=request.query_params.dict()) + query.is_valid(raise_exception=True) + scope = query.validated_data["scope"] + assignments = build_assignments(program.pk) + if scope != "all": + assignments = [ + item + for item in assignments + if (item["status"] == "completed") == (scope == "completed") + ] + return Response(ProgramAssignmentSerializer(assignments, many=True).data) + + +class ProgramManagerAssignmentScoresAPIView(ProgramManagerAnalyticsAccessAPIView): + def get(self, request, pk, assignment_id): + program = self.get_program(request, pk) + row = get_object_or_404(assignment_rows(program.pk), pk=assignment_id) + assignment = build_assignment(row, now=timezone.now()) + assignment["scores"] = build_assignment_scores(program.pk, assignment) + return Response(ProgramAssignmentScoresSerializer(assignment).data) + + class ProgramProjectFilterAPIView(GenericAPIView): serializer_class = ProgramProjectFilterRequestSerializer permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram]