From 5b030625499efe94cfd67db9694c713eecf2539f Mon Sep 17 00:00:00 2001 From: Fedor Date: Sat, 5 Sep 2026 23:29:28 +0300 Subject: [PATCH] Add program attention analytics drilldown API --- docs/program-manager-analytics-api.md | 180 ++++- partner_programs/pagination.py | 18 + partner_programs/serializers/attention.py | 99 +++ partner_programs/services/analytics.py | 206 +++-- .../services/assignment_analytics.py | 33 +- .../tests/test_attention_analytics_api.py | 728 ++++++++++++++++++ partner_programs/urls.py | 12 + partner_programs/views.py | 68 +- 8 files changed, 1273 insertions(+), 71 deletions(-) create mode 100644 partner_programs/serializers/attention.py create mode 100644 partner_programs/tests/test_attention_analytics_api.py diff --git a/docs/program-manager-analytics-api.md b/docs/program-manager-analytics-api.md index 471a7c40..821ce6f2 100644 --- a/docs/program-manager-analytics-api.md +++ b/docs/program-manager-analytics-api.md @@ -7,7 +7,7 @@ Endpoint доступен менеджерам указанной программы, staff и superuser. Для авторизованного пользователя без этих прав возвращается `403`, для неизвестной программы — `404`, для anonymous — `401`. Тот же `can_manage_program` -используется обоими read-only drilldown endpoints ниже. Эксперт программы без +используется всеми read-only drilldown endpoints ниже. Эксперт программы без прав менеджера доступа не получает. POST/PATCH/PUT/DELETE не поддерживаются. ## Контракт @@ -323,10 +323,186 @@ SLA учитывает только сданные, незавершённые SQL SELECT: связанные таблицы через JOIN, число критериев и DISTINCT-оценок — через Subquery/Count; связь проекта с программой также через Subquery. Нет запросов из сериализаторов и отдельных SQL-запросов в цикле назначений. -Сводка повторно использует тот же список для счётчиков проектов и SLA. +Сводка использует тот же список для SLA и счётчиков назначений. Статусы работ +вычисляет общий SQL-queryset сводки и списка ожидающих работ; проверка завершения +назначения общая с assignments. Внешние поля сводки и assignments не меняются. Score drilldown добавляет два фиксированных запроса (критерии и оценки пары). Regression query budget для manager с уже аутентифицированным request.user: список — 3 SQL, overview — 10 SQL, scores — 5 SQL; не растёт при переходе от 1 к 31 назначению. JWT/session-аутентификация может добавить свои запросы. Проверяются SQLite и PostgreSQL; новых моделей, индексов и миграций нет. + +## Детализация участников без команды и ожидающих работ (v1) + +### Результат аудита и источники + +Оба списка раскрывают **существующие** счётчики `attention`, без новых правил +регистрации, состава команды, сдачи или записи оценок. Предлагаемые URL следуют +существующему шаблону `/manager-overview//`. + +| Список | Единица строки и источник | Общая логика со сводкой | +| --- | --- | --- | +| Участники без команды | Уникальный ненулевой `PartnerProgramUserProfile.user_id` текущей программы | `_participant_profiles` + `_without_team_filter`; связанные проекты через `PartnerProgramProject`, руководитель либо `Collaborator` | +| Работы ожидают оценивания | Одна сданная связь `PartnerProgramProject` | `_solution_rows`; open — наличие оценки по критерию программы, distributed — общая `is_completed`-аннотация реальных назначений | + +В актуальной схеме есть `unique_together(user, partner_program)`: повторная +регистрация той же пары отклоняется БД. Ограничение не снимается. Список всё равно +группируется по пользователю и берёт `MIN(datetime_created)` текущей программы: +это не дата аккаунта и не регистрация в другой программе. Тест дубля проверяет +существующую гарантию БД; искусственное удаление ограничения или миграция не нужны. + +### Общие параметры и доступ + +Доступ только manager соответствующей программы / staff / superuser, через +тот же `can_manage_program`. Anonymous — `401`, авторизованный без доступа — +`403`, отсутствующая программа — `404`. POST/PUT/PATCH/DELETE — `405` для +аутентифицированного пользователя. Endpoint не предоставляет действий записи. + +Параметры обоих списков: + +- `limit`: целое от 1 до 100, по умолчанию 25; превышение 100 — `400`, не + молчаливое ограничение; +- `offset`: неотрицательное целое, по умолчанию 0; +- `search`: строка с удалением пробелов по краям; пустая строка равна отсутствию + поиска. Участники — поиск по имени и фамилии, работы — по названию проекта. + Поиск выполняется в SQL до count и пагинации и не расширяет доступ. + +Некорректные параметры дают `400` с ошибкой соответствующего поля. Ответ +сохраняет обёртку DRF `count`, `next`, `previous`, `results`; ссылки продолжают +тот же поиск. `offset` за концом списка возвращает `200` с пустым `results` +и актуальным `count`. Максимум 100 строк за запрос, сервер не возвращает все +страницы ради клиентской фильтрации. + +При отсутствии поиска и неизменившихся данных `count` точно соответствует +своему счётчику `attention`. При поиске это число найденных строк. Между двумя +HTTP-запросами данные могут измениться: клиент должен показывать актуальный +count списка, а не обрезать его до числа из старой сводки. + +### Участники без команды + +`GET /programs//manager-overview/participants-without-team/` + +Включаются существующие зарегистрированные пользователи, которые не являются +ни руководителем, ни Collaborator любого проекта **этой программы**. Команда +в другой программе не исключает участника; произвольный проект в анкете +`PartnerProgramUserProfile.project` не является доказательством команды. +Руководитель исключается даже без записи Collaborator. Регистрация с `user=null` +исключается. Фильтрация публичности или draft проекта не добавляется. + +Сортировка: первая регистрация по возрастанию, затем `user_id`. Разрешённые +поля — только ID, отображаемое имя, avatar, фактический city, дата регистрации. +Пустые имя/фамилия дают `Участник №ID`, пустые avatar/city — `null`. +Legacy city не нормализуется. Это **не** утверждение «Ищет команду». + +Пример страницы: + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "user_id": 123, + "full_name": "Анна Петрова", + "avatar": null, + "city": "Набережные Челны", + "registered_at": "2026-09-01T10:00:00+03:00" + } + ] +} +``` + +Email, телефон, auth-поля, ответы анкеты и полный User serializer не включаются. +Вуз, направление, предпочтительная роль и статус поиска команды отсутствуют. + +### Работы ожидают оценивания + +`GET /programs//manager-overview/projects-awaiting-evaluation/` + +Только `PartnerProgramProject.submitted=true` именно в запрошенной программе. +Одна работа остаётся одной строкой независимо от числа назначений. Несданные +связи и назначения `not_ready` сюда не входят. `mode` в обёртке — фактический +режим программы (`open` или `distributed`), а не параметр переключения правил. + +| mode / условие | status | reason | reason_label | +| --- | --- | --- | --- | +| distributed, назначений нет | `awaiting_evaluation` | `no_assignments` | Эксперты не назначены | +| distributed, назначения есть, завершённых нет | `awaiting_evaluation` | `no_completed_evaluations` | Нет завершённых оценок | +| distributed, завершена часть назначений | `partially_evaluated` | `partially_evaluated` | Частично оценено | +| open, нет оценки по критерию программы | `awaiting_evaluation` | `awaiting_first_evaluation` | Ожидает первой оценки | + +В distributed завершение требует сдачи и оценок по всем критериям программы +именно от назначенного эксперта. Нулевое число критериев не завершает назначение. +Частично заполненные критерии при отсутствии завершённых экспертов не дают +статус «Частично оценено». Полностью оценённая работа исключается. + +В open первая оценка по любому критерию текущей программы исключает работу, +без требования назначения и без выдуманных назначений. Оценки другой программы +не учитываются. Существующая семантика наличия строки оценки не меняется. + +`assignments_total` и `assignments_completed` в distributed — реальные числа, +а не `max_project_rates`. В open оба поля равны `null`. Новые часы ожидания, +SLA, кейсы, треки, проценты готовности или предполагаемые сроки не вычисляются. + +Сортировка: `datetime_submitted` по возрастанию, неизвестные даты в конце, +затем ID связи. `submitted_at=null` остаётся неизвестной датой — дата создания +проекта не подставляется. Руководитель — безопасные ID/имя/аватар либо `null`. + +Пример distributed: + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "program_project_id": 70, + "project": {"id": 55, "name": "Проект А"}, + "leader": {"user_id": 123, "full_name": "Анна Петрова", "avatar": null}, + "submitted_at": "2026-09-03T12:00:00+03:00", + "status": "partially_evaluated", + "reason": "partially_evaluated", + "reason_label": "Частично оценено", + "assignments_total": 3, + "assignments_completed": 1 + } + ], + "mode": "distributed" +} +``` + +В open та же строка ожидающей работы содержит: + +```json +{ + "status": "awaiting_evaluation", + "reason": "awaiting_first_evaluation", + "reason_label": "Ожидает первой оценки", + "assignments_total": null, + "assignments_completed": null +} +``` + +### Производительность и границы v1 + +Общий query-builder работ использует коррелированные подзапросы и агрегаты, +а не загрузку всех назначений ради пагинации. Участники группируются в SQL; +пользовательские поля и руководитель выбираются без отдельного запроса на строку. +SerializerMethodField форматирует только уже загруженные значения. + +Regression-бюджет для manager с уже аутентифицированным `request.user`: не более +4 SQL на непустую страницу (программа, проверка manager, count, строки страницы). +Он проверяется при росте каждого списка с 1 до 31 строки, для работ — также +с ростом назначений. Счётчик сводки сохраняет прежний бюджет не более 10 SQL. +Запросы JWT/session-аутентификации могут добавляться отдельно. Проверка +кириллического регистронезависимого поиска выполняется на PostgreSQL: SQLite +по умолчанию не поддерживает эквивалентный Unicode case-fold. + +Новые модели, миграции, зависимости и изменения scoring/submission lifecycle +не нужны. Overview, assignments, scores и задержки экспертов сохраняют контракты. +Angular, shared app-modal, production, workflows и Docker в этом этапе не меняются. +Будущему UI разрешены только существующие переходы к профилю/проекту; сообщения, +напоминания, подбор команды, назначения экспертов и новые выгрузки в v1 отсутствуют. diff --git a/partner_programs/pagination.py b/partner_programs/pagination.py index 77252350..a23e3c2d 100644 --- a/partner_programs/pagination.py +++ b/partner_programs/pagination.py @@ -13,3 +13,21 @@ class PartnerProgramPagination(pagination.LimitOffsetPagination): default_limit = 10 limit_query_param = "limit" offset_query_param = "offset" + + +class ProgramAttentionPagination(pagination.LimitOffsetPagination): + """Стандартная обёртка DRF с уже проверенными параметрами списка внимания.""" + + default_limit = 25 + max_limit = 100 + + def __init__(self, query): + self.query = query + + def get_limit(self, request): + """Не подменяет ошибочный limit значением по умолчанию после валидации.""" + return self.query["limit"] + + def get_offset(self, request): + """Использует валидированный неотрицательный offset.""" + return self.query["offset"] diff --git a/partner_programs/serializers/attention.py b/partner_programs/serializers/attention.py new file mode 100644 index 00000000..5b2a58c1 --- /dev/null +++ b/partner_programs/serializers/attention.py @@ -0,0 +1,99 @@ +"""Безопасные read-only контракты детализации показателей внимания программы.""" + +from rest_framework import serializers + +from partner_programs.serializers.analytics import AssignmentProjectSerializer + + +def participant_name(user_id, first_name, last_name): + """Отображаемое имя без приватных полей и нейтральная замена пустого имени.""" + name = " ".join( + part.strip() for part in (first_name, last_name) if part and part.strip() + ) + return name or f"Участник №{user_id}" + + +class ProgramAttentionQuerySerializer(serializers.Serializer): + """Проверяет limit/offset до SQL; search применяется до выбора страницы.""" + + limit = serializers.IntegerField(min_value=1, max_value=100, default=25) + offset = serializers.IntegerField(min_value=0, default=0) + search = serializers.CharField(allow_blank=True, default="", trim_whitespace=True) + + +class ProgramAttentionParticipantSerializer(serializers.Serializer): + """Один уникальный пользователь программы, без анкеты, email и телефона.""" + + user_id = serializers.IntegerField() + full_name = serializers.SerializerMethodField() + avatar = serializers.SerializerMethodField() + city = serializers.SerializerMethodField() + registered_at = serializers.DateTimeField(allow_null=True) + + def get_full_name(self, row): + """Имя строится только из уже выбранных публичных полей пользователя.""" + return participant_name( + row["user_id"], row["user__first_name"], row["user__last_name"] + ) + + def get_avatar(self, row): + """Пустой avatar унифицирован в null, без запроса пользователя.""" + return row["user__avatar"] or None + + def get_city(self, row): + """Сохраняет фактическую географию, не нормализуя legacy-значения.""" + return row["user__city"] or None + + +class ProgramAttentionLeaderSerializer(serializers.Serializer): + """Разрешённые поля руководителя; полный сериализатор User не используется.""" + + user_id = serializers.IntegerField(source="id") + full_name = serializers.SerializerMethodField() + avatar = serializers.SerializerMethodField() + + def get_full_name(self, user): + """Использует руководителя, загруженного вместе со связью проекта.""" + return participant_name(user.pk, user.first_name, user.last_name) + + def get_avatar(self, user): + """Возвращает URL или null из уже загруженной модели.""" + return user.avatar or None + + +WAITING_REASONS = { + "no_assignments": "Эксперты не назначены", + "no_completed_evaluations": "Нет завершённых оценок", + "partially_evaluated": "Частично оценено", + "awaiting_first_evaluation": "Ожидает первой оценки", +} + + +class ProgramAttentionProjectSerializer(serializers.Serializer): + """Одна сданная работа программы, а не строка назначения эксперта.""" + + program_project_id = serializers.IntegerField(source="pk") + project = AssignmentProjectSerializer() + leader = ProgramAttentionLeaderSerializer(source="project.leader", allow_null=True) + submitted_at = serializers.DateTimeField(source="datetime_submitted", allow_null=True) + status = serializers.ChoiceField( + choices=("awaiting_evaluation", "partially_evaluated") + ) + reason = serializers.SerializerMethodField() + reason_label = serializers.SerializerMethodField() + assignments_total = serializers.IntegerField(allow_null=True) + assignments_completed = serializers.IntegerField(allow_null=True) + + def get_reason(self, link): + """Причина следует из общего статуса и реальных назначений, не из лимита.""" + if link.assignments_total is None: + return "awaiting_first_evaluation" + if link.assignments_total == 0: + return "no_assignments" + if link.assignments_completed == 0: + return "no_completed_evaluations" + return "partially_evaluated" + + def get_reason_label(self, link): + """Контролируемая подпись не трактует отсутствие завершения как отсутствие начала.""" + return WAITING_REASONS[self.get_reason(link)] diff --git a/partner_programs/services/analytics.py b/partner_programs/services/analytics.py index abc84f48..eb1b2847 100644 --- a/partner_programs/services/analytics.py +++ b/partner_programs/services/analytics.py @@ -1,21 +1,28 @@ -from collections import defaultdict from datetime import timedelta -from django.db.models import Count, Exists, OuterRef, Q -from django.db.models.functions import TruncDate, Trim +from django.db.models import Case, CharField, Count, Exists, F, IntegerField, Min +from django.db.models import OuterRef, Q, Subquery, Value, When +from django.db.models.functions import Coalesce, TruncDate, Trim from django.utils import timezone from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile from projects.models import Collaborator from partner_programs.services.assignment_analytics import ( + annotated_assignment_queryset, build_assignments, build_delayed_experts, ) +from project_rates.models import ProjectScore ACTIVITY_DAYS = 30 -def _get_participant_metrics(program_id: int) -> dict[str, int]: +def _participant_profiles(program_id): + """Регистрации с признаками команды только в проектах текущей программы. + + Руководитель считается участником команды и без записи Collaborator. + Поле project регистрационной анкеты не заменяет фактические связи команды. + """ leader_exists = Exists( PartnerProgramProject.objects.filter( partner_program_id=program_id, @@ -35,13 +42,47 @@ def _get_participant_metrics(program_id: int) -> dict[str, int]: project__program_links__partner_program_id=program_id, ) ) - profiles = PartnerProgramUserProfile.objects.filter( + return PartnerProgramUserProfile.objects.filter( partner_program_id=program_id ).annotate( is_project_leader=leader_exists, is_submitted_project_leader=submitted_leader_exists, is_project_collaborator=collaborator_exists, ) + + +def _without_team_filter(): + """Общий предикат уникального участника без команды для счётчика и списка.""" + return Q( + user_id__isnull=False, + is_project_leader=False, + is_project_collaborator=False, + ) + + +def participants_without_team_rows(program_id): + """Уникальные пользователи без команды с первой регистрацией в программе. + + Группировка сохраняет одну строку на user_id даже при исторических дублях. + Удалённые пользователи исключены; поиск и пагинация остаются на уровне SQL. + """ + return ( + _participant_profiles(program_id) + .filter(_without_team_filter()) + .order_by() + .values( + "user_id", + "user__first_name", + "user__last_name", + "user__avatar", + "user__city", + ) + .annotate(registered_at=Min("datetime_created")) + ) + + +def _get_participant_metrics(program_id: int) -> dict[str, int]: + profiles = _participant_profiles(program_id) participant_filter = Q(user_id__isnull=False) team_filter = Q(is_project_leader=True) | Q(is_project_collaborator=True) @@ -59,11 +100,7 @@ def _get_participant_metrics(program_id: int) -> dict[str, int]: ), without_team=Count( "user_id", - filter=( - participant_filter - & Q(is_project_leader=False) - & Q(is_project_collaborator=False) - ), + filter=_without_team_filter(), distinct=True, ), project_creators=Count( @@ -108,70 +145,113 @@ def _get_participant_regions(program_id: int) -> list[dict]: ) -def _get_solution_metrics(program, assignments_by_project: dict) -> dict[str, int]: - program_id = program.id - project_rows = ( - PartnerProgramProject.objects.filter(partner_program_id=program_id) - .annotate( - rated_experts=Count( - "project__scores__user_id", - filter=Q( - project__scores__criteria__partner_program_id=program_id, - ), - distinct=True, +def _solution_rows(program): + """Общая SQL-классификация работ программы для overview и детализации. + + В distributed учитываются только реальные назначения и общая проверка + завершённости каждого назначения. В open достаточно первой оценки по + критериям программы; прогресс назначений в этом режиме неприменим (null). + """ + rows = PartnerProgramProject.objects.filter(partner_program_id=program.pk) + if program.is_distributed_evaluation: + assignment_totals = ( + annotated_assignment_queryset(program.pk) + .filter(project_id=OuterRef("project_id")) + .order_by() + .values("project_id") + .annotate( + total=Count("pk"), + completed=Count("pk", filter=Q(is_completed=True)), ) ) - .values_list("project_id", "submitted", "rated_experts") + rows = rows.annotate( + assignments_total=Coalesce( + Subquery(assignment_totals.values("total")[:1]), 0 + ), + assignments_completed=Coalesce( + Subquery(assignment_totals.values("completed")[:1]), 0 + ), + ) + evaluated_status = Case( + When( + Q(assignments_total=0) | Q(assignments_completed=0), + then=Value("awaiting_evaluation"), + ), + When( + assignments_completed__lt=F("assignments_total"), + then=Value("partially_evaluated"), + ), + default=Value("evaluated"), + output_field=CharField(), + ) + else: + rows = rows.annotate( + assignments_total=Value(None, output_field=IntegerField()), + assignments_completed=Value(None, output_field=IntegerField()), + has_program_score=Exists( + ProjectScore.objects.filter( + project_id=OuterRef("project_id"), + criteria__partner_program_id=program.pk, + ) + ), + ) + evaluated_status = Case( + When(has_program_score=True, then=Value("evaluated")), + default=Value("awaiting_evaluation"), + output_field=CharField(), + ) + return rows.annotate( + status=Case( + When(submitted=False, then=Value("not_submitted")), + default=evaluated_status, + output_field=CharField(), + ) ) - metrics = { - "created": 0, - "not_submitted": 0, - "submitted": 0, - "awaiting_evaluation": 0, - "partially_evaluated": 0, - "evaluated": 0, - } - for project_id, submitted, rated_experts in project_rows: - metrics["created"] += 1 - if not submitted: - metrics["not_submitted"] += 1 - continue - - metrics["submitted"] += 1 - if not program.is_distributed_evaluation: - status = "evaluated" if rated_experts > 0 else "awaiting_evaluation" - metrics[status] += 1 - continue - - project_assignments = assignments_by_project.get( - project_id, - {"total": 0, "evaluated": 0}, + +def projects_awaiting_evaluation_rows(program): + """Сданные работы, из которых состоит счётчик ожидания оценивания. + + Одна строка соответствует связи проекта с программой, а не назначению. + Фильтрация, count и пагинация выполняются в SQL; руководитель загружается + тем же запросом без полного пользовательского сериализатора или N+1. + """ + return ( + _solution_rows(program) + .filter(status__in=("awaiting_evaluation", "partially_evaluated")) + .select_related("project", "project__leader") + .only( + "id", + "project_id", + "datetime_submitted", + "project__name", + "project__leader_id", + "project__leader__id", + "project__leader__first_name", + "project__leader__last_name", + "project__leader__avatar", ) - assigned = project_assignments["total"] - evaluated_assignments = project_assignments["evaluated"] - if assigned == 0 or evaluated_assignments == 0: - metrics["awaiting_evaluation"] += 1 - elif evaluated_assignments < assigned: - metrics["partially_evaluated"] += 1 - else: - metrics["evaluated"] += 1 + ) - return metrics + +def _get_solution_metrics(program) -> dict[str, int]: + return _solution_rows(program).aggregate( + created=Count("pk"), + not_submitted=Count("pk", filter=Q(submitted=False)), + submitted=Count("pk", filter=Q(submitted=True)), + awaiting_evaluation=Count("pk", filter=Q(status="awaiting_evaluation")), + partially_evaluated=Count("pk", filter=Q(status="partially_evaluated")), + evaluated=Count("pk", filter=Q(status="evaluated")), + ) -def _get_assignment_metrics(assignments: list[dict]) -> tuple[dict[str, int], dict]: +def _get_assignment_metrics(assignments: list[dict]) -> dict[str, int]: metrics = {"total": 0, "pending": 0, "evaluated": 0} - by_project = defaultdict(lambda: {"total": 0, "evaluated": 0}) for assignment in assignments: - project_id = assignment["project"]["id"] completed = assignment["status"] == "completed" metrics["total"] += 1 metrics["evaluated" if completed else "pending"] += 1 - by_project[project_id]["total"] += 1 - if completed: - by_project[project_id]["evaluated"] += 1 - return metrics, dict(by_project) + return metrics def _get_activity(program_id: int) -> list[dict]: @@ -220,8 +300,8 @@ def build_program_manager_analytics(program) -> dict: regions = _get_regions(program_id) participant_regions = _get_participant_regions(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) + assignments = _get_assignment_metrics(assignment_items) + solutions = _get_solution_metrics(program) projects_awaiting_evaluation = ( solutions["awaiting_evaluation"] + solutions["partially_evaluated"] diff --git a/partner_programs/services/assignment_analytics.py b/partner_programs/services/assignment_analytics.py index aedd01df..74e1b28f 100644 --- a/partner_programs/services/assignment_analytics.py +++ b/partner_programs/services/assignment_analytics.py @@ -1,6 +1,6 @@ """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 import BooleanField, Case, Count, F, OuterRef, Subquery, Value, When from django.db.models.functions import Coalesce from django.utils import timezone @@ -8,8 +8,13 @@ from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore -def assignment_rows(program_id): - """One SELECT with indexed subqueries, not a query per serialized assignment.""" +def annotated_assignment_queryset(program_id): + """Общая SQL-проверка завершённости реального назначения в программе. + + Завершённое назначение требует сданного решения и оценок по всем критериям + этой программы; отсутствие критериев никогда не означает завершённость. + Аннотации пригодны и для списка назначений, и для агрегации работ без N+1. + """ criteria = ( Criteria.objects.filter(partner_program_id=program_id) .order_by() @@ -41,6 +46,25 @@ def assignment_rows(program_id): ), project_submitted_at=Subquery(link.values("datetime_submitted")[:1]), ) + .annotate( + is_completed=Case( + When( + project_submitted=True, + criteria_total__gt=0, + criteria_scored__gte=F("criteria_total"), + then=Value(True), + ), + default=Value(False), + output_field=BooleanField(), + ) + ) + ) + + +def assignment_rows(program_id): + """Данные назначений одним SELECT с общей проверкой завершённости.""" + return ( + annotated_assignment_queryset(program_id) .order_by("pk") .values( "id", @@ -56,6 +80,7 @@ def assignment_rows(program_id): "criteria_scored", "project_submitted", "project_submitted_at", + "is_completed", ) ) @@ -64,7 +89,7 @@ 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: + elif row["is_completed"]: status = "completed" elif total > 0 and scored > 0: status = "in_progress" diff --git a/partner_programs/tests/test_attention_analytics_api.py b/partner_programs/tests/test_attention_analytics_api.py new file mode 100644 index 00000000..3d4e43f7 --- /dev/null +++ b/partner_programs/tests/test_attention_analytics_api.py @@ -0,0 +1,728 @@ +from datetime import datetime, timedelta, timezone as datetime_timezone +from types import SimpleNamespace +from urllib.parse import parse_qs, urlparse + +from django.db import IntegrityError, connection, transaction +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.urls import reverse +from django.utils.dateparse import parse_datetime +from rest_framework.test import APIClient + +from partner_programs.models import PartnerProgramProject, PartnerProgramUserProfile +from partner_programs.serializers.attention import ProgramAttentionProjectSerializer +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_program_project, + create_project, + create_user, +) +from project_rates.models import Criteria, ProjectExpertAssignment, ProjectScore +from project_rates.tests.helpers import create_rate_expert +from projects.models import Collaborator + + +NOW = datetime(2026, 9, 5, 12, tzinfo=datetime_timezone.utc) +PARTICIPANTS = "manager-overview-participants-without-team" +PROJECTS = "manager-overview-projects-awaiting-evaluation" + + +class AttentionAnalyticsTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.manager = create_user(prefix="attention-manager") + cls.program = create_partner_program( + is_distributed_evaluation=True, max_project_rates=3 + ) + cls.program.managers.add(cls.manager) + cls.other_program = create_partner_program(is_distributed_evaluation=True) + cls.expert = create_rate_expert(program=cls.program) + # Program creation adds a comment criterion. Isolate two explicit criteria. + Criteria.objects.filter(partner_program=cls.program).delete() + cls.criteria = [ + Criteria.objects.create( + partner_program=cls.program, + name=f"Attention criterion {index}", + type="int", + min_value=0, + max_value=10, + ) + for index in range(2) + ] + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.manager) + + def url(self, endpoint, program=None): + return reverse( + "partner_programs:" + endpoint, + kwargs={"pk": (program or self.program).pk}, + ) + + def get(self, endpoint, **params): + response = self.client.get(self.url(endpoint), params) + self.assertEqual(response.status_code, 200, response.data) + return response.data + + def participant(self, *, program=None, user=None, hours=1, **user_fields): + user = user or create_user(prefix="attention-participant", **user_fields) + registration = create_program_member(program or self.program, user=user) + PartnerProgramUserProfile.objects.filter(pk=registration.pk).update( + datetime_created=NOW - timedelta(hours=hours) + ) + return registration + + def work(self, *, program=None, submitted=True, hours=1, **project_fields): + link = create_program_project( + program or self.program, + project=create_project(**project_fields), + submitted=submitted, + ) + PartnerProgramProject.objects.filter(pk=link.pk).update( + datetime_submitted=(NOW - timedelta(hours=hours)) if submitted else None + ) + return link + + def assignment(self, link, expert=None, program=None): + return ProjectExpertAssignment.objects.create( + partner_program=program or self.program, + project=link.project, + expert=(expert or self.expert).expert, + ) + + def score(self, link, *, expert=None, criteria=None): + for criterion in self.criteria if criteria is None else criteria: + ProjectScore.objects.get_or_create( + project=link.project, + user=expert or self.expert, + criteria=criterion, + defaults={"value": "0"}, + ) + + def open_mode(self): + self.program.is_distributed_evaluation = False + self.program.save(update_fields=["is_distributed_evaluation"]) + + def assert_matches_overview(self, endpoint, metric): + self.assertEqual( + self.get(endpoint)["count"], + self.get("manager-overview")["attention"][metric], + ) + + +class AttentionAnalyticsAccessAndPaginationTests(AttentionAnalyticsTestCase): + def test_manager_has_access_to_both_lists(self): + for endpoint in (PARTICIPANTS, PROJECTS): + with self.subTest(endpoint=endpoint): + self.assertEqual(self.get(endpoint)["results"], []) + + def test_staff_and_superuser_have_access_to_both_lists(self): + for permission in ("is_staff", "is_superuser"): + user = create_user(prefix=f"attention-{permission}", **{permission: True}) + self.client.force_authenticate(user) + for endpoint in (PARTICIPANTS, PROJECTS): + with self.subTest(permission=permission, endpoint=endpoint): + self.get(endpoint) + + def test_anonymous_get_is_unauthorized(self): + self.client.force_authenticate(None) + for endpoint in (PARTICIPANTS, PROJECTS): + with self.subTest(endpoint=endpoint): + self.assertEqual(self.client.get(self.url(endpoint)).status_code, 401) + + def test_participant_get_is_forbidden(self): + participant = self.participant().user + self.client.force_authenticate(participant) + for endpoint in (PARTICIPANTS, PROJECTS): + with self.subTest(endpoint=endpoint): + self.assertEqual(self.client.get(self.url(endpoint)).status_code, 403) + + def test_program_expert_get_is_forbidden(self): + self.client.force_authenticate(self.expert) + for endpoint in (PARTICIPANTS, PROJECTS): + with self.subTest(endpoint=endpoint): + self.assertEqual(self.client.get(self.url(endpoint)).status_code, 403) + + def test_manager_of_another_program_cannot_access_or_search_lists(self): + for endpoint in (PARTICIPANTS, PROJECTS): + with self.subTest(endpoint=endpoint): + response = self.client.get( + self.url(endpoint, self.other_program), {"search": "Program"} + ) + self.assertEqual(response.status_code, 403) + + def test_missing_program_is_not_found(self): + for endpoint in (PARTICIPANTS, PROJECTS): + with self.subTest(endpoint=endpoint): + url = reverse("partner_programs:" + endpoint, kwargs={"pk": 99999999}) + self.assertEqual(self.client.get(url).status_code, 404) + + def test_modifying_methods_are_not_allowed_and_leave_data_unchanged(self): + registration = self.participant() + link = self.work() + for endpoint in (PARTICIPANTS, PROJECTS): + for method in ("post", "put", "patch", "delete"): + with self.subTest(endpoint=endpoint, method=method): + response = getattr(self.client, method)( + self.url(endpoint), {"submitted": False}, format="json" + ) + self.assertEqual(response.status_code, 405) + self.assertTrue( + PartnerProgramUserProfile.objects.filter(pk=registration.pk).exists() + ) + link.refresh_from_db() + self.assertTrue(link.submitted) + + def test_invalid_pagination_is_rejected_on_both_endpoints(self): + for endpoint in (PARTICIPANTS, PROJECTS): + for params in ( + {"limit": "invalid"}, + {"limit": "1.5"}, + {"limit": ""}, + {"limit": 0}, + {"limit": -1}, + {"limit": 101}, + {"offset": "invalid"}, + {"offset": "1.5"}, + {"offset": ""}, + {"offset": -1}, + ): + with self.subTest(endpoint=endpoint, params=params): + self.assertEqual( + self.client.get(self.url(endpoint), params).status_code, 400 + ) + + def test_empty_page_has_standard_pagination_envelope(self): + for endpoint in (PARTICIPANTS, PROJECTS): + with self.subTest(endpoint=endpoint): + data = self.get(endpoint) + expected = {"count", "next", "previous", "results"} + if endpoint == PROJECTS: + expected.add("mode") + self.assertEqual(set(data), expected) + self.assertEqual(data["count"], 0) + self.assertIsNone(data["next"]) + self.assertIsNone(data["previous"]) + + def test_offset_beyond_total_returns_empty_page_without_losing_count(self): + self.participant() + self.work() + for endpoint in (PARTICIPANTS, PROJECTS): + with self.subTest(endpoint=endpoint): + data = self.get(endpoint, offset=100) + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"], []) + + def test_empty_search_matches_absent_search(self): + self.participant() + self.work() + for endpoint in (PARTICIPANTS, PROJECTS): + for query in ("", " ", "\t"): + with self.subTest(endpoint=endpoint, query=query): + self.assertEqual( + self.get(endpoint, search=query)["results"], + self.get(endpoint)["results"], + ) + + +class ParticipantsWithoutTeamTests(AttentionAnalyticsTestCase): + def test_registered_participant_without_team_is_included(self): + registration = self.participant() + data = self.get(PARTICIPANTS) + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"][0]["user_id"], registration.user_id) + + def test_leader_without_collaborator_is_excluded(self): + registration = self.participant() + link = self.work(leader=registration.user) + # The project creation signal adds its leader as collaborator automatically. + Collaborator.objects.filter(project=link.project, user=registration.user).delete() + self.assertEqual(self.get(PARTICIPANTS)["count"], 0) + self.assert_matches_overview(PARTICIPANTS, "participants_without_team") + + def test_collaborator_without_leader_role_is_excluded(self): + registration = self.participant() + link = self.work() + Collaborator.objects.create(project=link.project, user=registration.user) + self.assertNotEqual(link.project.leader_id, registration.user_id) + self.assertEqual(self.get(PARTICIPANTS)["count"], 0) + + def test_team_only_in_other_program_does_not_exclude_participant(self): + registration = self.participant() + self.participant(program=self.other_program, user=registration.user) + link = self.work(program=self.other_program) + Collaborator.objects.create(project=link.project, user=registration.user) + self.assertEqual( + self.get(PARTICIPANTS)["results"][0]["user_id"], registration.user_id + ) + + def test_leading_unlinked_project_does_not_exclude_participant(self): + registration = self.participant() + create_project(leader=registration.user) + self.assertEqual(self.get(PARTICIPANTS)["count"], 1) + + def test_registration_project_field_is_not_evidence_of_team_membership(self): + registration = self.participant() + link = self.work() + PartnerProgramUserProfile.objects.filter(pk=registration.pk).update( + project=link.project + ) + self.assertEqual(self.get(PARTICIPANTS)["count"], 1) + + def test_team_in_unsubmitted_draft_project_still_excludes_participant(self): + registration = self.participant() + self.work(leader=registration.user, submitted=False, draft=True) + self.assertEqual(self.get(PARTICIPANTS)["count"], 0) + + def test_registration_in_other_program_is_not_disclosed(self): + self.participant(program=self.other_program, first_name="UniqueOther") + self.assertEqual(self.get(PARTICIPANTS)["count"], 0) + self.assertEqual(self.get(PARTICIPANTS, search="UniqueOther")["count"], 0) + + def test_deleted_user_registration_is_excluded(self): + registration = self.participant() + registration.user.delete() + registration.refresh_from_db() + self.assertIsNone(registration.user_id) + self.assertEqual(self.get(PARTICIPANTS)["count"], 0) + + def test_duplicate_registration_is_prevented_by_existing_schema(self): + registration = self.participant() + with self.assertRaises(IntegrityError), transaction.atomic(): + create_program_member(self.program, user=registration.user) + self.assertEqual(self.get(PARTICIPANTS)["count"], 1) + + def test_registered_at_uses_current_program_not_account_or_other_program_date(self): + registration = self.participant(hours=10) + self.participant(program=self.other_program, user=registration.user, hours=100) + type(registration.user).objects.filter(pk=registration.user_id).update( + datetime_created=NOW - timedelta(days=365) + ) + item = self.get(PARTICIPANTS)["results"][0] + self.assertEqual(parse_datetime(item["registered_at"]), NOW - timedelta(hours=10)) + + def test_safe_participant_fields_and_missing_display_values(self): + registration = self.participant( + first_name="", last_name="", city=None, avatar=None + ) + registration.partner_program_data = { + "email": "secret@example.com", + "phone": "secret", + } + registration.save(update_fields=["partner_program_data"]) + item = self.get(PARTICIPANTS)["results"][0] + self.assertEqual( + set(item), {"user_id", "full_name", "avatar", "city", "registered_at"} + ) + self.assertEqual(item["full_name"], f"Участник №{registration.user_id}") + self.assertIsNone(item["avatar"]) + self.assertIsNone(item["city"]) + + def test_actual_name_avatar_and_legacy_city_are_preserved(self): + self.participant( + first_name="Анна", + last_name="Петрова", + city="Набережные Челны", + avatar="https://example.com/avatar.png", + ) + item = self.get(PARTICIPANTS)["results"][0] + self.assertEqual(item["full_name"], "Анна Петрова") + self.assertEqual(item["city"], "Набережные Челны") + self.assertEqual(item["avatar"], "https://example.com/avatar.png") + + def test_count_equals_overview_for_mixed_memberships_and_null_users(self): + self.participant() + leader = self.participant().user + teammate = self.participant().user + link = self.work(leader=leader) + Collaborator.objects.create(project=link.project, user=teammate) + self.participant(program=self.other_program) + PartnerProgramUserProfile.objects.create( + partner_program=self.program, user=None, partner_program_data={} + ) + self.assertEqual(self.get(PARTICIPANTS)["count"], 1) + self.assert_matches_overview(PARTICIPANTS, "participants_without_team") + + def test_search_is_trimmed_case_insensitive_and_matches_both_name_parts(self): + registration = self.participant(first_name="Alice", last_name="UniqueSmith") + self.participant(first_name="Bob", last_name="Jones") + for search in (" alice ", "uniquesmith", "LIC"): + with self.subTest(search=search): + data = self.get(PARTICIPANTS, search=search) + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"][0]["user_id"], registration.user_id) + + def test_postgresql_cyrillic_search_is_case_insensitive(self): + if connection.vendor != "postgresql": + self.skipTest("Unicode icontains is verified on PostgreSQL, not SQLite LIKE") + registration = self.participant(first_name="Анна", last_name="Петрова") + for search in ("АННА", "анна", "ПеТрОвА"): + with self.subTest(search=search): + self.assertEqual( + self.get(PARTICIPANTS, search=search)["results"][0]["user_id"], + registration.user_id, + ) + + def test_search_does_not_use_email_city_or_private_registration_answers(self): + registration = self.participant(city="UniqueNeedle") + registration.partner_program_data = {"answer": "UniqueNeedle"} + registration.save(update_fields=["partner_program_data"]) + for search in ("UniqueNeedle", registration.user.email): + with self.subTest(search=search): + self.assertEqual(self.get(PARTICIPANTS, search=search)["count"], 0) + + def test_stable_sort_is_oldest_registration_then_user_id(self): + first = self.participant(hours=1) + second = self.participant(hours=2) + third = self.participant(hours=2) + expected = [second.user_id, third.user_id, first.user_id] + for _ in range(2): + self.assertEqual( + [item["user_id"] for item in self.get(PARTICIPANTS)["results"]], expected + ) + + def test_default_page_size_and_search_before_pagination(self): + for _ in range(25): + self.participant(hours=2) + target = self.participant(first_name="Needle", hours=1) + first_page = self.get(PARTICIPANTS) + self.assertEqual(first_page["count"], 26) + self.assertEqual(len(first_page["results"]), 25) + self.assertNotIn( + target.user_id, [item["user_id"] for item in first_page["results"]] + ) + data = self.get(PARTICIPANTS, search="Needle") + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"][0]["user_id"], target.user_id) + + def test_pagination_links_preserve_search_and_page_boundaries(self): + registrations = [self.participant(first_name="Needle", hours=2) for _ in range(3)] + first = self.get(PARTICIPANTS, search="Needle", limit=2) + next_query = parse_qs(urlparse(first["next"]).query) + self.assertEqual( + next_query, {"search": ["Needle"], "limit": ["2"], "offset": ["2"]} + ) + second = self.get(PARTICIPANTS, search="Needle", limit=2, offset=2) + self.assertEqual(second["results"][0]["user_id"], registrations[-1].user_id) + self.assertIsNone(second["next"]) + self.assertIsNotNone(second["previous"]) + + def test_query_count_is_constant_when_participant_page_grows(self): + self.participant() + with CaptureQueriesContext(connection) as small: + self.get(PARTICIPANTS, limit=100) + for _ in range(30): + self.participant() + with CaptureQueriesContext(connection) as large: + data = self.get(PARTICIPANTS, limit=100) + self.assertEqual(len(data["results"]), 31) + self.assertEqual(len(small), len(large)) + self.assertLessEqual(len(large), 4) + + +class ProjectsAwaitingEvaluationTests(AttentionAnalyticsTestCase): + def test_distributed_submitted_without_assignments_has_explicit_reason(self): + link = self.work() + data = self.get(PROJECTS) + self.assertEqual(data["mode"], "distributed") + item = data["results"][0] + self.assertEqual(item["program_project_id"], link.pk) + self.assertEqual(item["status"], "awaiting_evaluation") + self.assertEqual(item["reason"], "no_assignments") + self.assertEqual(item["reason_label"], "Эксперты не назначены") + self.assertEqual(item["assignments_total"], 0) + self.assertEqual(item["assignments_completed"], 0) + + def test_assignments_without_scores_have_no_completed_evaluations_reason(self): + self.assignment(self.work()) + item = self.get(PROJECTS)["results"][0] + self.assertEqual(item["reason"], "no_completed_evaluations") + self.assertEqual(item["reason_label"], "Нет завершённых оценок") + self.assertEqual(item["assignments_total"], 1) + self.assertEqual(item["assignments_completed"], 0) + + def test_partial_criteria_do_not_mean_completed_assignment(self): + link = self.work() + self.assignment(link) + self.score(link, criteria=self.criteria[:1]) + item = self.get(PROJECTS)["results"][0] + self.assertEqual(item["status"], "awaiting_evaluation") + self.assertEqual(item["reason"], "no_completed_evaluations") + self.assertEqual(item["assignments_completed"], 0) + + def test_one_complete_assignment_of_three_is_partially_evaluated(self): + link = self.work() + self.assignment(link) + for _ in range(2): + self.assignment(link, expert=create_rate_expert(program=self.program)) + self.score(link) + item = self.get(PROJECTS)["results"][0] + self.assertEqual(item["status"], "partially_evaluated") + self.assertEqual(item["reason"], "partially_evaluated") + self.assertEqual(item["reason_label"], "Частично оценено") + self.assertEqual(item["assignments_total"], 3) + self.assertEqual(item["assignments_completed"], 1) + + def test_all_assignments_completed_excludes_work(self): + link = self.work() + self.assignment(link) + self.score(link) + self.assertEqual(self.get(PROJECTS)["count"], 0) + self.assert_matches_overview(PROJECTS, "projects_awaiting_evaluation") + + def test_maximum_is_not_required_number_of_completed_assignments(self): + self.program.max_project_rates = 10 + # Keep the isolated two-criterion fixture: program.save() recreates Comment. + type(self.program).objects.filter(pk=self.program.pk).update(max_project_rates=10) + link = self.work() + self.assignment(link) + self.score(link) + self.assertEqual(self.get(PROJECTS)["count"], 0) + + def test_unsubmitted_work_and_not_ready_assignments_are_excluded(self): + link = self.work(submitted=False) + self.assignment(link) + self.score(link) + self.assertEqual(self.get(PROJECTS)["count"], 0) + + def test_zero_criteria_does_not_complete_assignment(self): + Criteria.objects.filter(partner_program=self.program).delete() + self.assignment(self.work()) + item = self.get(PROJECTS)["results"][0] + self.assertEqual(item["assignments_completed"], 0) + self.assertEqual(item["reason"], "no_completed_evaluations") + + def test_scores_of_other_expert_cannot_complete_current_assignment(self): + link = self.work() + self.assignment(link) + self.score(link, expert=create_rate_expert(program=self.program)) + self.assertEqual(self.get(PROJECTS)["results"][0]["assignments_completed"], 0) + + def test_scores_of_other_program_cannot_complete_current_assignment(self): + link = self.work() + self.assignment(link) + criterion = Criteria.objects.create( + partner_program=self.other_program, name="Other criterion", type="int" + ) + self.score(link, criteria=[criterion]) + self.assertEqual(self.get(PROJECTS)["results"][0]["assignments_completed"], 0) + + def test_scores_of_other_project_cannot_complete_current_assignment(self): + self.assignment(self.work()) + self.score(self.work()) + self.assertTrue( + all( + item["assignments_completed"] == 0 + for item in self.get(PROJECTS)["results"] + ) + ) + + def test_other_program_assignment_on_same_project_is_ignored(self): + link = self.work() + create_program_project(self.other_program, project=link.project, submitted=True) + other_expert = create_rate_expert(program=self.other_program) + self.assignment(link, expert=other_expert, program=self.other_program) + item = self.get(PROJECTS)["results"][0] + self.assertEqual(item["assignments_total"], 0) + self.assertEqual(item["reason"], "no_assignments") + + def test_many_assignments_produce_only_one_program_project_row(self): + link = self.work() + self.assignment(link) + for _ in range(2): + self.assignment(link, expert=create_rate_expert(program=self.program)) + data = self.get(PROJECTS) + self.assertEqual(data["count"], 1) + self.assertEqual(len(data["results"]), 1) + self.assertEqual(data["results"][0]["assignments_total"], 3) + + def test_open_without_scores_has_null_assignment_aggregates(self): + self.open_mode() + self.work() + data = self.get(PROJECTS) + self.assertEqual(data["mode"], "open") + item = data["results"][0] + self.assertEqual(item["status"], "awaiting_evaluation") + self.assertEqual(item["reason"], "awaiting_first_evaluation") + self.assertEqual(item["reason_label"], "Ожидает первой оценки") + self.assertIsNone(item["assignments_total"]) + self.assertIsNone(item["assignments_completed"]) + self.assertFalse( + ProjectExpertAssignment.objects.filter(partner_program=self.program).exists() + ) + + def test_open_first_program_criterion_score_excludes_work_without_assignment(self): + self.open_mode() + link = self.work() + self.score(link, criteria=self.criteria[:1]) + self.assertEqual(self.get(PROJECTS)["count"], 0) + self.assert_matches_overview(PROJECTS, "projects_awaiting_evaluation") + + def test_open_other_program_score_does_not_exclude_work(self): + self.open_mode() + link = self.work() + criterion = Criteria.objects.create( + partner_program=self.other_program, name="Other open criterion", type="int" + ) + self.score(link, criteria=[criterion]) + self.assertEqual(self.get(PROJECTS)["count"], 1) + + def test_open_zero_criteria_still_waits_for_first_evaluation(self): + self.open_mode() + Criteria.objects.filter(partner_program=self.program).delete() + self.work() + self.assertEqual( + self.get(PROJECTS)["results"][0]["reason"], "awaiting_first_evaluation" + ) + + def test_open_existing_assignment_does_not_create_progress_requirement(self): + self.open_mode() + link = self.work() + self.assignment(link) + item = self.get(PROJECTS)["results"][0] + self.assertIsNone(item["assignments_total"]) + self.score(link, criteria=self.criteria[:1]) + self.assertEqual(self.get(PROJECTS)["count"], 0) + + def test_work_must_be_submitted_in_current_program(self): + link = self.work(submitted=False) + create_program_project(self.other_program, project=link.project, submitted=True) + self.work(program=self.other_program, name="OtherOnlyNeedle") + self.assertEqual(self.get(PROJECTS)["count"], 0) + self.assertEqual(self.get(PROJECTS, search="OtherOnlyNeedle")["count"], 0) + + def test_legacy_submitted_without_date_keeps_null(self): + link = self.work() + PartnerProgramProject.objects.filter(pk=link.pk).update(datetime_submitted=None) + self.assertIsNone(self.get(PROJECTS)["results"][0]["submitted_at"]) + + def test_project_and_leader_contract_has_only_safe_minimal_fields(self): + leader = create_user(first_name="Анна", last_name="Иванова", avatar=None) + link = self.work(leader=leader, name="Visible project") + item = self.get(PROJECTS)["results"][0] + self.assertEqual( + set(item), + { + "program_project_id", + "project", + "leader", + "submitted_at", + "status", + "reason", + "reason_label", + "assignments_total", + "assignments_completed", + }, + ) + self.assertEqual( + item["project"], {"id": link.project_id, "name": "Visible project"} + ) + self.assertEqual( + item["leader"], + {"user_id": leader.pk, "full_name": "Анна Иванова", "avatar": None}, + ) + self.assertEqual(parse_datetime(item["submitted_at"]), NOW - timedelta(hours=1)) + + def test_serializer_only_defensively_accepts_null_leader_without_schema_change(self): + # Project.leader is NOT NULL today; exercise the nullable response contract only. + link = SimpleNamespace( + pk=1, + project=SimpleNamespace(id=2, name="Legacy project", leader=None), + datetime_submitted=None, + status="awaiting_evaluation", + assignments_total=0, + assignments_completed=0, + ) + with self.assertNumQueries(0): + data = ProgramAttentionProjectSerializer(link).data + self.assertIsNone(data["leader"]) + self.assertIsNone(data["submitted_at"]) + + def test_sort_is_oldest_submission_then_link_id_and_unknown_dates_last(self): + newest = self.work(hours=1) + older = self.work(hours=2) + tied = self.work(hours=2) + unknown = self.work(hours=3) + PartnerProgramProject.objects.filter(pk=unknown.pk).update( + datetime_submitted=None + ) + expected = [older.pk, tied.pk, newest.pk, unknown.pk] + for _ in range(2): + self.assertEqual( + [item["program_project_id"] for item in self.get(PROJECTS)["results"]], + expected, + ) + + def test_search_is_trimmed_case_insensitive_and_name_only(self): + link = self.work(name="Unique Project") + self.work(name="Other", description="Unique Project") + for search in (" UNIQUE ", "project"): + with self.subTest(search=search): + data = self.get(PROJECTS, search=search) + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"][0]["program_project_id"], link.pk) + + def test_search_finds_work_outside_default_first_page(self): + for index in range(25): + self.work(name=f"Old project {index}", hours=2) + target = self.work(name="SearchTarget", hours=1) + page = self.get(PROJECTS) + self.assertEqual(page["count"], 26) + self.assertEqual(len(page["results"]), 25) + self.assertNotIn( + target.pk, [item["program_project_id"] for item in page["results"]] + ) + data = self.get(PROJECTS, search="SearchTarget") + self.assertEqual(data["count"], 1) + self.assertEqual(data["results"][0]["program_project_id"], target.pk) + + def test_project_pagination_preserves_mode_and_search(self): + links = [self.work(name=f"Needle {index}") for index in range(3)] + first = self.get(PROJECTS, limit=2, search="Needle") + self.assertEqual(first["mode"], "distributed") + self.assertEqual(first["count"], 3) + self.assertEqual( + parse_qs(urlparse(first["next"]).query), + {"limit": ["2"], "search": ["Needle"], "offset": ["2"]}, + ) + second = self.get(PROJECTS, limit=2, offset=2, search="Needle") + self.assertEqual(second["results"][0]["program_project_id"], links[-1].pk) + self.assertIsNotNone(second["previous"]) + self.assertIsNone(second["next"]) + + def test_distributed_count_matches_overview_for_all_completion_states(self): + self.work() + self.assignment(self.work()) + partial = self.work() + self.assignment(partial) + self.assignment(partial, expert=create_rate_expert(program=self.program)) + self.score(partial) + completed = self.work() + self.assignment(completed) + self.score(completed) + self.assignment(self.work(submitted=False)) + self.assertEqual(self.get(PROJECTS)["count"], 3) + self.assert_matches_overview(PROJECTS, "projects_awaiting_evaluation") + + def test_open_count_matches_overview_without_using_distributed_rules(self): + self.open_mode() + self.work() + evaluated = self.work() + self.score(evaluated, criteria=self.criteria[:1]) + self.work(submitted=False) + self.assertEqual(self.get(PROJECTS)["count"], 1) + self.assert_matches_overview(PROJECTS, "projects_awaiting_evaluation") + + def test_query_count_is_constant_when_work_page_and_assignments_grow(self): + self.assignment(self.work()) + with CaptureQueriesContext(connection) as small: + self.get(PROJECTS, limit=100) + for _ in range(30): + self.assignment(self.work()) + with CaptureQueriesContext(connection) as large: + data = self.get(PROJECTS, limit=100) + self.assertEqual(len(data["results"]), 31) + self.assertEqual(len(small), len(large)) + self.assertLessEqual(len(large), 4) diff --git a/partner_programs/urls.py b/partner_programs/urls.py index d8cd5c8a..9bc11081 100644 --- a/partner_programs/urls.py +++ b/partner_programs/urls.py @@ -11,6 +11,8 @@ ProgramManagerAnalyticsAPIView, ProgramManagerAssignmentsAPIView, ProgramManagerAssignmentScoresAPIView, + ProgramManagerParticipantsWithoutTeamAPIView, + ProgramManagerProjectsAwaitingEvaluationAPIView, PartnerProgramProjectApplyView, PartnerProgramProjectsAPIView, PartnerProgramProjectSubmitView, @@ -32,6 +34,16 @@ ProgramManagerAnalyticsAPIView.as_view(), name="manager-overview", ), + path( + "/manager-overview/participants-without-team/", + ProgramManagerParticipantsWithoutTeamAPIView.as_view(), + name="manager-overview-participants-without-team", + ), + path( + "/manager-overview/projects-awaiting-evaluation/", + ProgramManagerProjectsAwaitingEvaluationAPIView.as_view(), + name="manager-overview-projects-awaiting-evaluation", + ), path( "/manager-overview/assignments/", ProgramManagerAssignmentsAPIView.as_view(), diff --git a/partner_programs/views.py b/partner_programs/views.py index e0f2bf2b..215daba5 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -1,6 +1,7 @@ from django.contrib.auth import get_user_model from django.db import transaction -from django.db.models import Exists, OuterRef +from django.db.models import Exists, F, OuterRef, Value +from django.db.models.functions import Concat from django.shortcuts import get_object_or_404 from django.utils import timezone from django.utils.timezone import now @@ -22,7 +23,10 @@ PartnerProgramProject, PartnerProgramUserProfile, ) -from partner_programs.pagination import PartnerProgramPagination +from partner_programs.pagination import ( + PartnerProgramPagination, + ProgramAttentionPagination, +) from partner_programs.permissions import ( IsAdminManagerOrExpertOfProgram, IsAdminOrManagerOfProgram, @@ -61,6 +65,15 @@ ProgramAssignmentScoresSerializer, ProgramAssignmentSerializer, ) +from partner_programs.serializers.attention import ( + ProgramAttentionParticipantSerializer, + ProgramAttentionProjectSerializer, + ProgramAttentionQuerySerializer, +) +from partner_programs.services.analytics import ( + participants_without_team_rows, + projects_awaiting_evaluation_rows, +) from partner_programs.services.assignment_analytics import ( assignment_rows, build_assignment, @@ -487,6 +500,57 @@ def get(self, request, pk, assignment_id): return Response(ProgramAssignmentScoresSerializer(assignment).data) +class ProgramManagerAttentionListAPIView(ProgramManagerAnalyticsAccessAPIView): + """Общий read-only доступ и пагинация только двух новых списков внимания.""" + + def get(self, request, pk): + """Проверяет управление программой до поиска, подсчёта и сериализации страницы.""" + program = self.get_program(request, pk) + query = ProgramAttentionQuerySerializer(data=request.query_params.dict()) + query.is_valid(raise_exception=True) + paginator = ProgramAttentionPagination(query.validated_data) + queryset = self.get_queryset(program, query.validated_data["search"]) + page = paginator.paginate_queryset(queryset, request, view=self) + response = paginator.get_paginated_response( + self.serializer_class(page, many=True).data + ) + if self.include_mode: + response.data["mode"] = ( + "distributed" if program.is_distributed_evaluation else "open" + ) + return response + + +class ProgramManagerParticipantsWithoutTeamAPIView(ProgramManagerAttentionListAPIView): + """Уникальные зарегистрированные пользователи без команды именно этой программы.""" + + serializer_class = ProgramAttentionParticipantSerializer + include_mode = False + + def get_queryset(self, program, search): + """Ищет по имени до пагинации; старые регистрации первыми с user_id tie-breaker.""" + queryset = participants_without_team_rows(program.pk) + if search: + queryset = queryset.annotate( + search_name=Concat("user__first_name", Value(" "), "user__last_name") + ).filter(search_name__icontains=search) + return queryset.order_by(F("registered_at").asc(nulls_last=True), "user_id") + + +class ProgramManagerProjectsAwaitingEvaluationAPIView(ProgramManagerAttentionListAPIView): + """Сданные работы из того же предиката, что и projects_awaiting_evaluation.""" + + serializer_class = ProgramAttentionProjectSerializer + include_mode = True + + def get_queryset(self, program, search): + """Не включает not_ready; неизвестные даты сдачи остаются в конце списка.""" + queryset = projects_awaiting_evaluation_rows(program) + if search: + queryset = queryset.filter(project__name__icontains=search) + return queryset.order_by(F("datetime_submitted").asc(nulls_last=True), "pk") + + class ProgramProjectFilterAPIView(GenericAPIView): serializer_class = ProgramProjectFilterRequestSerializer permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram]