Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions docs/program-manager-analytics-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -506,3 +506,125 @@ Regression-бюджет для manager с уже аутентифицирова
Angular, shared app-modal, production, workflows и Docker в этом этапе не меняются.
Будущему UI разрешены только существующие переходы к профилю/проекту; сообщения,
напоминания, подбор команды, назначения экспертов и новые выгрузки в v1 отсутствуют.

## Проекты не сдали решение

### Аудит единиц измерения: почему «Сдали проект» = 2, а «Сдано» = 6

`participant_funnel.submitted_project_creators` считает **уникальные user_id
зарегистрированных участников**, руководящих хотя бы одним сданным проектом
текущей программы. Удалённые пользователи (`user_id=null`) не учитываются.
`solution_funnel.submitted` считает **сданные связи PartnerProgramProject**.
Например, два зарегистрированных руководителя с тремя сданными проектами у
каждого дают соответственно 2 человека и 6 связей. Это разные единицы, не
ошибка одного счётчика. Оба существующих API-поля и их расчёт сохранены.
Удаление дублирующей строки из воронки участников — отдельная задача Angular,
не изменение backend-контракта. Число регистраций также не подменяет число людей.

### Счётчик и применимость

В `manager-overview.attention` добавлено read-only поле:

```json
"projects_not_submitted": {"applicable": true, "total": 4}
```

Требование сдачи применимо только при `PartnerProgram.is_competitive=true`.
В этом случае одна несданная связь текущей программы (`submitted=false`) — одна
строка. Публичность и draft проекта, его команда, руководитель и назначения
экспертов не меняют включение. Сдача этого же проекта в другой программе не
влияет на текущую связь. Проекты без связи с программой исключены.

При одном состоянии БД и без поиска выполняется:

`attention.projects_not_submitted.total == solution_funnel.not_submitted == count`.

Все три используют общий `_not_submitted_filter()`: сводка повторно использует
уже рассчитанный агрегат воронки, без нового SQL. Список использует тот же
предикат через `projects_not_submitted_rows()`.

Для несоревновательной программы счётчик — `{"applicable": false, "total": 0}`:
несданные связи не являются проблемой. Старый `solution_funnel.not_submitted`
не пересчитывается и может оставаться ненулевым.

### Endpoint и безопасные поля

`GET /programs/<program_id>/manager-overview/projects-not-submitted/`

Доступ: manager **этой** программы, staff, superuser через существующий
`can_manage_program`. Anonymous — `401`; участник, эксперт без роли manager,
manager другой программы и посторонний — `403`; отсутствующая программа — `404`.
Для авторизованного manager POST/PUT/PATCH/DELETE возвращают `405`.
Endpoint не меняет состояния сдачи и не даёт новых возможностей записи.

```json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"program_project_id": 70,
"project": {"id": 55, "name": "Проект А"},
"leader": {"user_id": 123, "full_name": "Анна Петрова", "avatar": null},
"linked_at": "2026-09-01T10:00:00+03:00"
}
],
"applicable": true,
"submission_deadline": "2026-09-10T23:59:00+03:00",
"submission_open": true
}
```

`linked_at` — `PartnerProgramProject.datetime_created`, **не** дата создания
Project. Руководитель содержит только разрешённые ID, имя и avatar. Пустое имя
заменяется на `Участник №ID`, пустой avatar — `null`. Контракт защищён от
`leader=null` без изменения текущего NOT NULL ограничения Project.leader.
Нет email, телефона, auth-полей, анкет, файлов, оценок, SLA или полного User/Project
serializer. Режим оценивания не добавляется и не меняет состав списка.

### Сроки, поиск и пагинация

`submission_deadline` берётся из существующего
`program.get_project_submission_deadline()`: сначала
`datetime_project_submission_ends`, иначе `datetime_registration_ends`.
`submission_open` — результат существующего `program.is_project_submission_open()`.
Его правило: срок отсутствует либо ещё не прошёл, включая точное равенство
текущему времени. Даты timezone-aware; нового расчёта дедлайнов/SLA нет.

Несоревновательная программа всегда возвращает:

```json
{
"count": 0,
"next": null,
"previous": null,
"results": [],
"applicable": false,
"submission_deadline": null,
"submission_open": false
}
```

Повторно используется `ProgramAttentionPagination` и проверка query из #725:
`limit=25` по умолчанию, допустимо 1–100; `offset=0` по умолчанию, допустимо
неотрицательное целое. Некорректное значение, включая `limit>100`, даёт `400`.
Поиск `search` обрезает внешние пробелы и использует только
`project__name__icontains`, до count и SQL-пагинации. По имени руководителя,
email, описанию и приватным данным поиск не выполняется. Пробельная строка
не ограничивает список. Порядок: дата создания связи по возрастанию, затем её pk.
`next`/`previous` сохраняют поиск; offset за концом — `200`, пустые results и
актуальный count. Поиск меняет count найденных строк, но не счётчик сводки.

### Производительность и ограничения

Страница загружается через JOIN/select_related с project и leader и явным
набором полей. Сериализация уже выбранных строк выполняет **0 SQL**. Regression
проверяет одинаковое число SQL при росте с 1 до 31 строки с разными руководителями:
не более 4 запросов для аутентифицированного manager, как у списков #725.
Overview сохраняет бюджет не более 10 SQL и прежние контракты assignments,
scores, delayed_experts и двух существующих списков внимания.

Кейсы, сообщения, напоминания и изменение scoring/submission lifecycle не входят
в этот этап. Модели, migrations, зависимости, Angular/React, workflows, Docker и
deploy не меняются. Новые поля добавлены совместимо с существующим overview.
7 changes: 7 additions & 0 deletions partner_programs/serializers/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,16 @@ class DelayedExpertsSerializer(AnalyticsTotalSerializer):
items = DelayedExpertSerializer(many=True)


class ProjectsNotSubmittedSerializer(AnalyticsTotalSerializer):
"""Несданные связи программы; требование сдачи применимо только к конкурсной."""

applicable = serializers.BooleanField()


class ProgramAttentionSerializer(serializers.Serializer):
participants_without_team = serializers.IntegerField(min_value=0)
projects_awaiting_evaluation = serializers.IntegerField(min_value=0)
projects_not_submitted = ProjectsNotSubmittedSerializer()
delayed_experts = DelayedExpertsSerializer()


Expand Down
17 changes: 17 additions & 0 deletions partner_programs/serializers/attention.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,23 @@ def get_avatar(self, user):
return user.avatar or None


class ProgramNotSubmittedProjectSerializer(serializers.Serializer):
"""Минимальный read-only контракт несданной связи, без приватных данных."""

program_project_id = serializers.IntegerField(source="pk")
project = AssignmentProjectSerializer()
leader = ProgramAttentionLeaderSerializer(source="project.leader", allow_null=True)
linked_at = serializers.DateTimeField(source="datetime_created")


class ProgramNotSubmittedMetadataSerializer(serializers.Serializer):
"""Срок и доступность сдачи из существующих методов программы, без нового SLA."""

applicable = serializers.BooleanField()
submission_deadline = serializers.DateTimeField(allow_null=True)
submission_open = serializers.BooleanField()


WAITING_REASONS = {
"no_assignments": "Эксперты не назначены",
"no_completed_evaluations": "Нет завершённых оценок",
Expand Down
37 changes: 35 additions & 2 deletions partner_programs/services/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,35 @@ def _get_participant_regions(program_id: int) -> list[dict]:
)


def _not_submitted_filter():
"""Единый признак несданной связи проекта для воронки, счётчика и списка."""
return Q(submitted=False)


def projects_not_submitted_rows(program):
"""Несданные связи соревновательной программы, независимо от свойств проекта.

Несоревновательная программа не требует сдачи: список для неё неприменим.
Выбираются только публичные поля руководителя; сериализация не делает SQL.
"""
rows = PartnerProgramProject.objects.filter(
_not_submitted_filter(), partner_program_id=program.pk
)
if not program.is_competitive:
return rows.none()
return rows.select_related("project", "project__leader").only(
"id",
"project_id",
"datetime_created",
"project__name",
"project__leader_id",
"project__leader__id",
"project__leader__first_name",
"project__leader__last_name",
"project__leader__avatar",
)


def _solution_rows(program):
"""Общая SQL-классификация работ программы для overview и детализации.

Expand Down Expand Up @@ -202,7 +231,7 @@ def _solution_rows(program):
)
return rows.annotate(
status=Case(
When(submitted=False, then=Value("not_submitted")),
When(_not_submitted_filter(), then=Value("not_submitted")),
default=evaluated_status,
output_field=CharField(),
)
Expand Down Expand Up @@ -237,7 +266,7 @@ def projects_awaiting_evaluation_rows(program):
def _get_solution_metrics(program) -> dict[str, int]:
return _solution_rows(program).aggregate(
created=Count("pk"),
not_submitted=Count("pk", filter=Q(submitted=False)),
not_submitted=Count("pk", filter=_not_submitted_filter()),
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")),
Expand Down Expand Up @@ -349,6 +378,10 @@ def build_program_manager_analytics(program) -> dict:
"attention": {
"participants_without_team": participants["without_team"],
"projects_awaiting_evaluation": projects_awaiting_evaluation,
"projects_not_submitted": {
"applicable": program.is_competitive,
"total": solutions["not_submitted"] if program.is_competitive else 0,
},
"delayed_experts": (
build_delayed_experts(assignment_items)
if program.is_distributed_evaluation
Expand Down
Loading
Loading