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
108 changes: 108 additions & 0 deletions docs/program-manager-analytics-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Аналитика партнёрской программы

## Endpoint и доступ

`GET /programs/<program_id>/manager-overview/`

Endpoint доступен менеджерам указанной программы, staff и superuser. Для
авторизованного пользователя без этих прав возвращается `403`, для неизвестной
программы — `404`.

## Контракт

```json
{
"summary": {
"participants": {"total": 3},
"projects": {"total": 2},
"experts": {"total": 2},
"regions": {
"total": 1,
"items": [{"name": "Москва", "count": 2}]
}
},
"participant_funnel": {
"registrations": 4,
"unique_participants": 3,
"with_team": 2,
"project_creators": 1,
"submitted_project_creators": 1
},
"solution_funnel": {
"created": 2,
"not_submitted": 0,
"submitted": 2,
"evaluated": 1
},
"evaluation_status": {
"mode": "distributed",
"max_evaluations_per_project": 2,
"assignments": {"total": 3, "pending": 1, "evaluated": 2},
"projects": {
"submitted": 2,
"awaiting_evaluation": 0,
"partially_evaluated": 1,
"evaluated": 1
}
},
"attention": {
"participants_without_team": 1,
"projects_awaiting_evaluation": 1
},
"activity": [
{
"date": "2026-08-01",
"registrations": 2,
"submitted_solutions": 1
}
]
}
```

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

- `registrations` — количество регистрационных записей
`PartnerProgramUserProfile`, включая сохранённые записи с удалённым
пользователем.
- `unique_participants` и `summary.participants.total` — уникальные ненулевые
`PartnerProgramUserProfile.user_id`.
- Участник считается состоящим в команде, если он является руководителем либо
`Collaborator` проекта, связанного с программой через
`PartnerProgramProject`. Поле `PartnerProgramUserProfile.project` не
используется как единственный источник состава команды.
- `project_creators` — уникальные зарегистрированные участники, являющиеся
руководителями связанных с программой проектов.
- Решение программы — `PartnerProgramProject`. Состояние сдачи определяется
только его полями `submitted` и `datetime_submitted`.
- `max_evaluations_per_project` возвращает `PartnerProgram.max_project_rates`:
это верхний лимит числа оценивающих экспертов, а не обязательное количество
оценок. Значение может быть `null` и не определяет статус проекта.
- В открытом режиме (`mode=open`) назначения не обязательны. Сданный проект
считается оценённым после первой оценки любого уникального эксперта по
критериям программы; до первой оценки он ожидает оценивания.
- В распределённом режиме (`mode=distributed`) ожидаемые оценки определяются
`ProjectExpertAssignment`. Проект без назначений либо без выполненных
назначений ожидает оценивания; проект с частью выполненных назначений имеет
статус `partially_evaluated`; при выполнении всех назначений — `evaluated`.
- Назначение считается оценённым, если назначенный эксперт сохранил хотя бы
один `ProjectScore` этого проекта по критерию текущей программы.
- В открытом режиме `projects_awaiting_evaluation` включает только сданные
проекты без оценки. В распределённом режиме он включает ожидающие и частично
оценённые проекты.
- `summary.experts.total` — число уникальных `Expert`, состоящих в программе
через `Expert.programs`, независимо от наличия назначений.
- Регионы строятся по непустому `Project.region` связанных проектов. Пробелы по
краям удаляются; `items` сортируется по убыванию количества, затем по имени.
- `activity` всегда содержит последние 30 календарных дней, включая текущий.
Пропущенные даты заполняются нулями. Регистрации группируются по
`PartnerProgramUserProfile.datetime_created`, сдачи — по
`PartnerProgramProject.datetime_submitted`.

## Кейсы

В текущей модели нет отдельной сущности или обязательной связи «кейс».
Произвольные `PartnerProgramField` могут иметь похожее название, но не являются
стабильным системным контрактом. Поэтому `cases` в ответ не добавляется.
Для такой аналитики нужна отдельная модель кейса и явная внешняя связь
`PartnerProgramProject` с выбранным кейсом либо утверждённое системное поле с
гарантированным идентификатором.
10 changes: 9 additions & 1 deletion partner_programs/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
from partner_programs.models import PartnerProgram


def can_manage_program(user, program: PartnerProgram) -> bool:
if not user or not user.is_authenticated:
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
return program.is_manager(user)


class IsProjectLeader(BasePermission):
def has_object_permission(self, request, view, obj):
return obj.project.leader == request.user
Expand Down Expand Up @@ -54,4 +62,4 @@ def has_permission(self, request, view):
except PartnerProgram.DoesNotExist:
return False

return program.is_manager(user)
return can_manage_program(user, program)
2 changes: 2 additions & 0 deletions partner_programs/serializers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .analytics import ProgramManagerAnalyticsSerializer
from .fields import PartnerProgramFieldValueUpdateSerializer
from .programs import (
PartnerProgramBaseSerializerMixin,
Expand Down Expand Up @@ -26,6 +27,7 @@
"PartnerProgramForUnregisteredUserSerializer",
"PartnerProgramListSerializer",
"PartnerProgramMaterialSerializer",
"ProgramManagerAnalyticsSerializer",
"PartnerProgramNewUserSerializer",
"PartnerProgramProjectApplySerializer",
"PartnerProgramUserSerializer",
Expand Down
79 changes: 79 additions & 0 deletions partner_programs/serializers/analytics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from rest_framework import serializers


class AnalyticsTotalSerializer(serializers.Serializer):
total = serializers.IntegerField(min_value=0)


class AnalyticsRegionItemSerializer(serializers.Serializer):
name = serializers.CharField()
count = serializers.IntegerField(min_value=0)


class AnalyticsRegionsSerializer(AnalyticsTotalSerializer):
items = AnalyticsRegionItemSerializer(many=True)


class ProgramAnalyticsSummarySerializer(serializers.Serializer):
participants = AnalyticsTotalSerializer()
projects = AnalyticsTotalSerializer()
experts = AnalyticsTotalSerializer()
regions = AnalyticsRegionsSerializer()


class ProgramParticipantFunnelSerializer(serializers.Serializer):
registrations = serializers.IntegerField(min_value=0)
unique_participants = serializers.IntegerField(min_value=0)
with_team = serializers.IntegerField(min_value=0)
project_creators = serializers.IntegerField(min_value=0)
submitted_project_creators = serializers.IntegerField(min_value=0)


class ProgramSolutionFunnelSerializer(serializers.Serializer):
created = serializers.IntegerField(min_value=0)
not_submitted = serializers.IntegerField(min_value=0)
submitted = serializers.IntegerField(min_value=0)
evaluated = serializers.IntegerField(min_value=0)


class ProgramAssignmentEvaluationSerializer(serializers.Serializer):
total = serializers.IntegerField(min_value=0)
pending = serializers.IntegerField(min_value=0)
evaluated = serializers.IntegerField(min_value=0)


class ProgramProjectEvaluationSerializer(serializers.Serializer):
submitted = serializers.IntegerField(min_value=0)
awaiting_evaluation = serializers.IntegerField(min_value=0)
partially_evaluated = serializers.IntegerField(min_value=0)
evaluated = serializers.IntegerField(min_value=0)


class ProgramEvaluationStatusSerializer(serializers.Serializer):
mode = serializers.ChoiceField(choices=("open", "distributed"))
max_evaluations_per_project = serializers.IntegerField(
min_value=1,
allow_null=True,
)
assignments = ProgramAssignmentEvaluationSerializer()
projects = ProgramProjectEvaluationSerializer()


class ProgramAttentionSerializer(serializers.Serializer):
participants_without_team = serializers.IntegerField(min_value=0)
projects_awaiting_evaluation = serializers.IntegerField(min_value=0)


class ProgramActivityItemSerializer(serializers.Serializer):
date = serializers.DateField()
registrations = serializers.IntegerField(min_value=0)
submitted_solutions = serializers.IntegerField(min_value=0)


class ProgramManagerAnalyticsSerializer(serializers.Serializer):
summary = ProgramAnalyticsSummarySerializer()
participant_funnel = ProgramParticipantFunnelSerializer()
solution_funnel = ProgramSolutionFunnelSerializer()
evaluation_status = ProgramEvaluationStatusSerializer()
attention = ProgramAttentionSerializer()
activity = ProgramActivityItemSerializer(many=True)
2 changes: 2 additions & 0 deletions partner_programs/services/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from partner_programs.services.analytics import build_program_manager_analytics
from partner_programs.services.exports import (
BASE_COLUMNS,
ProgramExportFile,
Expand Down Expand Up @@ -28,6 +29,7 @@
)

__all__ = [
"build_program_manager_analytics",
"BASE_COLUMNS",
"ProgramExportFile",
"ProgramProjectAlreadyApplied",
Expand Down
Loading
Loading