From 0ad79b3c5b58f389bd24d4d995e06fb517cfa677 Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 28 Sep 2026 20:21:56 +0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(analytics):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20backend=20=D0=B4=D0=B5=D1=82=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BA=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D0=BE=D0=B2=20=D0=B8=20XLSX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/analytics-case-drilldown-api.md | 190 ++++++ ...lytics-case-drilldown-review-2026-09-28.md | 206 +++++++ .../serializers/project_case_drilldown.py | 89 +++ partner_programs/services/case_analytics.py | 68 ++- partner_programs/services/exports.py | 70 +++ .../services/project_case_drilldown.py | 68 +++ .../tests/test_project_case_drilldown.py | 577 ++++++++++++++++++ partner_programs/views.py | 51 ++ 8 files changed, 1293 insertions(+), 26 deletions(-) create mode 100644 docs/analytics-case-drilldown-api.md create mode 100644 docs/qa/analytics-case-drilldown-review-2026-09-28.md create mode 100644 partner_programs/serializers/project_case_drilldown.py create mode 100644 partner_programs/services/project_case_drilldown.py create mode 100644 partner_programs/tests/test_project_case_drilldown.py diff --git a/docs/analytics-case-drilldown-api.md b/docs/analytics-case-drilldown-api.md new file mode 100644 index 00000000..e29953a4 --- /dev/null +++ b/docs/analytics-case-drilldown-api.md @@ -0,0 +1,190 @@ +# Analytics case drilldown: backend contract and handoff + +## Scope and baseline + +Source: approved `docs/feature-specs/analytics-case-drilldown.md` in the PROCOLLAB +design-system/frontend checkout; Q1–Q4 and Figma section `30:1518` are approved. +This document records the backend implementation, not completion of the UI feature. + +Preflight on 2026-09-28 fetched `origin/master` at +`7b2918ed3d4914e50a5a5fc684706044ba7f5c06`. The implementation branch is +`feature/analytics-case-drilldown-backend`, based on that revision in a separate +checkout. Since the specification's `2fbc7225` reference, master changed expert +admin code/tests only. Relevant models, views, permissions, exporters and tests +were inspected again. Deployed SHA remains unconfirmed. + +No models, migrations, endpoints, frontend, Figma or Design System changes. +No evaluation fields. Existing `IsAdminOrManagerOfProgram` is retained for both +endpoints; staff/superuser API access remains as before, with no UI access changes. + +## Opt-in requests + +```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` is required. `case_name` is required only for `selected` and forbidden + otherwise; it must equal a current option exactly, without trimming/case folding. +- Missing/unknown view with a case selector, unknown/duplicate parameters, invalid + options, blank/invalid numbers or incompatible selectors return 400, never all. +- List: `limit` defaults to 25, range 1–100; `offset` defaults to 0, minimum 0. + `search` trims outer whitespace and matches project name case-insensitively. + Ordering is link `datetime_created ASC, pk ASC`. +- Export rejects `search`, `limit`, `offset`, `only_submitted`, even empty values. + The frontend must omit them: export always contains the complete selected bucket. +- Existing requests without case-mode parameters use the original serializer, + pagination and exports, including `only_submitted`; generic filters/rates are unchanged. + +Page fields: `count`, `next`, `previous`, `results`, `selection`, `cases_configured`, +`submission_applicable`, `case_metrics`. `count` includes search; metrics always +describe the full bucket (`projects_total`, `participants_total`, `submitted`, +`not_submitted`). In `all`, metrics are null; unique participants are not summed. +`selection` is `{scope, case_name}`, with null name outside `selected`. + +Each result contains: + +```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" +} +``` + +Example data only. `without_case` returns `{kind: "without_case", name: null}`. +Presentation/region may be null or blank. Missing leader is handled as null. +For unsent links or historical missing timestamps, `submitted_at` is null. + +## Shared classification and export + +`project_case_links()` in `services/project_case_analytics.py` classifies only +`PartnerProgramProject` links in the requested program, using that program's case +field. Overview/list/export share it. Missing, blank, whitespace, obsolete and +nonexact values all map to `without_case`. A real option named +«Без выбранного кейса» remains a separate selected bucket. No case definition or +no options means all existing links are in `without_case`; no synthetic rows. +`cases_configured` retains overview semantics: field existence, not nonempty options. + +`case_project_rows()` loads project/leader with `select_related` and annotates team +size with a correlated aggregate. No per-row queries or ProjectDetail fetches. +Legacy team-size semantics (`1 + Collaborator rows`, including a stored leader row) +are intentionally retained, distinct from registered unique case participants. + +XLSX sheet «Проекты», columns: + +1. № +2. Название проекта +3. Кейс +4. Ссылка на презентацию +5. Лидер +6. Регион +7. Размер команды +8. Сдача решения +9. Дата привязки к программе +10. Дата сдачи решения + +Full `Project.presentation_address` is stored as literal text; no hyperlink formula. +All user text gets explicit string cell type, including `=`, `+`, `-`, `@`, `#N/A`. +Existing control-character/Excel-length sanitization is reused. Dates are ISO 8601 +with timezone; missing values are blank. Noncompetitive status is «Не требуется», +without changing raw flags. Empty buckets produce a workbook with headers only. +Filename distinguishes all/selected/without_case and uses the existing safe +Content-Disposition helper. Legacy workbook builders are unchanged. + +Anonymous requests return 401; participant, project leader/team member, expert-only, +outsider and foreign manager return 403. Program manager and staff/superuser retain +access. Missing program returns 403 to ordinary users and 404 to staff/superuser, +following existing endpoint lookup/permission order. No private user serialization. + +## Requirement audit + +PASS below means the backend portion only. UI acceptance remains unverified. + +| AC | Verdict and evidence | +| --- | --- | +| AC01 | Angular pending: click/keyboard/modal/title. | +| AC02 | PASS: classifier parity and pagination fixtures compare full bucket counts and scoped links. | +| AC03 | Backend PASS: project/presentation/case projection and nulls; UI columns/placeholder pending. | +| AC04 | Backend PASS: safe leader/team/region/submission projection; 100-row serializer makes zero queries; UI pending. | +| AC05 | PASS: missing/blank/obsolete/nonexact/literal-label overview/list/export parity. | +| AC06 | Backend PASS: no definition/options and empty program; Angular visibility pending. | +| AC07 | PASS: all XLSX includes all 32 fixture links once with Case and presentation. | +| AC08 | PASS: 30-row selected export includes every page; search is rejected by export; without_case parity. | +| AC09 | Backend PASS: legacy payload/pagination/all/submitted exports and 88 existing regression tests; frontend unmodified. | +| AC10 | Backend PASS: stable ordering, URL encoding, search/count/pagination; Enter/reset/retry pending in Angular. | +| AC11 | PASS: direct API permission matrix, missing-program and read-only-method assertions. | +| AC12 | PASS: one project in two programs uses independent case/submission/dates; foreign field ignored. | +| AC13 | Backend PASS: raw flags retained, noncompetitive XLSX and historical null dates; UI pending. | +| AC14 | Backend error/empty responses covered; loading/retry/error presentation pending in Angular. | +| AC15 | Angular pending: cancellation, stale responses and duplicate export clicks. | +| AC16 | Angular/browser pending: responsive, focus, keyboard and visual continuity. | +| AC17 | Backend PASS: strict query matrix including duplicate and empty params; old-server frontend guard pending. | +| AC18 | PASS: workbook read-back, headers, filenames, literal values, URL preservation and empty workbook. | +| AC19 | PASS: read-only capture; overview 4 queries at 1/100 rows; list/export constant at 1/100, bounded to <=10/<=7. | + +No unresolved BLOCKER/MAJOR backend findings in sequential `procollab-review`. +Backend is ready for integration; the complete feature is not accepted until Angular QA. + +## Verification evidence + +Environment: Python 3.11.15, Django 4.2.11, isolated local PostgreSQL 18, +`DJANGO_SETTINGS_MODULE=procollab.settings_ci`. CI uses PostgreSQL 15; that version +was not run locally. No existing development/production database was used. + +Executed regression command (100 tests, PASS, 26.231s): + +```text +python manage.py test partner_programs.tests.test_project_case_drilldown partner_programs.tests.test_project_case_analytics partner_programs.tests.test_program_filters partner_programs.tests.test_exports partner_programs.tests.test_program_filter_access partner_programs.tests.test_project_analytics_api partner_programs.tests.test_project_analytics_attention_api --noinput --verbosity 1 +``` + +This covered 12 new and 88 existing tests. Final additional option-removal and +owner/team permission fixtures were verified by: + +```text +python manage.py test partner_programs.tests.test_project_case_drilldown --noinput --verbosity 1 +``` + +Final targeted result: **13 tests, PASS, 6.739s**, exit code 0. Combined coverage is +101 distinct tests, not 113: the targeted run repeats the feature tests. + +- Checked by source: baseline/diff, permissions, consumers, legacy branches and AC audit. +- Checked by tests: both commands above, PostgreSQL-backed API/workbook/query assertions. +- Checked by static checks: `manage.py check` (0 issues), + `manage.py makemigrations --check --dry-run` (no changes), flake8 on all six changed + Python files; Black on five files passed. `exports.py` has one pre-existing Black + formatting difference in the untouched legacy rates builder, reproduced from HEAD; + no new formatting differences. `git diff --check` passed. +- Checked by build: no separate backend image/package build run. +- Checked in browser: not run; no frontend implementation in this task. +- Not checked: full backend suite, PostgreSQL 15 CI, deployed SHA/live data, + production-scale export time/memory, Angular end-to-end and visual/accessibility QA. + +## Angular handoff + +Implement the approved seven states using the existing AnalyticsDrilldown; no Case +column inside the selected modal, no evaluation metrics and no staff UI gate change. +Q2 requires showing without_case when its project count is positive even if there +are no case definition/options. Search changes list/count only; compact metrics and +export remain full-bucket. Do not pass search or pagination to export. + +Backend must deploy first. Validate the new list response contract before enabling +case exports; old servers may ignore query parameters. Preserve response filename, +or align the existing frontend filename helper with the backend scope. Add UI +pagination/search/retry/cancellation tests, all seven visual states, responsive and +keyboard checks. No silent fallback to the legacy all-project list/export. diff --git a/docs/qa/analytics-case-drilldown-review-2026-09-28.md b/docs/qa/analytics-case-drilldown-review-2026-09-28.md new file mode 100644 index 00000000..001f14fb --- /dev/null +++ b/docs/qa/analytics-case-drilldown-review-2026-09-28.md @@ -0,0 +1,206 @@ +# Независимый review backend: Analytics → Case drilldown + +Дата: 28.09.2026. Skill: `procollab-review`. + +**Verdict: READY FOR ANGULAR.** Нарушений backend-требований, требующих исправления +перед Angular, не обнаружено. Это не приёмка всей feature и не разрешение на deploy. + +## Основание и границы review + +Сначала прочитаны утверждённая specification +`procollab-design-system-v1/docs/feature-specs/analytics-case-drilldown.md`, AC01–AC19, +решения Q1–Q4 и [backend contract](../analytics-case-drilldown-api.md). +Implementation report использован как указатель, но его PASS не перенесены автоматически. + +Checkout: `api-analytics-case-drilldown`, ветка `feature/analytics-case-drilldown-backend`. +`HEAD` и локальный `origin/master` равны `7b2918ed3d4914e50a5a5fc684706044ba7f5c06`. +Реализация ещё не закоммичена: проверены `git diff 7b2918e` и все новые untracked-файлы. +Diff между двумя commit refs пуст; результат этого review относится к рабочему дереву, +а не к уже опубликованному commit ветки. Remote/deployed SHA в этом review не подтверждался. + +Прочитан полный diff трёх изменённых файлов и содержимое трёх новых Python-файлов: +views, exports, общий classifier, query/response serializers, выборка drilldown, tests. +Прослежены модели, permissions, pagination, case-field lookup, XLSX helpers, +legacy list/filter/rates exports, overview и attention endpoints. +Runtime-код и штатные тесты во время review не изменялись. Добавлен этот отчёт и +локальные проверочные скрипт/логи в ignored `.cache/case-tests/`. + +## Findings: BLOCKER → MAJOR → MINOR → NOTE + +**BLOCKER / MAJOR / MINOR: не обнаружены.** + +**NOTE — часть штатных workbook assertions зависит от реализации.** + +- Участок: `partner_programs/tests/test_project_case_drilldown.py`, helper `workbook` + (строки 90–102) импортирует `CASE_PROJECT_COLUMNS`; legacy-тест (строки 463–493) + строит ожидаемый workbook текущим `build_program_projects_export_file`. +- Почему важно: одновременная ошибочная смена константы/генератора и результата может + оставить эти конкретные assertions зелёными. При этом прочие проверки содержимого, + permissions, negative cases и независимые regression-тесты содержательны. +- AC09/AC18 в проверенном diff **не нарушены**: дополнительно проверены буквальные + заголовки из specification, AST всех прежних функций/classes exporter и workbook + из исходника `7b2918e`; результаты совпали. +- Минимальное улучшение штатных тестов: фиксировать ожидаемые заголовки литералами + из контракта и проверять legacy workbook через независимые ожидаемые строки. + Перенос review-проб в постоянные тесты полезен, но не блокирует Angular. + +## Результат проверки контрактов + +### API и классификация + +- Новый response получается только при `view=case_analytics`. Обычные legacy-запросы + идут через `super().list()`/прежний exporter. Новые `case_scope`/`case_name` без + правильного `view` отклоняются с 400; это защита от тихого fallback на all. +- `selected` принимает точный текущий option без trim/casefold. Остальные scopes + запрещают `case_name`. Неизвестные/дублированные параметры и неверные комбинации + отклоняются. Export запрещает даже пустые `search/limit/offset/only_submitted`. +- Только list поддерживает поиск по имени (`icontains` после trim), limit 1–100, + offset >= 0, default 25. Count учитывает поиск; case_metrics относится ко всему bucket. +- `project_case_links()` используется overview/list/export. Missing, blank, whitespace, + obsolete, nonexact значения относятся к typed `without_case` (`name=null`). + Настоящий option «Без выбранного кейса» остаётся `selected`; label не служит identity. +- Отсутствие definition/options не скрывает проекты из API. `cases_configured` + сохраняет прежнее значение «поле существует», а не «есть options». +- Поля/типы страницы и строки соответствуют specification; evaluation metrics нет. + +### Scope, permissions и презентация + +- Выборка начинается с `PartnerProgramProject` выбранной программы; case field + также выбирается из неё. Submitted/linked_at/submitted_at берутся с этой связи. + Сценарий одного Project в двух программах и foreign field value проверен тестами. +- DRF проверяет `IsAdminOrManagerOfProgram` до handler/выборки. Менеджер своей + программы, staff и superuser разрешены; чужой менеджер, expert-only, participant, + leader/collaborator без manager-права и посторонний получают 403; anonymous — 401. +- Для отсутствующей программы обычный пользователь получает 403, staff/superuser — + 404, как у этих endpoints на base. Семантика других analytics endpoints не перенесена. +- Дополнительная SQL-проверка отказов установила: проект, program-project и field + values не читаются до успешной проверки доступа; private presentation/name не утекли. +- Presentation берётся только из `Project.presentation_address`. Проверены внешняя + ссылка с query/fragment, null, blank и отличный URL самой программы. ProjectDetail + не вызывается; project/leader загружаются одним `select_related`. +- Сохранена текущая формула `team_size = 1 + Collaborator rows`, включая возможную + строку лидера. Это не метрика уникальных зарегистрированных участников кейса. + +### XLSX и совместимость + +- `.xlsx`, лист «Проекты», десять колонок в утверждённом порядке, включая явный + «Кейс» независимо от label поля. All/selected/without_case выгружают полный scope. +- Порядок — link datetime_created, затем pk. Проверены даты с timezone, submitted + historical null и stale timestamp несданной связи; для noncompetitive — «Не требуется». +- Полная presentation URL сохраняется текстом. `=`, `+`, `-`, `@`, `#N/A` и табуляция + не превращаются в формулы: ячейки имеют string type, HYPERLINK не строится. +- Filename проходит существующую sanitization, без CR/LF/path separators, и различает + all/selected/without_case. После удаления связей уже просмотренного bucket возвращён + корректный header-only workbook для всех трёх scopes. +- Legacy list/default pagination, generic filter, all/submitted/rates XLSX, + `/project-analytics/` и attention endpoints покрыты самостоятельно запущенными + regression-тестами. Старые exporter функции/classes побайтно по AST не изменились; + workbook all/submitted и overview дополнительно сравнены с исходником base. + +## Acceptance Criteria audit + +Ссылки T ведут к штатным тестам, P — к дополнительным review-пробам, описанным ниже. +PASS означает полную проверку backend-требования; PARTIAL явно оставляет UI/интеграционную часть. + +| AC | Статус | Конкретное evidence | +| --- | --- | --- | +| AC01 | NOT APPLICABLE — Angular | Click/Enter/Space, заголовок и открытие modal относятся к Angular. | +| AC02 | PASS — backend fully verified | `project_case_links` + `case_project_rows`; T2 сравнивает bucket/count, T5 проверяет изоляцию программ, T6 — pagination. | +| AC03 | PARTIAL — backend part verified | `CaseProjectSerializer`, `ProjectCaseRowSerializer`; T1/T7 и P1 проверяют project/presentation/null/blank и JSON/XLSX case. UI placeholders/колонки ещё отсутствуют. | +| AC04 | PARTIAL — backend part verified | `case_project_rows`, safe leader serializer; T1/T13 проверяют team/region/submitted и 0 SQL при сериализации 100 строк. Отображение и browser/network остаются Angular. | +| AC05 | PASS — backend fully verified | `project_case_links`; T2/T4 проверяют missing/blank/obsolete/nonexact и literal label в list/overview/export; P5 сравнивает с base. | +| AC06 | PARTIAL — backend part verified | T3: нет definition/options и пустая программа; без ложных строк. Видимость карточки по Q2 остаётся Angular. | +| AC07 | PASS — backend fully verified | `build_case_projects_export_file`; T6: 32 уникальные строки all, P1: буквальные headers, полная project URL и порядок. | +| AC08 | PASS — backend fully verified | T6: selected с 30 строками при странице 25 и поиске; T2: полный without_case; T9: export отклоняет search/limit/offset. | +| AC09 | PASS — backend fully verified | T11 + запущенные filter/export/overview/attention suites; P5: legacy AST/workbook/overview равны base. Старые serializers/permissions/URLs не изменены. | +| AC10 | PARTIAL — backend part verified | T6: trim+icontains, count, next/previous, encoded option, стабильный порядок, out-of-range. Enter/reset/retry — Angular. | +| AC11 | PASS — backend fully verified | `IsAdminOrManagerOfProgram` и view dispatch; T10: матрица ролей/401/403/404/405; P4: SQL и private payload отсутствуют при отказе. | +| AC12 | PASS — backend fully verified | T5: общий Project, две program links, разные case/submitted/link dates; чужой field игнорируется. Источник дат подтверждён service/serializer/export и P1. | +| AC13 | PARTIAL — backend part verified | T7: raw flags, noncompetitive status XLSX и historical null; serializer/export не придумывают дату. UI status — Angular. | +| AC14 | PARTIAL — backend part verified | T3/T6/T8/T9/T10 и P2: empty/no results/400/401/403/404 остаются разными ответами; loading/error/retry UX — Angular. | +| AC15 | NOT APPLICABLE — Angular | Cancellation, stale response/download и повторный клик. | +| AC16 | NOT APPLICABLE — Angular | Mobile, focus trap, Escape, Mont и visual continuity. | +| AC17 | PARTIAL — backend part verified | `ProjectCaseQuerySerializer`; T9/T4: invalid/unknown/removed option и несовместимые параметры → 400 без all fallback. Guard против старого backend — будущая Angular integration. | +| AC18 | PASS — backend fully verified | T8/T12 и P1/P2: workbook read-back, буквальные headers, URL, ISO даты, filename, literal cells, header-only после удаления данных. | +| AC19 | PASS — backend fully verified | T13: отсутствие writes и 1/100 rows; существующий case test: 1/20 options; P3: 1/100 заполненных cases, queries 4/9/6 без роста. | + +## Тесты и независимые evidence + +Окружение: Python 3.11.15, Django 4.2.11, отдельный PostgreSQL 18 на loopback:55439, +`procollab.settings_ci`. Использована выделенная тестовая БД; dev/prod не затрагивались. + +Самостоятельно выполненная команда: + +```text +python manage.py test partner_programs.tests.test_project_case_drilldown partner_programs.tests.test_project_case_analytics partner_programs.tests.test_program_filters partner_programs.tests.test_exports partner_programs.tests.test_program_filter_access partner_programs.tests.test_project_analytics_api partner_programs.tests.test_project_analytics_attention_api --noinput --verbosity 1 +``` + +**101 test, OK, 27.627s, exit code 0.** Это результат текущего review, не прежнего implementation run. +Лог: `.cache/case-tests/independent-review-tests.log`. + +T1–T13 — методы [ProjectCaseDrilldownTests](../../partner_programs/tests/test_project_case_drilldown.py): + +| ID | Метод | +| --- | --- | +| T1 | `test_selected_projection_and_full_bucket_metrics` | +| T2 | `test_classifier_parity_for_missing_blank_obsolete_and_nonexact_values` | +| T3 | `test_without_case_without_definition_or_options_and_empty_program` | +| T4 | `test_removed_option_is_rejected_and_its_links_move_to_without_case` | +| T5 | `test_same_project_in_two_programs_has_independent_case_submission_and_dates` | +| T6 | `test_pagination_search_exact_encoded_option_and_full_exports` | +| T7 | `test_noncompetitive_null_presentation_and_historical_submission_dates` | +| T8 | `test_empty_case_returns_zero_metadata_and_header_only_workbook` | +| T9 | `test_query_validation_rejects_invalid_and_ambiguous_scope_without_fallback` | +| T10 | `test_permission_matrix_missing_program_and_read_only_methods` | +| T11 | `test_legacy_list_and_all_submitted_xlsx_contracts_stay_unchanged` | +| T12 | `test_xlsx_user_values_are_literal_text_and_filename_is_safe` | +| T13 | `test_constant_queries_for_one_and_one_hundred_rows_and_no_writes` | + +Дополнительно создан и запущен отдельный локальный скрипт: + +```text +python .cache/case-tests/review_probes.py +``` + +**5 tests, OK, 2.295s, exit code 0.** Лог: `.cache/case-tests/independent-review-probes.log`. +Скрипт и логи — локальные ignored review-артефакты; они не добавлены в штатный suite. + +| ID | Независимая проверка | +| --- | --- | +| P1 | Буквальные headers из spec, изменённый label case, project URL вместо program URL, null/blank, даты и обратный chronological порядок связей. | +| P2 | Сначала прочитать непустой список, удалить связи, затем получить header-only для all/selected/without_case. | +| P3 | 1 строка/1 заполненный case → 100 строк/100 заполненных cases; одинаковый query count. | +| P4 | Anonymous/outsider + все scopes, включая invalid selector: permission error до валидации/чтения private данных; SQL capture. | +| P5 | AST прежних exporter functions/classes совпадает с base; all/submitted workbook и overview сравниваются с исполнением исходника `7b2918e`. | + +Реальные замеры P3 (manager, полный request): + +| Размер fixture | Overview service | List API | Export API | +| --- | ---: | ---: | ---: | +| 1 project / 1 case | 4 | 9 | 6 | +| 100 projects / 100 cases | 4 | 9 | 6 | + +Штатные тесты имеют meaningful assertions по составу строк, точным ролям/ошибкам, +multi-program данным, metadata, пагинации, cell types и отсутствию writes. +Замечание о self-referential expected values ограничено указанным NOTE; остальные +проверки не сведены к вызову того же helper для actual/expected. + +## Уровни проверки и следующий этап + +- **Checked by source:** spec → полный рабочий diff → модели/permissions/consumers → AC audit; + новые endpoints/entities/evaluation/UI-права не добавлены. +- **Checked by tests:** 101 regression + 5 независимых review-проб, всего 106 tests; + оба процесса завершились с exit code 0. Django system checks при запуске тестов успешны. +- **Static checks:** flake8 для всех шести затронутых Python-файлов и `git diff --check` — PASS. +- **Checked by build:** отдельный backend image/package build не запускался. +- **Checked in browser:** не запускалось; UI вне scope текущего этапа. +- **Not checked:** полный backend suite, PostgreSQL 15 CI, live API/данные/deployed SHA, + нагрузка на production-объёмах, реальное открытие файла в desktop Excel, + Angular E2E/response guard/keyboard/responsive/визуальная приёмка. + +К `procollab-angular` переходить можно на основании этого backend-контракта. +Сохраняются Q1–Q4 и backend-first rollout: export UI включается только после +подтверждения нового list contract, без fallback к legacy all export. +Перед передачей через remote-ветку нужно включить текущие рабочие изменения в commit; +само переключение на нынешний branch HEAD не воспроизводит проверенную реализацию. +Merge и production deploy остаются за пользователем. Angular/React/Figma/DS не менялись. diff --git a/partner_programs/serializers/project_case_drilldown.py b/partner_programs/serializers/project_case_drilldown.py new file mode 100644 index 00000000..7f0c4cf7 --- /dev/null +++ b/partner_programs/serializers/project_case_drilldown.py @@ -0,0 +1,89 @@ +"""Opt-in manager contracts; the public/legacy project serializer stays unchanged.""" + +from rest_framework import serializers + +from .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) + # QueryDict is treated as an HTML form by DRF: a supplied empty integer + # otherwise becomes a missing field and silently takes its 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) diff --git a/partner_programs/services/case_analytics.py b/partner_programs/services/case_analytics.py index f0879b9a..bc8a9f5b 100644 --- a/partner_programs/services/case_analytics.py +++ b/partner_programs/services/case_analytics.py @@ -1,6 +1,6 @@ -"""Read-only case analytics on existing Project × Program relations.""" +"""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, @@ -19,25 +19,17 @@ def _empty_metrics(): } -def build_case_analytics(program) -> dict: - """Count program links by exact current options of the system field name='case'. +_UNSET = object() - 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. + +def project_case_links(program, *, field): + """Classify every Project × Program link against exact current options. + + NULL is the typed without_case bucket, including missing/blank/obsolete + values. Overview, list and export all use this expression without writes. """ - 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 = ( + case_value = ( Subquery( PartnerProgramFieldValue.objects.filter( program_project_id=OuterRef("pk"), field_id=field.pk @@ -46,28 +38,52 @@ def build_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=choice) + .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_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"] 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) + .values_list("case_name", user_path) .distinct() ) - for case_value, user_id in pairs: - bucket = case_value if case_value in buckets else None + for value, user_id in pairs: + bucket = value if value in buckets else None participant_ids[bucket].add(user_id) for name, metrics in buckets.items(): diff --git a/partner_programs/services/exports.py b/partner_programs/services/exports.py index e2c153ef..fb40351e 100644 --- a/partner_programs/services/exports.py +++ b/partner_programs/services/exports.py @@ -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 ( @@ -15,6 +16,8 @@ PartnerProgramProject, PartnerProgramUserProfile, ) +from partner_programs.serializers.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 @@ -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 infers formulas/error cells from leading '=' / '#N/A'. + # Keep exact sanitized user text without adding an apostrophe or a formula. + cell.data_type = "s" + return cell + + +def build_case_projects_export_file(*, program, field, selection) -> ProgramExportFile: + """Export the full bucket; pagination/search never enter this contract.""" + 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]: """ Готовит данные для выгрузки оценок проектов. diff --git a/partner_programs/services/project_case_drilldown.py b/partner_programs/services/project_case_drilldown.py new file mode 100644 index 00000000..721c9114 --- /dev/null +++ b/partner_programs/services/project_case_drilldown.py @@ -0,0 +1,68 @@ +"""Read-only, program-scoped projection shared by the case list and XLSX.""" + +from django.db.models import Count, IntegerField, OuterRef, Subquery, Value +from django.db.models.functions import Coalesce + +from projects.models import Collaborator + +from .case_analytics import project_case_links + + +def case_project_rows(program, *, field, selection): + links = project_case_links(program, field=field) + if selection["case_scope"] == "selected": + links = links.filter(case_name=selection["case_name"]) + elif selection["case_scope"] == "without_case": + links = links.filter(case_name__isnull=True) + + collaborators = ( + Collaborator.objects.filter(project_id=OuterRef("project_id")) + .order_by() + .values("project_id") + .annotate(total=Count("pk")) + .values("total") + ) + return ( + links.select_related("project", "project__leader") + .only( + "id", + "project_id", + "datetime_created", + "submitted", + "datetime_submitted", + "project__id", + "project__name", + "project__presentation_address", + "project__region", + "project__leader_id", + "project__leader__id", + "project__leader__first_name", + "project__leader__last_name", + ) + .annotate( + # Keep the legacy export definition: leader + collaborator rows, + # not unique registered participants from the bucket metrics. + team_size=Coalesce( + Subquery(collaborators, output_field=IntegerField()), Value(0) + ) + + Value(1) + ) + .order_by("datetime_created", "pk") + ) + + +def case_list_metadata(cases, selection): + scope = selection["case_scope"] + name = selection.get("case_name") + metrics = None + if scope == "without_case": + metrics = cases["without_case"] + elif scope == "selected": + bucket = next(item for item in cases["items"] if item["name"] == name) + metrics = {key: value for key, value in bucket.items() if key != "name"} + return { + "selection": {"scope": scope, "case_name": name}, + "cases_configured": cases["configured"], + "submission_applicable": cases["submission_applicable"], + "case_metrics": metrics, + } diff --git a/partner_programs/tests/test_project_case_drilldown.py b/partner_programs/tests/test_project_case_drilldown.py new file mode 100644 index 00000000..ef19d120 --- /dev/null +++ b/partner_programs/tests/test_project_case_drilldown.py @@ -0,0 +1,577 @@ +"""Contract, security, XLSX and query-budget tests for opt-in case analytics.""" + +import io +from urllib.parse import parse_qs, unquote, urlsplit + +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from django.utils.dateparse import parse_datetime +from openpyxl import load_workbook +from rest_framework.test import APIClient + +from partner_programs.models import ( + PartnerProgramField, + PartnerProgramFieldValue, + PartnerProgramProject, +) +from partner_programs.serializers.project_case_drilldown import ProjectCaseRowSerializer +from partner_programs.services.exports import ( + BASE_COLUMNS, + CASE_PROJECT_COLUMNS, + build_program_projects_export_file, +) +from partner_programs.services.case_analytics import build_case_analytics +from partner_programs.services.project_case_drilldown import case_project_rows +from partner_programs.tests.helpers import ( + create_partner_program, + create_program_member, + create_program_project, + create_project, + create_user, +) +from partner_programs.tests.test_case_fields import create_case_field +from project_rates.tests.helpers import create_rate_expert +from projects.models import Collaborator +from projects.serializers import ProjectListSerializer + + +class ProjectCaseDrilldownTests(TestCase): + @classmethod + def setUpTestData(cls): + cls.program = create_partner_program(is_competitive=True) + cls.manager = create_user(password=None) + cls.program.managers.add(cls.manager) + cls.field = create_case_field( + cls.program, + options=["A", "B", "Empty", "Без выбранного кейса", "Кейс + &=100%"], + ) + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(self.manager) + + def url(self, export=False, program=None): + program = program or self.program + suffix = "export-projects" if export else "projects" + return f"/programs/{program.pk}/{suffix}/" + + def get(self, scope="selected", *, name="A", export=False, program=None, **params): + query = {"view": "case_analytics", "case_scope": scope, **params} + if scope == "selected": + query["case_name"] = name + return self.client.get(self.url(export, program), query) + + def link( + self, value=None, *, program=None, field=None, project=None, submitted=False + ): + link = create_program_project( + program or self.program, + project=project or create_project(leader=self.manager), + submitted=submitted, + ) + if value is not None: + # Historical invalid values must remain readable, without relaxing writes. + PartnerProgramFieldValue.objects.bulk_create( + [ + PartnerProgramFieldValue( + program_project=link, field=field or self.field, value_text=value + ) + ] + ) + return link + + def page(self, *args, **kwargs): + response = self.get(*args, **kwargs) + self.assertEqual(response.status_code, 200, response.data) + return response.json() + + def workbook(self, *args, **kwargs): + response = self.get(*args, export=True, **kwargs) + self.assertEqual(response.status_code, 200, response.content[:300]) + self.assertEqual( + response["Content-Type"], + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + book = load_workbook(io.BytesIO(response.content), data_only=False) + self.assertEqual(book.sheetnames, ["Проекты"]) + self.addCleanup(book.close) + rows = list(book.active.iter_rows(values_only=True)) + self.assertEqual(list(rows[0]), CASE_PROJECT_COLUMNS) + return book, rows[1:], response + + def test_selected_projection_and_full_bucket_metrics(self): + create_program_member(self.program, user=self.manager) + member = create_user(password=None) + create_program_member(self.program, user=member) + unregistered = create_user(password=None) + project = create_project( + leader=self.manager, + name="Selected", + region="Москва", + presentation_address="https://example.org/project.pdf", + draft=True, + ) + Collaborator.objects.create(project=project, user=member) + Collaborator.objects.create(project=project, user=unregistered) + link = self.link("A", project=project, submitted=True) + self.link("A") + self.link("B") + page = self.page(search=" Selected ") + self.assertEqual(page["count"], 1) + self.assertEqual(page["selection"], {"scope": "selected", "case_name": "A"}) + self.assertTrue(page["cases_configured"]) + self.assertTrue(page["submission_applicable"]) + self.assertEqual( + page["case_metrics"], + { + "projects_total": 2, + "participants_total": 2, + "submitted": 1, + "not_submitted": 1, + }, + ) + row = page["results"][0] + self.assertEqual( + set(row), + { + "program_project_id", + "project", + "case", + "leader", + "team_size", + "linked_at", + "submitted", + "submitted_at", + }, + ) + self.assertEqual(row["program_project_id"], link.pk) + self.assertEqual( + row["project"], + { + "id": project.pk, + "name": "Selected", + "region": "Москва", + "presentation_address": "https://example.org/project.pdf", + }, + ) + self.assertEqual(row["case"], {"kind": "selected", "name": "A"}) + self.assertEqual( + row["leader"], + { + "user_id": self.manager.pk, + "full_name": "Program User", + }, + ) + # Project creation also inserts the leader as a collaborator. Preserve + # the existing export formula (1 + rows), including that stored row. + self.assertEqual(row["team_size"], 4) + self.assertTrue(row["submitted"]) + self.assertEqual(parse_datetime(row["submitted_at"]), link.datetime_submitted) + self.assertEqual(parse_datetime(row["linked_at"]), link.datetime_created) + + def test_classifier_parity_for_missing_blank_obsolete_and_nonexact_values(self): + values = [ + "A", + "B", + "Без выбранного кейса", + None, + "", + " \t\n", + "old", + "a", + " A", + "A ", + ] + links = [self.link(value) for value in values] + expected_without = {link.pk for link in links[3:]} + overview = build_case_analytics(self.program) + without = self.page("without_case") + self.assertEqual(without["case_metrics"], overview["without_case"]) + self.assertEqual( + {r["program_project_id"] for r in without["results"]}, expected_without + ) + for row in without["results"]: + self.assertEqual(row["case"], {"kind": "without_case", "name": None}) + for bucket in overview["items"]: + page = self.page(name=bucket["name"]) + self.assertEqual(page["count"], bucket["projects_total"]) + self.assertEqual( + page["case_metrics"], {k: v for k, v in bucket.items() if k != "name"} + ) + all_page = self.page("all") + self.assertEqual(all_page["count"], len(values)) + self.assertIsNone(all_page["case_metrics"]) + self.assertEqual(all_page["selection"], {"scope": "all", "case_name": None}) + _, rows, _ = self.workbook("without_case") + self.assertEqual({r[1] for r in rows}, {link.project.name for link in links[3:]}) + self.assertTrue(all(r[2] == "Без выбранного кейса" for r in rows)) + _, literal_rows, _ = self.workbook(name="Без выбранного кейса") + self.assertEqual([r[1] for r in literal_rows], [links[2].project.name]) + self.assertEqual( + PartnerProgramFieldValue.objects.get(program_project=links[-1]).value_text, + "A ", + ) + + def test_without_case_without_definition_or_options_and_empty_program(self): + program = create_partner_program() + program.managers.add(self.manager) + page = self.page("without_case", program=program) + self.assertEqual(page["count"], 0) + self.assertFalse(page["cases_configured"]) + self.link(program=program) + page = self.page("without_case", program=program) + self.assertEqual(page["count"], 1) + self.assertFalse(page["cases_configured"]) + _, rows, _ = self.workbook("without_case", program=program) + self.assertEqual(len(rows), 1) + field = create_case_field(program) + PartnerProgramField.objects.filter(pk=field.pk).update(options="") + page = self.page("without_case", program=program) + self.assertTrue(page["cases_configured"]) + self.assertEqual(page["case_metrics"]["projects_total"], 1) + self.assertEqual(self.get(program=program).status_code, 400) + _, rows, _ = self.workbook("without_case", program=program) + self.assertEqual(len(rows), 1) + + def test_removed_option_is_rejected_and_its_links_move_to_without_case(self): + link = self.link("A") + self.assertEqual(self.page()["count"], 1) + PartnerProgramField.objects.filter(pk=self.field.pk).update(options="B") + for export in (False, True): + self.assertEqual(self.get(export=export).status_code, 400) + page = self.page("without_case") + self.assertEqual( + [row["program_project_id"] for row in page["results"]], [link.pk] + ) + self.assertEqual(page["case_metrics"]["projects_total"], 1) + _, rows, _ = self.workbook("without_case") + self.assertEqual(rows[0][2], "Без выбранного кейса") + self.assertEqual( + PartnerProgramFieldValue.objects.get(program_project=link).value_text, "A" + ) + + def test_same_project_in_two_programs_has_independent_case_submission_and_dates(self): + other = create_partner_program(is_competitive=True) + other.managers.add(self.manager) + other_field = create_case_field(other, options=["A", "B"]) + shared = create_project( + leader=self.manager, presentation_address="https://example.org/shared" + ) + link_a = self.link("A", project=shared, submitted=True) + link_b = self.link("B", project=shared, program=other, field=other_field) + created = timezone.now() - timezone.timedelta(days=10) + PartnerProgramProject.objects.filter(pk=link_b.pk).update( + datetime_created=created + ) + row_a = self.page()["results"][0] + row_b = self.page(name="B", program=other)["results"][0] + self.assertNotEqual(row_a["program_project_id"], row_b["program_project_id"]) + self.assertEqual(row_a["program_project_id"], link_a.pk) + self.assertEqual(row_b["program_project_id"], link_b.pk) + self.assertEqual(row_a["project"], row_b["project"]) + self.assertTrue(row_a["submitted"]) + self.assertFalse(row_b["submitted"]) + self.assertIsNone(row_b["submitted_at"]) + self.assertEqual(parse_datetime(row_b["linked_at"]), created) + self.assertEqual(self.page(program=other)["count"], 0) + _, rows_a, _ = self.workbook() + _, rows_b, _ = self.workbook(name="B", program=other) + self.assertEqual(rows_a[0][2], "A") + self.assertEqual(rows_b[0][2], "B") + self.assertEqual(rows_a[0][7], "Сдано") + self.assertEqual(rows_b[0][7], "Не сдано") + self.assertIsNone(rows_b[0][9]) + # Even historically corrupt values from a different program are ignored. + self.link("A", field=other_field) + self.assertEqual(self.page("without_case")["count"], 1) + + def test_pagination_search_exact_encoded_option_and_full_exports(self): + name = "Кейс + &=100%" + links = [ + self.link( + name, + project=create_project( + leader=self.manager, name=f"{'Find' if i < 3 else 'Project'} {i:02d}" + ), + ) + for i in range(30) + ] + self.link("B") + self.link() + PartnerProgramProject.objects.filter(pk__in=[x.pk for x in links]).update( + datetime_created=timezone.now() + ) + first = self.page(name=name) + self.assertEqual(len(first["results"]), 25) + self.assertEqual(first["count"], 30) + self.assertIsNone(first["previous"]) + self.assertEqual(parse_qs(urlsplit(first["next"]).query)["case_name"], [name]) + second = self.client.get(first["next"]).json() + ids = [r["program_project_id"] for r in first["results"] + second["results"]] + self.assertEqual(ids, [link.pk for link in links]) + self.assertIsNone(second["next"]) + self.assertIsNotNone(second["previous"]) + search = self.page(name=name, search=" fInD ", limit=2) + self.assertEqual(search["count"], 3) + self.assertEqual(search["case_metrics"]["projects_total"], 30) + self.assertEqual(self.page(name=name, search="missing")["count"], 0) + self.assertEqual(self.page(name=name, offset=100)["results"], []) + _, selected, _ = self.workbook(name=name) + self.assertEqual([r[1] for r in selected], [link.project.name for link in links]) + _, all_rows, _ = self.workbook("all") + self.assertEqual(len(all_rows), 32) + self.assertEqual(len({r[1] for r in all_rows}), 32) + self.assertEqual([r[0] for r in all_rows], list(range(1, 33))) + + def test_noncompetitive_null_presentation_and_historical_submission_dates(self): + self.program.is_competitive = False + self.program.save(update_fields=["is_competitive"]) + historical = self.link("A", submitted=True) + unsent = self.link("A") + PartnerProgramProject.objects.filter(pk=historical.pk).update( + datetime_submitted=None + ) + PartnerProgramProject.objects.filter(pk=unsent.pk).update( + datetime_submitted=timezone.now() + ) + page = self.page() + self.assertFalse(page["submission_applicable"]) + self.assertEqual([r["submitted"] for r in page["results"]], [True, False]) + for row in page["results"]: + self.assertIsNone(row["submitted_at"]) + self.assertIsNone(row["project"]["presentation_address"]) + _, rows, _ = self.workbook() + for row in rows: + self.assertEqual(row[7], "Не требуется") + self.assertIsNone(row[3]) + self.assertIsNone(row[9]) + self.assertIsNotNone(parse_datetime(row[8]).tzinfo) + + def test_empty_case_returns_zero_metadata_and_header_only_workbook(self): + self.assertEqual(self.page(name="Empty")["count"], 0) + self.assertEqual(self.page(name="Empty")["case_metrics"]["projects_total"], 0) + _, rows, _ = self.workbook(name="Empty") + self.assertEqual(rows, []) + + def test_query_validation_rejects_invalid_and_ambiguous_scope_without_fallback(self): + self.link("A") + invalid = [ + {}, + {"case_scope": "unknown"}, + {"case_scope": "selected"}, + {"case_scope": "selected", "case_name": "a"}, + {"case_scope": "selected", "case_name": " A "}, + {"case_scope": "selected", "case_name": "old"}, + {"case_scope": "selected", "case_name": ""}, + {"case_scope": "selected", "case_name": " \t "}, + {"case_scope": "all", "case_name": "A"}, + {"case_scope": "without_case", "case_name": "A"}, + {"case_scope": "all", "unknown": "1"}, + {"case_scope": "all", "only_submitted": "1"}, + ] + for export in (False, True): + for query in invalid: + with self.subTest(export=export, query=query): + response = self.client.get( + self.url(export), {"view": "case_analytics", **query} + ) + self.assertEqual(response.status_code, 400) + self.assertNotIn("Content-Disposition", response) + for query in ( + "view=wrong&case_scope=all", + "case_scope=selected&case_name=A", + "view=case_analytics&view=wrong&case_scope=all", + "view=case_analytics&case_scope=all&case_scope=selected", + "view=case_analytics&case_scope=selected&case_name=A&case_name=B", + ): + self.assertEqual( + self.client.get(self.url(export) + "?" + query).status_code, 400 + ) + for params in ( + {"limit": 0}, + {"limit": 101}, + {"limit": "x"}, + {"offset": -1}, + {"offset": "x"}, + {"limit": ""}, + {"limit": "1.5"}, + {"offset": ""}, + ): + self.assertEqual(self.get(**params).status_code, 400) + for key in ("search", "limit", "offset", "only_submitted"): + for value in ("", "1"): + self.assertEqual(self.get(export=True, **{key: value}).status_code, 400) + self.assertEqual(self.page(limit=100)["count"], 1) + + def test_permission_matrix_missing_program_and_read_only_methods(self): + self.link( + "A", project=create_project(leader=self.manager, name="Private case project") + ) + foreign = create_partner_program() + foreign_manager = create_user(password=None) + foreign.managers.add(foreign_manager) + participant = create_program_member(self.program).user + expert = create_rate_expert(program=self.program) + outsider = create_user(password=None) + leader = create_program_member(self.program).user + teammate = create_program_member(self.program).user + owned = self.link("A", project=create_project(leader=leader)) + Collaborator.objects.create(project=owned.project, user=teammate) + staff = create_user(password=None, is_staff=True) + superuser = create_user(password=None, is_superuser=True) + for user, expected in ( + (None, 401), + (participant, 403), + (expert, 403), + (outsider, 403), + (leader, 403), + (teammate, 403), + (foreign_manager, 403), + (self.manager, 200), + (staff, 200), + (superuser, 200), + ): + self.client.force_authenticate(user) + for export in (False, True): + with self.subTest(user=user, export=export): + response = self.get(export=export) + self.assertEqual(response.status_code, expected) + if expected != 200: + self.assertNotIn(b"Private case project", response.content) + self.assertNotIn("Content-Disposition", response) + missing_url = f"/programs/{foreign.pk + 100000}/{'export-projects' if export else 'projects'}/" + missing = self.client.get( + missing_url, {"view": "case_analytics", "case_scope": "all"} + ) + self.assertEqual( + missing.status_code, + 401 + if user is None + else 404 + if user in (staff, superuser) + else 403, + ) + self.client.force_authenticate(self.manager) + for export in (False, True): + for method in ("post", "put", "patch", "delete"): + self.assertEqual( + getattr(self.client, method)(self.url(export)).status_code, 405 + ) + + def test_legacy_list_and_all_submitted_xlsx_contracts_stay_unchanged(self): + links = [self.link("A", submitted=i == 0) for i in range(12)] + legacy = self.client.get(self.url()) + self.assertEqual(legacy.status_code, 200) + self.assertEqual(set(legacy.data), {"count", "next", "previous", "results"}) + self.assertEqual(len(legacy.data["results"]), 10) + expected = ProjectListSerializer( + [x.project for x in reversed(links[-10:])], + many=True, + context={"request": legacy.wsgi_request}, + ).data + self.assertEqual(legacy.data["results"], expected) + for submitted in (False, True): + response = self.client.get( + self.url(True), {"only_submitted": "1"} if submitted else {} + ) + self.assertEqual(response.status_code, 200) + actual = load_workbook(io.BytesIO(response.content)) + reference = build_program_projects_export_file( + program=self.program, only_submitted=submitted + ) + baseline = load_workbook(io.BytesIO(reference.binary_data)) + self.addCleanup(actual.close) + self.addCleanup(baseline.close) + actual_rows = list(actual.active.values) + self.assertEqual(actual_rows, list(baseline.active.values)) + self.assertEqual( + list(actual_rows[0]), + [title for _, title in BASE_COLUMNS] + [self.field.label], + ) + self.assertEqual(len(actual_rows) - 1, 1 if submitted else 12) + + def test_xlsx_user_values_are_literal_text_and_filename_is_safe(self): + option = '=HYPERLINK("https://example.org", "case")' + PartnerProgramField.objects.filter(pk=self.field.pk).update(options=option) + self.program.name = 'Program / unsafe\r\n"name' + self.program.save(update_fields=["name"]) + self.manager.first_name = "=1+1" + self.manager.last_name = "" + self.manager.save(update_fields=["first_name", "last_name"]) + names = [ + "=1+1", + "+SUM(1,2)", + "-1+2", + "@SUM(1,2)", + "#N/A", + "Текст & + %", + "\t=1+1", + ] + url = "https://example.org/presentation?x=1&token=a+b%20c#slide-15" + for name in names: + self.link( + option, + project=create_project( + leader=self.manager, + name=name, + region="=1+1", + presentation_address=url, + ), + ) + book, rows, response = self.workbook(name=option) + self.assertEqual([r[1] for r in rows], names) + for cells in list(book.active.iter_rows())[1:]: + for index in (1, 2, 3, 4, 5): + self.assertEqual(cells[index].data_type, "s") + self.assertIsNone(cells[index].hyperlink) + self.assertEqual(cells[2].value, option) + self.assertEqual(cells[3].value, url) + self.assertEqual(cells[4].value, "=1+1") + disposition = unquote(response["Content-Disposition"]) + for unsafe in ("\r", "\n", "/", "\\"): + self.assertNotIn(unsafe, disposition) + self.assertIn("projects_case", disposition) + self.assertIn(".xlsx", disposition) + for scope, label in (("all", "all_cases"), ("without_case", "without_case")): + _, _, response = self.workbook(scope) + self.assertIn(label, response["Content-Disposition"]) + + def test_constant_queries_for_one_and_one_hundred_rows_and_no_writes(self): + counts = {"list": [], "export": [], "overview": []} + self.link("A") + collaborator = create_user(password=None) + for size in (1, 100): + if size == 100: + for index in range(99): + project = create_project(leader=self.manager, name=f"Load {index}") + Collaborator.objects.create(project=project, user=collaborator) + self.link("A", project=project) + for operation in counts: + with CaptureQueriesContext(connection) as queries: + if operation == "overview": + payload = build_case_analytics(self.program) + self.assertEqual(payload["items"][0]["projects_total"], size) + elif operation == "list": + self.assertEqual(len(self.page(limit=100)["results"]), size) + else: + _, rows, _ = self.workbook() + self.assertEqual(len(rows), size) + counts[operation].append(len(queries)) + for query in queries: + self.assertNotRegex( + query["sql"], r"(?i)\b(INSERT INTO|UPDATE|DELETE FROM)\b" + ) + self.assertEqual(counts["overview"], [4, 4]) + self.assertEqual(counts["list"][0], counts["list"][1], counts) + self.assertEqual(counts["export"][0], counts["export"][1], counts) + self.assertLessEqual(counts["list"][1], 10, counts) + self.assertLessEqual(counts["export"][1], 7, counts) + rows = list( + case_project_rows( + self.program, field=self.field, selection={"case_scope": "all"} + ) + ) + with self.assertNumQueries(0): + self.assertEqual(len(ProjectCaseRowSerializer(rows, many=True).data), 100) diff --git a/partner_programs/views.py b/partner_programs/views.py index 3ce43519..7fc2b9b2 100644 --- a/partner_programs/views.py +++ b/partner_programs/views.py @@ -86,6 +86,19 @@ build_assignment_scores, build_assignments, ) +from partner_programs.serializers.project_case_drilldown import ( + ProjectCaseListQuerySerializer, + ProjectCaseQuerySerializer, + ProjectCaseRowSerializer, + case_analytics_requested, +) +from partner_programs.services.case_fields import get_program_case_field +from partner_programs.services.exports import build_case_projects_export_file +from partner_programs.services.case_analytics import build_case_analytics +from partner_programs.services.project_case_drilldown import ( + case_list_metadata, + case_project_rows, +) from projects.models import Project from projects.serializers import ProjectListSerializer @@ -567,6 +580,30 @@ class PartnerProgramProjectsAPIView(generics.ListAPIView): permission_classes = [IsAuthenticated, IsAdminOrManagerOfProgram] pagination_class = PartnerProgramPagination + def list(self, request, *args, **kwargs): + if not case_analytics_requested(request.query_params): + return super().list(request, *args, **kwargs) + + program = get_object_or_404(PartnerProgram, pk=self.kwargs["pk"]) + field = get_program_case_field(program) + query = ProjectCaseListQuerySerializer( + data=request.query_params, + context={"case_options": field.get_options_list() if field else []}, + ) + query.is_valid(raise_exception=True) + selection = query.validated_data + rows = case_project_rows(program, field=field, selection=selection) + if selection["search"]: + rows = rows.filter(project__name__icontains=selection["search"]) + paginator = ProgramAttentionPagination(selection) + page = paginator.paginate_queryset(rows, request, view=self) + response = paginator.get_paginated_response( + ProjectCaseRowSerializer(page, many=True).data + ) + cases = build_case_analytics(program, field=field) + response.data.update(case_list_metadata(cases, selection)) + return response + def get_queryset(self): if "pk" not in self.kwargs: return Project.objects.none() @@ -635,6 +672,20 @@ def get(self, request, pk: int): {"detail": "Недостаточно прав."}, status=status.HTTP_403_FORBIDDEN ) + if case_analytics_requested(request.query_params): + field = get_program_case_field(program) + query = ProjectCaseQuerySerializer( + data=request.query_params, + context={"case_options": field.get_options_list() if field else []}, + ) + query.is_valid(raise_exception=True) + export_file = build_case_projects_export_file( + program=program, field=field, selection=query.validated_data + ) + return build_xlsx_download_response( + export_file.binary_data, base_name=export_file.base_name + ) + only_submitted = request.query_params.get("only_submitted") in ( "1", "true", From 2603312f8f129d4ed6dc964da741d9d54fccce7c Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 28 Sep 2026 21:01:45 +0300 Subject: [PATCH 2/2] =?UTF-8?q?docs(analytics):=20=D0=BF=D0=BE=D0=B4=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B4=D0=B8=D1=82=D1=8C=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=BE=D1=81=20=D0=BD=D0=B0=20dev=20=D0=B8=20=D1=83?= =?UTF-8?q?=D1=81=D0=B8=D0=BB=D0=B8=D1=82=D1=8C=20XLSX=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/analytics-case-drilldown-api.md | 244 +++++++----------- .../qa/analytics-case-drilldown-dev-review.md | 52 ++++ .../serializers/project_case_drilldown.py | 6 +- partner_programs/services/case_analytics.py | 16 +- partner_programs/services/exports.py | 6 +- .../services/project_case_drilldown.py | 6 +- .../tests/test_project_case_drilldown.py | 27 +- 7 files changed, 184 insertions(+), 173 deletions(-) create mode 100644 docs/qa/analytics-case-drilldown-dev-review.md diff --git a/docs/analytics-case-drilldown-api.md b/docs/analytics-case-drilldown-api.md index e29953a4..fa8df32b 100644 --- a/docs/analytics-case-drilldown-api.md +++ b/docs/analytics-case-drilldown-api.md @@ -1,23 +1,21 @@ -# Analytics case drilldown: backend contract and handoff +# Analytics → Case drilldown: контракт backend -## Scope and baseline +## Основание и целевая ветка -Source: approved `docs/feature-specs/analytics-case-drilldown.md` in the PROCOLLAB -design-system/frontend checkout; Q1–Q4 and Figma section `30:1518` are approved. -This document records the backend implementation, not completion of the UI feature. +Источник — утверждённая specification `docs/feature-specs/analytics-case-drilldown.md` в Angular repo. +Q1–Q4 закрыты, Figma section `30:1518` утверждена. Полная приёмка UI приведена в связанном Angular PR. -Preflight on 2026-09-28 fetched `origin/master` at -`7b2918ed3d4914e50a5a5fc684706044ba7f5c06`. The implementation branch is -`feature/analytics-case-drilldown-backend`, based on that revision in a separate -checkout. Since the specification's `2fbc7225` reference, master changed expert -admin code/tests only. Relevant models, views, permissions, exporters and tests -were inspected again. Deployed SHA remains unconfirmed. +Цель — `dev`, исходная revision `29be99d15ec42d7f69b8e74c402dadc489c6216e`. +Ветка `feature/dev-analytics-case-drilldown-backend` создана от dev; из сохранённого commit +`608bf09` перенесена только feature, без истории master. Имена модулей адаптированы к dev: +`case_analytics.py`, `serializers/attention.py`, `ProgramAttentionPagination`. +Общая аналитика dev остаётся на `/manager-overview/`. +[Исторический независимый review](qa/analytics-case-drilldown-review-2026-09-28.md) относится +к исходному checkout на `7b2918e`, а не к этой базе. -No models, migrations, endpoints, frontend, Figma or Design System changes. -No evaluation fields. Existing `IsAdminOrManagerOfProgram` is retained for both -endpoints; staff/superuser API access remains as before, with no UI access changes. +Новые модели, миграции и endpoints не создавались. Case entity и evaluation fields не добавлялись. -## Opt-in requests +## Запросы ```text GET /programs/{id}/projects/?view=case_analytics&case_scope=selected&case_name={encodedExactOption}&limit=25&offset=0&search={query} @@ -29,25 +27,26 @@ 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` is required. `case_name` is required only for `selected` and forbidden - otherwise; it must equal a current option exactly, without trimming/case folding. -- Missing/unknown view with a case selector, unknown/duplicate parameters, invalid - options, blank/invalid numbers or incompatible selectors return 400, never all. -- List: `limit` defaults to 25, range 1–100; `offset` defaults to 0, minimum 0. - `search` trims outer whitespace and matches project name case-insensitively. - Ordering is link `datetime_created ASC, pk ASC`. -- Export rejects `search`, `limit`, `offset`, `only_submitted`, even empty values. - The frontend must omit them: export always contains the complete selected bucket. -- Existing requests without case-mode parameters use the original serializer, - pagination and exports, including `only_submitted`; generic filters/rates are unchanged. - -Page fields: `count`, `next`, `previous`, `results`, `selection`, `cases_configured`, -`submission_applicable`, `case_metrics`. `count` includes search; metrics always -describe the full bucket (`projects_total`, `participants_total`, `submitted`, -`not_submitted`). In `all`, metrics are null; unique participants are not summed. -`selection` is `{scope, case_name}`, with null name outside `selected`. - -Each result contains: +- 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 { @@ -67,124 +66,71 @@ Each result contains: } ``` -Example data only. `without_case` returns `{kind: "without_case", name: null}`. -Presentation/region may be null or blank. Missing leader is handled as null. -For unsent links or historical missing timestamps, `submitted_at` is null. - -## Shared classification and export - -`project_case_links()` in `services/project_case_analytics.py` classifies only -`PartnerProgramProject` links in the requested program, using that program's case -field. Overview/list/export share it. Missing, blank, whitespace, obsolete and -nonexact values all map to `without_case`. A real option named -«Без выбранного кейса» remains a separate selected bucket. No case definition or -no options means all existing links are in `without_case`; no synthetic rows. -`cases_configured` retains overview semantics: field existence, not nonempty options. - -`case_project_rows()` loads project/leader with `select_related` and annotates team -size with a correlated aggregate. No per-row queries or ProjectDetail fetches. -Legacy team-size semantics (`1 + Collaborator rows`, including a stored leader row) -are intentionally retained, distinct from registered unique case participants. - -XLSX sheet «Проекты», columns: - -1. № -2. Название проекта -3. Кейс -4. Ссылка на презентацию -5. Лидер -6. Регион -7. Размер команды -8. Сдача решения -9. Дата привязки к программе -10. Дата сдачи решения - -Full `Project.presentation_address` is stored as literal text; no hyperlink formula. -All user text gets explicit string cell type, including `=`, `+`, `-`, `@`, `#N/A`. -Existing control-character/Excel-length sanitization is reused. Dates are ISO 8601 -with timezone; missing values are blank. Noncompetitive status is «Не требуется», -without changing raw flags. Empty buckets produce a workbook with headers only. -Filename distinguishes all/selected/without_case and uses the existing safe -Content-Disposition helper. Legacy workbook builders are unchanged. - -Anonymous requests return 401; participant, project leader/team member, expert-only, -outsider and foreign manager return 403. Program manager and staff/superuser retain -access. Missing program returns 403 to ordinary users and 404 to staff/superuser, -following existing endpoint lookup/permission order. No private user serialization. - -## Requirement audit - -PASS below means the backend portion only. UI acceptance remains unverified. - -| AC | Verdict and evidence | -| --- | --- | -| AC01 | Angular pending: click/keyboard/modal/title. | -| AC02 | PASS: classifier parity and pagination fixtures compare full bucket counts and scoped links. | -| AC03 | Backend PASS: project/presentation/case projection and nulls; UI columns/placeholder pending. | -| AC04 | Backend PASS: safe leader/team/region/submission projection; 100-row serializer makes zero queries; UI pending. | -| AC05 | PASS: missing/blank/obsolete/nonexact/literal-label overview/list/export parity. | -| AC06 | Backend PASS: no definition/options and empty program; Angular visibility pending. | -| AC07 | PASS: all XLSX includes all 32 fixture links once with Case and presentation. | -| AC08 | PASS: 30-row selected export includes every page; search is rejected by export; without_case parity. | -| AC09 | Backend PASS: legacy payload/pagination/all/submitted exports and 88 existing regression tests; frontend unmodified. | -| AC10 | Backend PASS: stable ordering, URL encoding, search/count/pagination; Enter/reset/retry pending in Angular. | -| AC11 | PASS: direct API permission matrix, missing-program and read-only-method assertions. | -| AC12 | PASS: one project in two programs uses independent case/submission/dates; foreign field ignored. | -| AC13 | Backend PASS: raw flags retained, noncompetitive XLSX and historical null dates; UI pending. | -| AC14 | Backend error/empty responses covered; loading/retry/error presentation pending in Angular. | -| AC15 | Angular pending: cancellation, stale responses and duplicate export clicks. | -| AC16 | Angular/browser pending: responsive, focus, keyboard and visual continuity. | -| AC17 | Backend PASS: strict query matrix including duplicate and empty params; old-server frontend guard pending. | -| AC18 | PASS: workbook read-back, headers, filenames, literal values, URL preservation and empty workbook. | -| AC19 | PASS: read-only capture; overview 4 queries at 1/100 rows; list/export constant at 1/100, bounded to <=10/<=7. | - -No unresolved BLOCKER/MAJOR backend findings in sequential `procollab-review`. -Backend is ready for integration; the complete feature is not accepted until Angular QA. - -## Verification evidence - -Environment: Python 3.11.15, Django 4.2.11, isolated local PostgreSQL 18, -`DJANGO_SETTINGS_MODULE=procollab.settings_ci`. CI uses PostgreSQL 15; that version -was not run locally. No existing development/production database was used. - -Executed regression command (100 tests, PASS, 26.231s): +Without_case: `{kind: "without_case", name: null}`. Презентация/регион nullable и могут быть пустыми. +Отсутствующий leader — null. Для несданной связи/отсутствующей исторической даты submitted_at — null. +Презентация берётся исключительно из `Project.presentation_address`. -```text -python manage.py test partner_programs.tests.test_project_case_drilldown partner_programs.tests.test_project_case_analytics partner_programs.tests.test_program_filters partner_programs.tests.test_exports partner_programs.tests.test_program_filter_access partner_programs.tests.test_project_analytics_api partner_programs.tests.test_project_analytics_attention_api --noinput --verbosity 1 -``` +## Семантика, права и запросы + +Общий `project_case_links()` в `services/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 -This covered 12 new and 88 existing tests. Final additional option-removal and -owner/team permission fixtures were verified by: +Лист «Проекты». Порядок колонок: №; Название проекта; Кейс; Ссылка на презентацию; Лидер; +Регион; Размер команды; Сдача решения; Дата привязки к программе; Дата сдачи решения. + +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 не изменены. + +## Проверки на базе dev + +28.09.2026: Python 3.11.15, Django 4.2.11, отдельный PostgreSQL 18, ICU locale und, +`procollab.settings_ci`. Рабочие/dev/production БД не использовались. ```text -python manage.py test partner_programs.tests.test_project_case_drilldown --noinput --verbosity 1 +python manage.py test partner_programs.tests.test_project_case_drilldown partner_programs.tests.test_case_analytics_api partner_programs.tests.test_program_filters partner_programs.tests.test_case_filters_api partner_programs.tests.test_exports partner_programs.tests.test_manager_analytics_api partner_programs.tests.test_attention_analytics_api partner_programs.tests.test_not_submitted_analytics_api partner_programs.tests.test_assignment_analytics_api --noinput --verbosity 1 ``` -Final targeted result: **13 tests, PASS, 6.739s**, exit code 0. Combined coverage is -101 distinct tests, not 113: the targeted run repeats the feature tests. - -- Checked by source: baseline/diff, permissions, consumers, legacy branches and AC audit. -- Checked by tests: both commands above, PostgreSQL-backed API/workbook/query assertions. -- Checked by static checks: `manage.py check` (0 issues), - `manage.py makemigrations --check --dry-run` (no changes), flake8 on all six changed - Python files; Black on five files passed. `exports.py` has one pre-existing Black - formatting difference in the untouched legacy rates builder, reproduced from HEAD; - no new formatting differences. `git diff --check` passed. -- Checked by build: no separate backend image/package build run. -- Checked in browser: not run; no frontend implementation in this task. -- Not checked: full backend suite, PostgreSQL 15 CI, deployed SHA/live data, - production-scale export time/memory, Angular end-to-end and visual/accessibility QA. - -## Angular handoff - -Implement the approved seven states using the existing AnalyticsDrilldown; no Case -column inside the selected modal, no evaluation metrics and no staff UI gate change. -Q2 requires showing without_case when its project count is positive even if there -are no case definition/options. Search changes list/count only; compact metrics and -export remain full-bucket. Do not pass search or pagination to export. - -Backend must deploy first. Validate the new list response contract before enabling -case exports; old servers may ignore query parameters. Preserve response filename, -or align the existing frontend filename helper with the backend scope. Add UI -pagination/search/retry/cancellation tests, all seven visual states, responsive and -keyboard checks. No silent fallback to the legacy all-project list/export. +**187 tests PASS**, 45.647 s. Первый прогон на collation C дал три subtest errors в одном +существующем тесте кириллического поиска. Повтор на Unicode collation прошёл; +бизнес-логика поиска ради окружения не менялась. + +Покрыты exact/obsolete/blank/nonexact/одноимённый option, without definition/options, +multi-program isolation, permission matrix, pagination/search, invalid query params, +legacy list/filter/export, XLSX contents/даты/порядок/формулы/имена/пустой файл, отсутствие writes. +Overview — 4 запроса; list/export имеют одинаковое число при 1 и 100 строках, +с ограничениями <=10 / <=7. Сериализация 100 строк не делает SQL. +Заголовки нового workbook дополнительно закреплены literal assertion независимо от константы exporter. + +Не проверены локально: полный backend suite, PostgreSQL 15, live deployed SHA/data, +production-scale время/память XLSX. GitHub CI фиксируется в PR отдельно. + +## Rollout + +Backend PR первым попадает в dev; после проверки нового list/export contract — Angular PR. +Старый backend может игнорировать query params: Angular проверяет DTO и scope перед +включением export и повторно непосредственно перед XLSX. Fallback на legacy list/export запрещён. +List и export выпускаются одной backend-версией. Migration не требуется. +Merge, проверку dev и решение о production выполняет пользователь. diff --git a/docs/qa/analytics-case-drilldown-dev-review.md b/docs/qa/analytics-case-drilldown-dev-review.md new file mode 100644 index 00000000..52dfc813 --- /dev/null +++ b/docs/qa/analytics-case-drilldown-dev-review.md @@ -0,0 +1,52 @@ +# Review backend после переноса на dev + +Дата: 28.09.2026. Skill: procollab-review. **READY FOR ANGULAR**. + +Сравнение: origin/dev `29be99d` → ветка `feature/dev-analytics-case-drilldown-backend`. +Исходный implementation сохранён commit `608bf09` в первоначальной ветке; +перенос от dev — commit `0ad79b3`. История master в dev не переносилась. +Deployed SHA не подтверждался. + +Повторно проверены specification/AC01–AC19, Q1–Q4, [API contract](../analytics-case-drilldown-api.md), +полный diff, модели, permissions, существующие dev analytics/filter/export consumers и тесты. +Общий classifier теперь находится в существующем dev `services/case_analytics.py`; +сохраняется `build_case_analytics` и dev endpoint `manager-overview`. +List и export расширены opt-in ветками; остальной views diff — необходимые imports. +Новых моделей, endpoints, permissions, migration или frontend changes нет. + +## Findings + +BLOCKER / MAJOR / MINOR после повторной проверки не обнаружены. + +NOTE из предыдущего review частично устранён: заголовки нового XLSX теперь проверяются +literal списком из specification, без импорта константы exporter. +Legacy export дополнительно сравнивается с прежним builder; независимые существующие +`test_exports` проверяют legacy контракт. Это не объявлено новой полной приёмкой Angular. + +## Фактически выполнено + +- 187 feature/regression tests на отдельном PostgreSQL 18 ICU und — PASS, 45.647 s; + точная команда из девяти test modules приведена в API contract. +- После усиления workbook assertions: `test_project_case_drilldown` — 13 PASS, 6.681 s. + Это повторные тесты, не ещё 13 уникальных сценариев. +- flake8 шести изменённых Python-файлов — PASS. +- `manage.py check` — 0 issues. +- `manage.py makemigrations --check --dry-run` — No changes detected. +- `git diff --check` — PASS после удаления лишней пустой строки документа. +- Query capture: overview 4 SQL; list/export одинаковое число запросов на 1/100 строках, + <=10 / <=7; сериализатор 100 строк — 0 дополнительных SQL, writes отсутствуют. + +Первый regression-прогон на collation C дал ошибки существующего кириллического поиска; +повтор на Unicode collation прошёл. Код поиска ради локальной среды не изменён. + +## AC и ограничения + +Backend evidence подтверждено повторным прогоном для AC02–09, AC10 (server search/page), +AC11–13, AC14 (ответы/ошибки), AC17–19. Полные AC01, AC15, AC16 и UI-части остальных +требуют Angular/browser evidence в связанном PR; старый backend-only audit не подменяет его. +Staff API-доступ сохранён, расширение Angular gate не разрешено. + +Локально не запускались полный backend suite, PostgreSQL 15 CI и image build. +Не проверялись live data/production SHA и production-scale время/память XLSX. +GitHub CI после публикации PR фиксируется отдельно. +Merge/deploy не выполнялись; порядок — backend → проверка dev API → Angular. diff --git a/partner_programs/serializers/project_case_drilldown.py b/partner_programs/serializers/project_case_drilldown.py index 7f0c4cf7..50aded3e 100644 --- a/partner_programs/serializers/project_case_drilldown.py +++ b/partner_programs/serializers/project_case_drilldown.py @@ -1,4 +1,4 @@ -"""Opt-in manager contracts; the public/legacy project serializer stays unchanged.""" +"""Opt-in контракт менеджера, отдельный от прежнего сериализатора проектов.""" from rest_framework import serializers @@ -29,8 +29,8 @@ def to_internal_value(self, data): errors[key] = "Укажите параметр только один раз." if errors: raise serializers.ValidationError(errors) - # QueryDict is treated as an HTML form by DRF: a supplied empty integer - # otherwise becomes a missing field and silently takes its default. + # DRF считает QueryDict HTML-формой: пустой integer иначе становится + # отсутствующим полем и незаметно получает default. return super().to_internal_value({key: data[key] for key in data}) def validate(self, attrs): diff --git a/partner_programs/services/case_analytics.py b/partner_programs/services/case_analytics.py index bc8a9f5b..128f09ad 100644 --- a/partner_programs/services/case_analytics.py +++ b/partner_programs/services/case_analytics.py @@ -1,4 +1,4 @@ -"""Read-only case analytics for legacy Project links in one program.""" +"""Read-only аналитика кейсов по связям Project × Program.""" from django.db.models import Case, Count, F, OuterRef, Subquery, TextField, Value, When @@ -23,10 +23,10 @@ def _empty_metrics(): def project_case_links(program, *, field): - """Classify every Project × Program link against exact current options. + """Классифицирует связи по точному совпадению с текущими options. - NULL is the typed without_case bucket, including missing/blank/obsolete - values. Overview, list and export all use this expression without writes. + NULL обозначает without_case, включая отсутствующие/пустые/устаревшие + значения. Overview, список и экспорт используют одно выражение без writes. """ options = field.get_options_list() if field else [] case_value = ( @@ -53,11 +53,11 @@ def project_case_links(program, *, field): def build_case_analytics(program, *, field=_UNSET) -> dict: - """Group current-program links by exact current system case options. + """Группирует связи выбранной программы по текущим options системного case. - 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. + Четыре ограниченных SELECT загружают definition, счётчики связей, + зарегистрированных лидеров и участников. Связь входит в один bucket, + участники уникальны внутри каждого bucket. """ if field is _UNSET: field = get_program_case_field(program) diff --git a/partner_programs/services/exports.py b/partner_programs/services/exports.py index fb40351e..a2ddb7e4 100644 --- a/partner_programs/services/exports.py +++ b/partner_programs/services/exports.py @@ -361,14 +361,14 @@ def _literal_excel_cell(worksheet, value): value = sanitize_excel_value(value) cell = WriteOnlyCell(worksheet, value=value) if isinstance(value, str): - # openpyxl infers formulas/error cells from leading '=' / '#N/A'. - # Keep exact sanitized user text without adding an apostrophe or a formula. + # openpyxl выводит тип formula/error по '=' / '#N/A'. + # Явный string сохраняет исходный текст без апострофа или формулы. cell.data_type = "s" return cell def build_case_projects_export_file(*, program, field, selection) -> ProgramExportFile: - """Export the full bucket; pagination/search never enter this contract.""" + """Выгружает весь bucket; pagination/search в этот контракт не входят.""" workbook = Workbook(write_only=True) worksheet = workbook.create_sheet(title="Проекты") worksheet.append(CASE_PROJECT_COLUMNS) diff --git a/partner_programs/services/project_case_drilldown.py b/partner_programs/services/project_case_drilldown.py index 721c9114..04e054fc 100644 --- a/partner_programs/services/project_case_drilldown.py +++ b/partner_programs/services/project_case_drilldown.py @@ -1,4 +1,4 @@ -"""Read-only, program-scoped projection shared by the case list and XLSX.""" +"""Read-only проекция связей выбранной программы для списка кейса и XLSX.""" from django.db.models import Count, IntegerField, OuterRef, Subquery, Value from django.db.models.functions import Coalesce @@ -40,8 +40,8 @@ def case_project_rows(program, *, field, selection): "project__leader__last_name", ) .annotate( - # Keep the legacy export definition: leader + collaborator rows, - # not unique registered participants from the bucket metrics. + # Сохраняем legacy export: лидер + строки Collaborator. + # Это не число уникальных зарегистрированных участников bucket. team_size=Coalesce( Subquery(collaborators, output_field=IntegerField()), Value(0) ) diff --git a/partner_programs/tests/test_project_case_drilldown.py b/partner_programs/tests/test_project_case_drilldown.py index ef19d120..91dc4bbe 100644 --- a/partner_programs/tests/test_project_case_drilldown.py +++ b/partner_programs/tests/test_project_case_drilldown.py @@ -1,4 +1,4 @@ -"""Contract, security, XLSX and query-budget tests for opt-in case analytics.""" +"""Контракт, права, XLSX и число запросов нового режима аналитики кейсов.""" import io from urllib.parse import parse_qs, unquote, urlsplit @@ -19,7 +19,6 @@ from partner_programs.serializers.project_case_drilldown import ProjectCaseRowSerializer from partner_programs.services.exports import ( BASE_COLUMNS, - CASE_PROJECT_COLUMNS, build_program_projects_export_file, ) from partner_programs.services.case_analytics import build_case_analytics @@ -72,7 +71,7 @@ def link( submitted=submitted, ) if value is not None: - # Historical invalid values must remain readable, without relaxing writes. + # Исторические невалидные значения читаются без ослабления правил записи. PartnerProgramFieldValue.objects.bulk_create( [ PartnerProgramFieldValue( @@ -98,7 +97,21 @@ def workbook(self, *args, **kwargs): self.assertEqual(book.sheetnames, ["Проекты"]) self.addCleanup(book.close) rows = list(book.active.iter_rows(values_only=True)) - self.assertEqual(list(rows[0]), CASE_PROJECT_COLUMNS) + self.assertEqual( + list(rows[0]), + [ + "№", + "Название проекта", + "Кейс", + "Ссылка на презентацию", + "Лидер", + "Регион", + "Размер команды", + "Сдача решения", + "Дата привязки к программе", + "Дата сдачи решения", + ], + ) return book, rows[1:], response def test_selected_projection_and_full_bucket_metrics(self): @@ -164,8 +177,8 @@ def test_selected_projection_and_full_bucket_metrics(self): "full_name": "Program User", }, ) - # Project creation also inserts the leader as a collaborator. Preserve - # the existing export formula (1 + rows), including that stored row. + # Создание Project также добавляет лидера в Collaborator. Сохраняем + # формулу существующего экспорта (1 + строки), включая эту запись. self.assertEqual(row["team_size"], 4) self.assertTrue(row["submitted"]) self.assertEqual(parse_datetime(row["submitted_at"]), link.datetime_submitted) @@ -283,7 +296,7 @@ def test_same_project_in_two_programs_has_independent_case_submission_and_dates( self.assertEqual(rows_a[0][7], "Сдано") self.assertEqual(rows_b[0][7], "Не сдано") self.assertIsNone(rows_b[0][9]) - # Even historically corrupt values from a different program are ignored. + # Даже исторически некорректные значения другой программы игнорируются. self.link("A", field=other_field) self.assertEqual(self.page("without_case")["count"], 1)