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
67 changes: 54 additions & 13 deletions docs/program-manager-analytics-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ Endpoint доступен менеджерам указанной програм
}
},
"attention": {
"participants_without_onboarding": 1,
"participants_without_team": 1,
"projects_awaiting_evaluation": 1,
"delayed_experts": {"total": 0, "items": []}
Expand All @@ -81,6 +82,33 @@ Endpoint доступен менеджерам указанной програм

## Семантика метрик

### Требует внимания: условия срабатывания

Все счётчики относятся к текущей программе и обновляются при запросе аналитики.
Пункт доступен для открытия списка при значении больше нуля; нулевые строки
отключены. Если все значения равны нулю, интерфейс пишет «Ничего не требует внимания».

| Пункт интерфейса | Условие |
| --- | --- |
| Участники не завершили онбординг | Есть уникальные зарегистрированные пользователи с `onboarding_stage IS NOT NULL`, включая этап 0. Завершённый онбординг — `NULL`. Подтверждение почты и членство в команде не заменяют онбординг; временного порога нет. |
| Участники без команды | Зарегистрированный пользователь не является ни руководителем, ни `Collaborator` проекта, связанного с этой программой. Команда в другой программе не исключает пользователя; временного порога нет. |
| Работы ожидают оценивания | Только сданные работы. В открытом режиме нет ни одной оценки по критериям программы. В распределённом режиме нет назначений либо хотя бы одно назначение ещё не завершено, включая частично оценённые работы. Временного порога нет. |
| Эксперты задерживают оценивание | Только распределённое оценивание: задана `datetime_evaluation_ends`, наступил момент за 5 суток до неё (`now >= datetime_evaluation_ends - 5 days`), у эксперта есть хотя бы одно назначение со статусом `pending` или `in_progress` на сданную работу. До дедлайна — `warning`, начиная с дедлайна — `critical`; сигнал остаётся до завершения оценок. Время ожидания 24/48 часов не влияет на появление. Несданные работы и завершённые назначения не создают сигнал. |

Новый счётчик `attention.participants_without_onboarding` использует тот же
предикат, что и список
`GET /programs/{id}/manager-overview/participants-without-onboarding/`.
Список поддерживает `search`, `limit` (1–100, по умолчанию 25), `offset`;
возвращает стандартные `count`, `next`, `previous`, `results` с публичными полями
участника. Права, поиск, порядок и пагинация совпадают со списком участников
без команды. Удалённые пользователи и регистрации других программ исключены.
Равенство: `unique_participants = onboarded_participants + participants_without_onboarding`.
Новая миграция БД не требуется.

Существующее API-поле `projects_not_submitted` сохранено для совместимости,
но его строка в «Требует внимания» убрана по требованию не дублировать
показатель, который получается как «Создано − Сдано».

- `registrations` — количество регистрационных записей
`PartnerProgramUserProfile`, включая сохранённые записи с удалённым
пользователем.
Expand Down Expand Up @@ -284,9 +312,10 @@ overview, scoring, team/invite rules, фильтры, сохранение field
Для `not_ready`: `project_submitted_at`, `waiting_since`, `waiting_seconds` —
`null`. Для `completed` оба поля ожидания — `null`, дата сдачи сохранена.
Если у legacy-сданного проекта `datetime_submitted=null`, статус рассчитывается
обычно, но ожидание остаётся `null`: достоверного начала SLA нет. Такая запись
не объявляется просроченной. Дата создания проекта никогда не подставляется.
Будущая дата даёт 0 секунд ожидания и не создаёт просрочку.
обычно, но ожидание остаётся `null`: достоверного начала ожидания нет. Это не
мешает сигналу по дедлайну оценивания. Дата создания проекта никогда не
подставляется. Будущая дата даёт 0 секунд ожидания; длительность ожидания не
определяет появление пункта «Эксперты задерживают оценивание».

## Оценки назначения

Expand Down Expand Up @@ -346,16 +375,25 @@ overview, scoring, team/invite rules, фильтры, сохранение field
Существующие `participants_without_team` и `projects_awaiting_evaluation`
сохраняются. В open-режиме всегда `{"total": 0, "items": []}`.

SLA учитывает только сданные, незавершённые назначения с известным наступившим
`waiting_since`:
Сигнал использует только отдельную дату окончания оценки программы
`datetime_evaluation_ends`. Если она не задана, список пуст: даты окончания
программы, регистрации или подачи проектов не подставляются.

- `warning`: минимум 2 назначения ждут каждое >= 24 часов;
- `critical`: хотя бы 1 назначение ждёт >= 48 часов (имеет приоритет).
- До `datetime_evaluation_ends - timedelta(days=5)` список пуст, независимо
от времени ожидания и числа назначений.
- Начиная ровно за 5 суток до дедлайна эксперт попадает в список, если у него
есть хотя бы одно назначение со статусом `pending` или `in_progress`.
- `warning`: дедлайн ещё не наступил; `critical`: `now >= datetime_evaluation_ends`.
- После дедлайна сигнал сохраняется до завершения всех оценок сданных работ.

Один проект, ожидающий 25 часов, не даёт предупреждение. Выполненные,
несданные, будущие и назначения другой программы не создают просрочку.
Свежие назначения и legacy-сданные работы без даты сдачи учитываются:
известная длительность ожидания не требуется. Выполненные, несданные и
назначения другой программы не создают сигнал.
`assignments_total`, `completed`, `pending` включают все реальные назначения
эксперта текущей программы, включая несданные в `pending`.
`overdue_24h` и `overdue_48h` сохранены для совместимости как сведения о
длительности ожидания; они не определяют включение в список или серьёзность.
Это пункт аналитики; уведомления экспертам не отправляются.

```json
{
Expand All @@ -381,17 +419,20 @@ SLA учитывает только сданные, незавершённые
}
```

Сортировка: critical перед warning, затем большее время ожидания, затем
`expert_id` по возрастанию. Старейшее ожидание берётся среди незавершённых
сданных назначений с известной датой, а не по дате создания проекта.
Сортировка: известное время ожидания перед неизвестным, затем большее время
ожидания, затем `expert_id` по возрастанию. Старейшее ожидание берётся среди
незавершённых сданных назначений с известной датой, а не по дате создания
проекта. При неизвестной дате оба поля `oldest_waiting_*` равны `null`;
нулевое ожидание сохраняется как 0. Серьёзность одинакова для дедлайна программы.

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

Прогресс, дата сдачи и безопасные поля пользователя/проекта выбираются одним
SQL SELECT: связанные таблицы через JOIN, число критериев и DISTINCT-оценок —
через Subquery/Count; связь проекта с программой также через Subquery.
Нет запросов из сериализаторов и отдельных SQL-запросов в цикле назначений.
Сводка использует тот же список для SLA и счётчиков назначений. Статусы работ
Сводка использует тот же список и один момент `now` для сигнала по дедлайну
и счётчиков назначений. Статусы работ
вычисляет общий SQL-queryset сводки и списка ожидающих работ; проверка завершения
назначения общая с assignments. Внешние поля сводки и assignments не меняются.
Score drilldown добавляет два фиксированных запроса (критерии и оценки пары).
Expand Down
5 changes: 3 additions & 2 deletions partner_programs/serializers/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ class DelayedExpertSerializer(AssignmentExpertSerializer):
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)
oldest_waiting_since = serializers.DateTimeField(allow_null=True)
oldest_waiting_seconds = serializers.IntegerField(min_value=0, allow_null=True)
severity = serializers.ChoiceField(choices=("critical", "warning"))


Expand All @@ -136,6 +136,7 @@ class ProjectsNotSubmittedSerializer(AnalyticsTotalSerializer):


class ProgramAttentionSerializer(serializers.Serializer):
participants_without_onboarding = serializers.IntegerField(min_value=0)
participants_without_team = serializers.IntegerField(min_value=0)
projects_awaiting_evaluation = serializers.IntegerField(min_value=0)
projects_not_submitted = ProjectsNotSubmittedSerializer()
Expand Down
40 changes: 35 additions & 5 deletions partner_programs/services/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,29 @@ def participants_without_team_rows(program_id):
Группировка сохраняет одну строку на user_id даже при исторических дублях.
Удалённые пользователи исключены; поиск и пагинация остаются на уровне SQL.
"""
return _unique_participant_rows(
_participant_profiles(program_id).filter(_without_team_filter())
)


def _without_onboarding_filter():
"""Незавершённый онбординг у существующего пользователя программы."""
return Q(user_id__isnull=False, user__onboarding_stage__isnull=False)


def participants_without_onboarding_rows(program_id):
"""Тот же признак незавершённого онбординга, что и в счётчике внимания."""
return _unique_participant_rows(
PartnerProgramUserProfile.objects.filter(partner_program_id=program_id).filter(
_without_onboarding_filter()
)
)


def _unique_participant_rows(profiles):
"""Публичные поля уникального пользователя и его первая регистрация в программе."""
return (
_participant_profiles(program_id)
.filter(_without_team_filter())
.order_by()
profiles.order_by()
.values(
"user_id",
"user__first_name",
Expand Down Expand Up @@ -99,6 +118,11 @@ def _get_participant_metrics(program_id: int) -> dict[str, int]:
filter=participant_filter & Q(user__onboarding_stage__isnull=True),
distinct=True,
),
without_onboarding=Count(
"user_id",
filter=_without_onboarding_filter(),
distinct=True,
),
with_team=Count(
"user_id",
filter=participant_filter & team_filter,
Expand Down Expand Up @@ -331,10 +355,11 @@ def _get_activity(program_id: int) -> list[dict]:

def build_program_manager_analytics(program) -> dict:
program_id = program.id
now = timezone.now()
participants = _get_participant_metrics(program_id)
regions = _get_regions(program_id)
participant_regions = _get_participant_regions(program_id)
assignment_items = build_assignments(program_id)
assignment_items = build_assignments(program_id, now=now)
assignments = _get_assignment_metrics(assignment_items)
solutions = _get_solution_metrics(program)

Expand Down Expand Up @@ -383,14 +408,19 @@ def build_program_manager_analytics(program) -> dict:
},
},
"attention": {
"participants_without_onboarding": participants["without_onboarding"],
"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)
build_delayed_experts(
assignment_items,
evaluation_ends=program.datetime_evaluation_ends,
now=now,
)
if program.is_distributed_evaluation
else {"total": 0, "items": []}
),
Expand Down
31 changes: 19 additions & 12 deletions partner_programs/services/assignment_analytics.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Read-only analytics of real expert assignments; no scoring/lifecycle writes."""

from datetime import timedelta

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
Expand Down Expand Up @@ -104,7 +106,7 @@ def build_assignment(row, *, now):
submitted_at = row["project_submitted_at"] if row["project_submitted"] else None
waiting_since = None
waiting_seconds = None
# Missing historical submission timestamps cannot establish an SLA start.
# Missing historical submission timestamps cannot establish waiting duration.
if status not in ("not_ready", "completed") and submitted_at is not None:
waiting_since = max(submitted_at, row["datetime_created"])
waiting_seconds = max(0, int((now - waiting_since).total_seconds()))
Expand All @@ -131,8 +133,8 @@ def build_assignment(row, *, now):
}


def build_assignments(program_id):
now = timezone.now()
def build_assignments(program_id, *, now=None):
now = now if now is not None else timezone.now()
return [build_assignment(row, now=now) for row in assignment_rows(program_id)]


Expand Down Expand Up @@ -160,8 +162,14 @@ def build_assignment_scores(program_id, assignment):
]


def build_delayed_experts(assignments):
def build_delayed_experts(assignments, *, evaluation_ends, now=None):
"""Flag unfinished submitted work from five days before evaluation closes."""
now = now if now is not None else timezone.now()
if evaluation_ends is None or now < evaluation_ends - timedelta(days=5):
return {"total": 0, "items": []}

experts = {}
unfinished_experts = set()
for assignment in assignments:
expert = assignment["expert"]
item = experts.setdefault(
Expand All @@ -179,12 +187,14 @@ def build_delayed_experts(assignments):
)
item["assignments_total"] += 1
item["completed" if assignment["status"] == "completed" else "pending"] += 1
if assignment["status"] in ("pending", "in_progress"):
unfinished_experts.add(expert["expert_id"])
seconds = assignment["waiting_seconds"]
if seconds is None:
continue
item["overdue_24h"] += int(seconds >= 24 * 3600)
item["overdue_48h"] += int(seconds >= 48 * 3600)
if seconds > 0 and (
if (
item["oldest_waiting_seconds"] is None
or seconds > item["oldest_waiting_seconds"]
):
Expand All @@ -193,17 +203,14 @@ def build_delayed_experts(assignments):

items = []
for item in experts.values():
if item["overdue_48h"] >= 1:
item["severity"] = "critical"
elif item["overdue_24h"] >= 2:
item["severity"] = "warning"
else:
if item["expert_id"] not in unfinished_experts:
continue
item["severity"] = "critical" if now >= evaluation_ends else "warning"
items.append(item)
items.sort(
key=lambda item: (
item["severity"] != "critical",
-item["oldest_waiting_seconds"],
item["oldest_waiting_seconds"] is None,
-(item["oldest_waiting_seconds"] or 0),
item["expert_id"],
)
)
Expand Down
Loading
Loading