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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 178 additions & 2 deletions docs/program-manager-analytics-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 не поддерживаются.

## Контракт
Expand Down Expand Up @@ -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/<read-only-drilldown>/`.

| Список | Единица строки и источник | Общая логика со сводкой |
| --- | --- | --- |
| Участники без команды | Уникальный ненулевой `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/<program_id>/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/<program_id>/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 отсутствуют.
18 changes: 18 additions & 0 deletions partner_programs/pagination.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading
Loading