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
122 changes: 122 additions & 0 deletions docs/analytics-case-drilldown-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# Analytics → Case drilldown: production API

## Основание и целевая ветка

Выборочный перенос принятого на DEV PR #760 в `master`.
Production base после fetch: `7b2918ed3d4914e50a5a5fc684706044ba7f5c06`.

Реализация сопоставлена с сохранённым вариантом для master `608bf09` и финальным DEV `2603312`.
Сохранены production-модули `project_case_analytics.py`, `serializers/project_analytics_attention.py`
и `ProjectAnalyticsAttentionPagination`. Сводка и attention остаются на `/project-analytics/`.
Из DEV перенесено усиленное независимое assertion заголовков XLSX.

Модели, endpoints и миграции не добавлялись. Прежние контракты и permissions сохранены;
прочие DEV-функции не переносились.
Кнопка общей выгрузки в Angular Cases удалена по PR #390; API `case_scope=all` сохранён.

## Запросы

```text
GET /programs/{id}/projects/?view=case_analytics&case_scope=selected&case_name={encodedExactOption}&limit=25&offset=0&search={query}
GET /programs/{id}/projects/?view=case_analytics&case_scope=without_case
GET /programs/{id}/projects/?view=case_analytics&case_scope=all

GET /programs/{id}/export-projects/?view=case_analytics&case_scope=selected&case_name={encodedExactOption}
GET /programs/{id}/export-projects/?view=case_analytics&case_scope=without_case
GET /programs/{id}/export-projects/?view=case_analytics&case_scope=all
```

- Case_scope обязателен; case_name обязателен только для selected, в других scopes запрещён.
Имя — точный текущий option, без trim и изменения регистра.
- Неправильные/повторные/неизвестные параметры, selector без правильного view, неизвестный option,
пустые/невалидные числа дают 400. Fallback на all отсутствует.
- List: limit по умолчанию 25, диапазон 1–100; offset по умолчанию 0, минимум 0.
Search ищет подстроку в имени проекта без учёта регистра; внешние пробелы удаляются.
Порядок: `datetime_created ASC, pk ASC` связи.
- Export не принимает search, limit, offset, only_submitted, включая пустые значения.
Выгружается полный bucket независимо от поиска и страницы.
- Без параметров нового режима прежние serializer, pagination и XLSX, включая only_submitted,
сохраняются. Generic filter и rates export не затронуты.

## Ответ списка

Поля страницы: count, next, previous, results, selection, cases_configured,
submission_applicable, case_metrics. Count учитывает search; case_metrics описывают весь bucket:
projects_total, participants_total, submitted, not_submitted. Для all case_metrics = null.
Selection = `{scope, case_name}`; имя вне selected — null.

Пример строки (данные условные):

```json
{
"program_project_id": 421,
"project": {
"id": 81,
"name": "Название проекта",
"presentation_address": "https://example.org/presentation.pdf",
"region": "Москва"
},
"case": {"kind": "selected", "name": "Точный option"},
"leader": {"user_id": 15, "full_name": "Имя Фамилия"},
"team_size": 3,
"linked_at": "2026-09-01T10:00:00Z",
"submitted": true,
"submitted_at": "2026-09-20T10:00:00Z"
}
```

Without_case: `{kind: "without_case", name: null}`. Презентация/регион nullable и могут быть пустыми.
Отсутствующий leader — null. Для несданной связи/отсутствующей исторической даты submitted_at — null.
Презентация берётся исключительно из `Project.presentation_address`.

## Семантика, права и запросы

Общий `project_case_links()` в `services/project_case_analytics.py` обслуживает overview/list/export.
Он использует field и links выбранной программы. Missing, blank, whitespace, obsolete и nonexact
попадают в without_case. Настоящий option «Без выбранного кейса» остаётся selected.
Без definition/options все существующие связи попадают в without_case; synthetic rows не создаются.
Cases_configured означает наличие field, как в прежнем overview.

Project/leader загружаются через select_related; team_size — коррелированный aggregate.
Сохраняется legacy формула `1 + число строк Collaborator`, включая сохранённую строку лидера;
она не равна числу зарегистрированных уникальных участников bucket. ProjectDetail fetch и N+1 отсутствуют.

Server-side `IsAdminOrManagerOfProgram` и прежняя дополнительная проверка export сохранены.
Anonymous — 401; participant, leader/team member, expert-only, outsider, foreign manager — 403.
Manager текущей программы и staff/superuser сохраняют API-доступ.
Несуществующая программа: 403 обычному пользователю, 404 staff/superuser, по прежнему порядку
permission/lookup этих endpoints. UI gate staff/superuser не расширяется; контакты/auth-поля не выдаются.

## XLSX

Лист «Проекты». Порядок колонок: №; Название проекта; Кейс; Ссылка на презентацию; Лидер;
Регион; Размер команды; Сдача решения; Дата привязки к программе; Дата сдачи решения.

Without_case отображается как «Без выбранного кейса». Полный URL — literal text, без HYPERLINK.
Пользовательские строки имеют явный string cell type: =, +, -, @ и #N/A остаются текстом.
Прежняя очистка управляющих символов/длины Excel сохранена. Даты — ISO 8601 с timezone, null — пустая ячейка.
Noncompetitive — «Не требуется», raw submitted не меняется.
Если проекты исчезли к экспорту, результат — header-only workbook.

Base name: `projects_{all_cases | without_case | case - NAME} - PROGRAM - DD.MM.YY`.
Content-Disposition использует прежний NFKD sanitizer и UTF-8 filename*.
Angular повторяет этот формат, используя название программы и UTC-дату.
Legacy workbook builders не изменены.

## Production rollout и smoke-check

1. Пользователь мержит backend PR в `master`.
2. Пользователь публикует production release или запускает существующий **Release Prod**
с соответствующими `image_tag` / `deploy_ref`.
3. Проверяет доступность API/admin и здоровье Celery, затем manager selected/without_case list,
отсутствие доступа у участника/менеджера другой программы, presentation URL/null и scoped XLSX.
4. После успешного API smoke-check пользователь мержит отдельный Angular production PR;
push в Angular `master` запускает существующий frontend deploy workflow.
5. Проверяет Team, drilldown, отсутствие общей кнопки в Cases, поиск/страницы/презентацию/XLSX,
mobile и lifecycle карточек.

List и export выпускаются одной backend-версией. Новая версия совместима со старым Angular.
Angular проверяет DTO/scope и не подменяет case export старой общей выгрузкой.
Feature не записывает данные; rollback схемы не требуется. При откате backend после Angular
сначала откатить Angular либо принять временную недоступность case drilldown.
Release/deploy и проверку живой production-среды выполняет пользователь.
89 changes: 89 additions & 0 deletions partner_programs/serializers/project_case_drilldown.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Opt-in контракт менеджера, отдельный от прежнего сериализатора проектов."""

from rest_framework import serializers

from .project_analytics_attention import participant_name


def case_analytics_requested(params):
if not {"view", "case_scope", "case_name"}.intersection(params):
return False
if params.get("view") != "case_analytics":
raise serializers.ValidationError({"view": "Укажите view=case_analytics."})
return True


class ProjectCaseQuerySerializer(serializers.Serializer):
view = serializers.ChoiceField(choices=("case_analytics",))
case_scope = serializers.ChoiceField(choices=("all", "selected", "without_case"))
case_name = serializers.CharField(required=False, trim_whitespace=False)

def to_internal_value(self, data):
errors = {
key: "Параметр не поддерживается в этом режиме."
for key in set(data) - set(self.fields)
}
if hasattr(data, "getlist"):
for key in data:
if len(data.getlist(key)) != 1:
errors[key] = "Укажите параметр только один раз."
if errors:
raise serializers.ValidationError(errors)
# DRF считает QueryDict HTML-формой: пустой integer иначе становится
# отсутствующим полем и незаметно получает default.
return super().to_internal_value({key: data[key] for key in data})

def validate(self, attrs):
if attrs["case_scope"] == "selected":
if attrs.get("case_name") not in self.context["case_options"]:
raise serializers.ValidationError(
{"case_name": "Выберите точный текущий вариант кейса."}
)
elif "case_name" in attrs:
raise serializers.ValidationError(
{"case_name": "Название допустимо только для case_scope=selected."}
)
return attrs


class ProjectCaseListQuerySerializer(ProjectCaseQuerySerializer):
limit = serializers.IntegerField(min_value=1, max_value=100, default=25)
offset = serializers.IntegerField(min_value=0, default=0)
search = serializers.CharField(allow_blank=True, default="", trim_whitespace=True)


class CaseProjectSerializer(serializers.Serializer):
id = serializers.IntegerField()
name = serializers.CharField()
presentation_address = serializers.CharField(allow_null=True, allow_blank=True)
region = serializers.CharField(allow_null=True, allow_blank=True)


class CaseLeaderSerializer(serializers.Serializer):
user_id = serializers.IntegerField(source="pk")
full_name = serializers.SerializerMethodField()

def get_full_name(self, user):
return participant_name(user.pk, user.first_name, user.last_name)


class ProjectCaseRowSerializer(serializers.Serializer):
program_project_id = serializers.IntegerField(source="pk")
project = CaseProjectSerializer()
case = serializers.SerializerMethodField()
leader = CaseLeaderSerializer(source="project.leader", allow_null=True)
team_size = serializers.IntegerField()
linked_at = serializers.DateTimeField(source="datetime_created")
submitted = serializers.BooleanField()
submitted_at = serializers.SerializerMethodField()

def get_case(self, link):
return {
"kind": "selected" if link.case_name is not None else "without_case",
"name": link.case_name,
}

def get_submitted_at(self, link):
if not link.submitted or link.datetime_submitted is None:
return None
return serializers.DateTimeField().to_representation(link.datetime_submitted)
70 changes: 70 additions & 0 deletions partner_programs/services/exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.db.models import Prefetch
from django.utils import timezone
from openpyxl import Workbook
from openpyxl.cell import WriteOnlyCell

from core.utils import XlsxFileToExport, sanitize_excel_value
from partner_programs.models import (
Expand All @@ -15,6 +16,8 @@
PartnerProgramProject,
PartnerProgramUserProfile,
)
from partner_programs.serializers.project_analytics_attention import participant_name
from partner_programs.services.project_case_drilldown import case_project_rows
from project_rates.models import Criteria, ProjectScore
from projects.models import Collaborator

Expand Down Expand Up @@ -340,6 +343,73 @@ def build_program_project_scores_export_file(
)


CASE_PROJECT_COLUMNS = [
"№",
"Название проекта",
"Кейс",
"Ссылка на презентацию",
"Лидер",
"Регион",
"Размер команды",
"Сдача решения",
"Дата привязки к программе",
"Дата сдачи решения",
]


def _literal_excel_cell(worksheet, value):
value = sanitize_excel_value(value)
cell = WriteOnlyCell(worksheet, value=value)
if isinstance(value, str):
# openpyxl распознаёт '=' / '#N/A' как формулу/ошибку.
# Сохраняем очищенный текст буквально, без апострофа и вычислений.
cell.data_type = "s"
return cell


def build_case_projects_export_file(*, program, field, selection) -> ProgramExportFile:
"""Выгружаем весь bucket; поиск и пагинация не входят в этот контракт."""
workbook = Workbook(write_only=True)
worksheet = workbook.create_sheet(title="Проекты")
worksheet.append(CASE_PROJECT_COLUMNS)
links = case_project_rows(program, field=field, selection=selection)
for number, link in enumerate(links.iterator(chunk_size=1000), start=1):
project = link.project
leader = project.leader
submitted_at = link.datetime_submitted if link.submitted else None
values = [
number,
project.name,
link.case_name if link.case_name is not None else "Без выбранного кейса",
project.presentation_address,
participant_name(leader.pk, leader.first_name, leader.last_name)
if leader
else "",
project.region,
link.team_size,
("Сдано" if link.submitted else "Не сдано")
if program.is_competitive
else "Не требуется",
link.datetime_created.isoformat(),
submitted_at.isoformat() if submitted_at else "",
]
worksheet.append([_literal_excel_cell(worksheet, value) for value in values])

buffer = io.BytesIO()
workbook.save(buffer)
scope = selection["case_scope"]
scope_label = {
"all": "all_cases",
"without_case": "without_case",
"selected": f"case - {selection.get('case_name', '')}",
}[scope]
date_suffix = timezone.now().strftime("%d.%m.%y")
return ProgramExportFile(
binary_data=buffer.getvalue(),
base_name=f"projects_{scope_label} - {program.name or 'program'} - {date_suffix}",
)


def prepare_project_scores_export_data(program_id: int) -> list[dict]:
"""
Готовит данные для выгрузки оценок проектов.
Expand Down
53 changes: 37 additions & 16 deletions partner_programs/services/project_case_analytics.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Read-only case analytics for legacy Project links in one program."""

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

from partner_programs.models import (
PartnerProgramFieldValue,
Expand All @@ -19,20 +19,16 @@ def _empty_metrics():
}


def build_project_case_analytics(program) -> dict:
"""Group current-program links by exact current system case options.
_UNSET = object()

Four bounded SELECTs load the case definition, grouped link counts,
registered leaders and registered collaborators. Every link belongs to one
project bucket; participants are unique inside each bucket.

def project_case_links(program, *, field):
"""Классифицируем связь Project × Program по точным текущим options.

NULL обозначает without_case, включая отсутствующие, пустые и устаревшие
значения. Сводка, список и экспорт используют одно выражение без записи в БД.
"""
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()

case_value = (
Subquery(
PartnerProgramFieldValue.objects.filter(
Expand All @@ -42,13 +38,38 @@ def build_project_case_analytics(program) -> dict:
if field
else Value(None, output_field=TextField())
)
links = (
return (
PartnerProgramProject.objects.filter(partner_program_id=program.pk)
.order_by()
.annotate(case_value=case_value)
.annotate(
case_name=Case(
When(case_value__in=options, then=F("case_value")),
default=Value(None),
output_field=TextField(),
)
)
)
for row in links.values("case_value", "submitted").annotate(total=Count("pk")):
metrics = buckets.get(row["case_value"], without_case)


def build_project_case_analytics(program, *, field=_UNSET) -> dict:
"""Group current-program links by exact current system case options.

Four bounded SELECTs load the case definition, grouped link counts,
registered leaders and registered collaborators. Every link belongs to one
project bucket; participants are unique inside each bucket.
"""
if field is _UNSET:
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()

links = project_case_links(program, field=field)
for row in links.values("case_name", "submitted").annotate(total=Count("pk")):
metrics = buckets.get(row["case_name"], without_case)
metrics["projects_total"] += row["total"]
metrics["submitted" if row["submitted"] else "not_submitted"] += row["total"]

Expand All @@ -58,7 +79,7 @@ def build_project_case_analytics(program) -> dict:
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)
.values_list("case_name", user_path)
.distinct()
)
for value, user_id in pairs:
Expand Down
Loading
Loading