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
218 changes: 214 additions & 4 deletions docs/program-manager-analytics-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

Endpoint доступен менеджерам указанной программы, staff и superuser. Для
авторизованного пользователя без этих прав возвращается `403`, для неизвестной
программы — `404`.
программы — `404`, для anonymous — `401`. Тот же `can_manage_program`
используется обоими read-only drilldown endpoints ниже. Эксперт программы без
прав менеджера доступа не получает. POST/PATCH/PUT/DELETE не поддерживаются.

## Контракт

Expand Down Expand Up @@ -54,7 +56,8 @@ Endpoint доступен менеджерам указанной програм
},
"attention": {
"participants_without_team": 1,
"projects_awaiting_evaluation": 1
"projects_awaiting_evaluation": 1,
"delayed_experts": {"total": 0, "items": []}
},
"activity": [
{
Expand Down Expand Up @@ -91,8 +94,9 @@ Endpoint доступен менеджерам указанной програм
`ProjectExpertAssignment`. Проект без назначений либо без выполненных
назначений ожидает оценивания; проект с частью выполненных назначений имеет
статус `partially_evaluated`; при выполнении всех назначений — `evaluated`.
- Назначение считается оценённым, если назначенный эксперт сохранил хотя бы
один `ProjectScore` этого проекта по критерию текущей программы.
- Назначение считается выполненным только после сдачи проекта и заполнения
**всех критериев текущей программы** назначенным экспертом. Наличие одной
оценки больше не означает завершение назначения (см. статусы ниже).
- В открытом режиме `projects_awaiting_evaluation` включает только сданные
проекты без оценки. В распределённом режиме он включает ожидающие и частично
оценённые проекты.
Expand Down Expand Up @@ -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/<program_id>/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/<program_id>/manager-overview/assignments/<assignment_id>/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; новых моделей, индексов и миграций нет.
69 changes: 69 additions & 0 deletions partner_programs/serializers/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
35 changes: 17 additions & 18 deletions partner_programs/services/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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),
}
Loading
Loading