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
41 changes: 39 additions & 2 deletions docs/program-case-field.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,5 +212,42 @@ Project. Manager/expert/staff не получают право записи. Ч
критерии и scoring не изменены. Тесты проверяют A, B, A+B и независимые значения
одного Project в разных программах.

Analytics по кейсам, отдельная модель кейса, автоматическое исправление старых
данных и frontend — следующие отдельные этапы, не часть этого PR.
## Аналитика проектов по системному кейсу

Существующий `GET /programs/{programId}/manager-overview/` дополнен top-level
блоком `cases` (полный контракт — в [analytics docs](program-manager-analytics-api.md#кейсы)).
Новый endpoint или drilldown не добавляются; права manager/staff/superuser прежние.

- `configured` — наличие definition с exact `name=PROGRAM_CASE_FIELD_NAME` (`case`).
Label, тип, show_filter и похожие имена (`Case`, `cases`, `case_name`, `кейс`)
не определяют системный кейс.
- `items` содержит **все текущие options**, включая нулевые строки, в порядке
конфигурации. Сохранённое значение сравнивается с option точно, без исправления
опечаток, изменения регистра или trim выбранного value.
- `projects_total` считает связи Project × Program именно этой программы;
`submitted` / `not_submitted` используют только флаг связи `submitted`.
- `without_case` содержит отсутствующий/пустой/null результат выбора и значения
вне актуальных options. При отсутствии definition все связи попадают сюда,
`configured=false`, `items=[]`. Исторические данные не изменяются и не теряются.
`value_text` сейчас NOT NULL; null результата также возникает при отсутствии
строки значения в коррелированном подзапросе.
- `participants_total` — уникальные зарегистрированные в этой программе
пользователи, являющиеся лидером или Collaborator хотя бы одного проекта
bucket. Orphan profiles, непринадлежащие программе пользователи, приглашения
без членства и одно лишь поле project в анкете не считаются. Лидер учитывается
без Collaborator; дубли ролей/проектов внутри bucket не увеличивают счётчик.
Один пользователь может входить в разные cases и without_case: сумму этих
значений **нельзя считать глобальным числом уникальных участников**.
- `submission_applicable=program.is_competitive`. Это metadata UI; даже при false
счётчики сохраняют raw flags, `projects_total = submitted + not_submitted`.

Суммы `items + without_case` по projects_total/submitted/not_submitted точно
соответствуют solution_funnel.created/submitted/not_submitted. Helper делает
четыре фиксированных SQL-запроса независимо от числа options/связей: definition,
сгруппированные счётчики, пары лидеров и пары collaborators. Регистрации проверяются
SQL IN subquery; множества user_id объединяются внутри bucket. Для 1 и 20 options
число запросов одинаково; сериализаторы SQL не выполняют.

Analytics только читает существующие данные. Case validation, option protection,
canonical GET/PUT, submission, admin и остальные поля overview не меняются.
Отдельная Case model, миграции, export/drilldown по кейсам и frontend не добавляются.
70 changes: 61 additions & 9 deletions docs/program-manager-analytics-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ Endpoint доступен менеджерам указанной програм
"submitted": 2,
"evaluated": 1
},
"cases": {
"configured": true,
"submission_applicable": true,
"items": [
{"name": "Кейс A", "participants_total": 2, "projects_total": 2, "not_submitted": 0, "submitted": 2},
{"name": "Кейс B", "participants_total": 0, "projects_total": 0, "not_submitted": 0, "submitted": 0}
],
"without_case": {"participants_total": 0, "projects_total": 0, "not_submitted": 0, "submitted": 0}
},
"evaluation_status": {
"mode": "distributed",
"max_evaluations_per_project": 2,
Expand Down Expand Up @@ -118,12 +127,55 @@ Endpoint доступен менеджерам указанной програм

## Кейсы

В текущей модели нет отдельной сущности или обязательной связи «кейс».
Произвольные `PartnerProgramField` могут иметь похожее название, но не являются
стабильным системным контрактом. Поэтому `cases` в ответ не добавляется.
Для такой аналитики нужна отдельная модель кейса и явная внешняя связь
`PartnerProgramProject` с выбранным кейсом либо утверждённое системное поле с
гарантированным идентификатором.
Top-level `cases` — аддитивный блок существующего manager overview, без нового
endpoint и без изменений разрешений. Источник — [системное поле](program-case-field.md)
с exact `name=PROGRAM_CASE_FIELD_NAME` (`case`). Другие select/label/имена не
используются как эвристика.

`configured` сообщает наличие definition. `items` следует текущему порядку
`get_options_list()` и включает options с нулём проектов. Сравнение сохранённого
`PartnerProgramFieldValue.value_text` с option точное. Значения относятся только
к конкретной связи и definition текущей программы, не к Project глобально.

В каждом item и `without_case`:

- `projects_total` — число связей `PartnerProgramProject`;
- `submitted`, `not_submitted` — разбиение по raw boolean `submitted` связи,
независимо от draft/public проекта и datetime_submitted;
- `participants_total` — число уникальных ненулевых user_id зарегистрированных
`PartnerProgramUserProfile` этой программы, состоящих в команде через leader
или Collaborator. Приглашения и одно лишь project в регистрационной анкете
членства не образуют. Пользователи других программ без регистрации здесь
исключены. Лидер и collaborator, а также несколько проектов одного bucket,
не дублируют пользователя. Один человек может учитываться в нескольких buckets;
сумма participants_total не обязана равняться summary.participants.total.

`without_case` собирает отсутствующее/null/пустое значение и устаревшие значения
вне options. При отсутствии system field: `configured=false`, `items=[]`, все
связи и соответствующие участники остаются в `without_case`. Похожий generic
select не используется вместо case. Никакие исторические данные не исправляются.

`submission_applicable = program.is_competitive` — подсказка для UI. При false
счётчики всё равно отражают реальные submitted flags; не создаются искусственные
сдачи. Для каждого bucket `projects_total = submitted + not_submitted`.

Инварианты для любой программы, в том числе без definition:

```text
sum(items.projects_total) + without_case.projects_total = solution_funnel.created
sum(items.submitted) + without_case.submitted = solution_funnel.submitted
sum(items.not_submitted) + without_case.not_submitted = solution_funnel.not_submitted
```

`build_case_analytics(program)` добавляет **4 SQL-запроса**, независимо от количества
options: definition, grouped counts, distinct case/user пары лидеров и collaborators.
Фильтрация зарегистрированных пользователей — IN subquery без размножения строк.
Сериализаторы явные, все счётчики IntegerField(min_value=0), без SQL. Тест сравнивает
1 и 20 options (в том числе HTTP overview); общий бюджет manager overview увеличен
с 10 до 14 запросов, прежняя проверка независимости от числа проектов сохранена.

Новых моделей, миграций, endpoint/drilldown/export по кейсам нет. Остальные поля
overview, scoring, team/invite rules, фильтры, сохранение fields и submission не меняются.

## Статусы назначений и проектов

Expand Down Expand Up @@ -329,7 +381,7 @@ SQL SELECT: связанные таблицы через JOIN, число кри
Score drilldown добавляет два фиксированных запроса (критерии и оценки пары).

Regression query budget для manager с уже аутентифицированным request.user:
список — 3 SQL, overview — 10 SQL, scores — 5 SQL; не растёт при переходе
список — 3 SQL, overview с cases — 14 SQL, scores — 5 SQL; не растёт при переходе
от 1 к 31 назначению. JWT/session-аутентификация может добавить свои запросы.
Проверяются SQLite и PostgreSQL; новых моделей, индексов и миграций нет.

Expand Down Expand Up @@ -496,7 +548,7 @@ SerializerMethodField форматирует только уже загруже
Regression-бюджет для manager с уже аутентифицированным `request.user`: не более
4 SQL на непустую страницу (программа, проверка manager, count, строки страницы).
Он проверяется при росте каждого списка с 1 до 31 строки, для работ — также
с ростом назначений. Счётчик сводки сохраняет прежний бюджет не более 10 SQL.
с ростом назначений. Overview с блоком cases использует не более 14 SQL.
Запросы JWT/session-аутентификации могут добавляться отдельно. Проверка
кириллического регистронезависимого поиска выполняется на PostgreSQL: SQLite
по умолчанию не поддерживает эквивалентный Unicode case-fold.
Expand Down Expand Up @@ -622,7 +674,7 @@ email, описанию и приватным данным поиск не вы
набором полей. Сериализация уже выбранных строк выполняет **0 SQL**. Regression
проверяет одинаковое число SQL при росте с 1 до 31 строки с разными руководителями:
не более 4 запросов для аутентифицированного manager, как у списков #725.
Overview сохраняет бюджет не более 10 SQL и прежние контракты assignments,
Overview с cases использует не более 14 SQL и сохраняет прежние контракты assignments,
scores, delayed_experts и двух существующих списков внимания.

Кейсы, сообщения, напоминания и изменение scoring/submission lifecycle не входят
Expand Down
19 changes: 19 additions & 0 deletions partner_programs/serializers/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,29 @@ class ProgramActivityItemSerializer(serializers.Serializer):
submitted_solutions = serializers.IntegerField(min_value=0)


class ProgramCaseAnalyticsMetricsSerializer(serializers.Serializer):
participants_total = serializers.IntegerField(min_value=0)
projects_total = serializers.IntegerField(min_value=0)
not_submitted = serializers.IntegerField(min_value=0)
submitted = serializers.IntegerField(min_value=0)


class ProgramCaseAnalyticsItemSerializer(ProgramCaseAnalyticsMetricsSerializer):
name = serializers.CharField(trim_whitespace=False)


class ProgramCaseAnalyticsSerializer(serializers.Serializer):
configured = serializers.BooleanField()
submission_applicable = serializers.BooleanField()
items = ProgramCaseAnalyticsItemSerializer(many=True)
without_case = ProgramCaseAnalyticsMetricsSerializer()


class ProgramManagerAnalyticsSerializer(serializers.Serializer):
summary = ProgramAnalyticsSummarySerializer()
participant_funnel = ProgramParticipantFunnelSerializer()
solution_funnel = ProgramSolutionFunnelSerializer()
evaluation_status = ProgramEvaluationStatusSerializer()
attention = ProgramAttentionSerializer()
activity = ProgramActivityItemSerializer(many=True)
cases = ProgramCaseAnalyticsSerializer()
2 changes: 2 additions & 0 deletions partner_programs/services/analytics.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
build_assignments,
build_delayed_experts,
)
from partner_programs.services.case_analytics import build_case_analytics
from project_rates.models import ProjectScore

ACTIVITY_DAYS = 30
Expand Down Expand Up @@ -389,4 +390,5 @@ def build_program_manager_analytics(program) -> dict:
),
},
"activity": _get_activity(program_id),
"cases": build_case_analytics(program),
}
81 changes: 81 additions & 0 deletions partner_programs/services/case_analytics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Read-only case analytics on existing Project × Program relations."""

from django.db.models import Count, OuterRef, Subquery, TextField, Value

from partner_programs.models import (
PartnerProgramFieldValue,
PartnerProgramProject,
PartnerProgramUserProfile,
)
from partner_programs.services.case_fields import get_program_case_field


def _empty_metrics():
return {
"participants_total": 0,
"projects_total": 0,
"not_submitted": 0,
"submitted": 0,
}


def build_case_analytics(program) -> dict:
"""Count program links by exact current options of the system field name='case'.

Missing/empty/obsolete choices share without_case; every link is counted once,
using only its submitted flag, including in noncompetitive programs. Options
retain configuration order and zero rows. Team participants are leaders or
collaborators registered in THIS program, unique within each bucket. A user
may appear in several cases, so participant totals are not globally additive.
Four SQL queries (definition, link counts, leader pairs, collaborator pairs),
independent of option count; serializers perform no queries.
"""
field = get_program_case_field(program)
options = field.get_options_list() if field else []
buckets = {name: _empty_metrics() for name in options}
without_case = _empty_metrics()
participant_ids = {name: set() for name in buckets}
participant_ids[None] = set()

choice = (
Subquery(
PartnerProgramFieldValue.objects.filter(
program_project_id=OuterRef("pk"), field_id=field.pk
).values("value_text")[:1]
)
if field
else Value(None, output_field=TextField())
)
links = (
PartnerProgramProject.objects.filter(partner_program_id=program.pk)
.order_by()
.annotate(case_value=choice)
)
for row in links.values("case_value", "submitted").annotate(total=Count("pk")):
metrics = buckets.get(row["case_value"], without_case)
metrics["projects_total"] += row["total"]
metrics["submitted" if row["submitted"] else "not_submitted"] += row["total"]

registered_users = PartnerProgramUserProfile.objects.filter(
partner_program_id=program.pk, user_id__isnull=False
).values("user_id")
# IN subqueries do not multiply memberships if legacy profiles are duplicated.
for user_path in ("project__leader_id", "project__collaborator__user_id"):
pairs = (
links.filter(**{f"{user_path}__in": registered_users})
.values_list("case_value", user_path)
.distinct()
)
for case_value, user_id in pairs:
bucket = case_value if case_value in buckets else None
participant_ids[bucket].add(user_id)

for name, metrics in buckets.items():
metrics["participants_total"] = len(participant_ids[name])
without_case["participants_total"] = len(participant_ids[None])
return {
"configured": field is not None,
"submission_applicable": program.is_competitive,
"items": [{"name": name, **metrics} for name, metrics in buckets.items()],
"without_case": without_case,
}
3 changes: 2 additions & 1 deletion partner_programs/tests/test_assignment_analytics_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,8 @@ def test_query_counts_are_constant_for_one_and_many_assignments(self):
with CaptureQueriesContext(connection) as queries:
self.get(url)
counts.append(len(queries))
self.assertEqual(counts, [3, 10, 5])
# Only overview grows by the four fixed case-analytics queries.
self.assertEqual(counts, [3, 14, 5])
for _ in range(30):
expert = create_rate_expert(program=self.program)
self.score(self.assignment(expert=expert, hours=49), 1)
Expand Down
Loading
Loading