From 40b1f3ad69a5eddd92e1c778978f9c3dfbef3ee4 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Sun, 7 Jun 2026 14:09:35 +0500 Subject: [PATCH] =?UTF-8?q?feat(export):=20add=20PPTX=20presentation=20exp?= =?UTF-8?q?ort=20of=20=C2=A722=20forecast=20report=20(#959)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes EPIC #959 export formats (md/json/tg/docx/pptx; PDF/XLSX pre-existing). render_report_pptx (python-pptx, pure + graceful, reuses report_pdf helpers, local import) → 6-slide deck. Endpoint format=pptx (early-return, presentationml media-type, .pptx attachment). Add python-pptx + regenerate uv.lock (python-pptx + xlsxwriter; uv sync --frozen passes). Closes #959. --- backend/app/api/v1/parcels.py | 26 +- backend/app/services/exporters/report_pptx.py | 353 ++++++++++++++++++ backend/pyproject.toml | 1 + backend/tests/api/v1/test_parcels_forecast.py | 49 ++- .../services/exporters/test_report_pptx.py | 205 ++++++++++ backend/uv.lock | 26 ++ 6 files changed, 654 insertions(+), 6 deletions(-) create mode 100644 backend/app/services/exporters/report_pptx.py create mode 100644 backend/tests/services/exporters/test_report_pptx.py diff --git a/backend/app/api/v1/parcels.py b/backend/app/api/v1/parcels.py index e31201a4..5862b6e2 100644 --- a/backend/app/api/v1/parcels.py +++ b/backend/app/api/v1/parcels.py @@ -49,6 +49,7 @@ from app.services.exporters.report_md import ( render_report_markdown, render_report_telegram_summary, ) +from app.services.exporters.report_pptx import render_report_pptx from app.services.exporters.snapshot_pdf import generate_snapshot_pdf from app.services.site_finder.best_layouts import get_best_layouts from app.services.site_finder.cadastre_fetch import ( @@ -1324,14 +1325,14 @@ def export_parcel_forecast( cad_num: str, db: Annotated[Session, Depends(get_db)], format: Annotated[ - Literal["md", "json", "tg", "docx"], + Literal["md", "json", "tg", "docx", "pptx"], Query( description="Формат выгрузки: md (Markdown) | json (сырой отчёт) | " - "tg (сводка) | docx (Word-документ)" + "tg (сводка) | docx (Word-документ) | pptx (презентация)" ), ] = "md", ) -> Response: - """Экспорт §22-форсайта участка — Markdown / JSON / DOCX (файл) или TG-сводка (#959). + """Экспорт §22-форсайта — Markdown / JSON / DOCX / PPTX (файл) или TG-сводка (#959). Читает ПОСЛЕДНИЙ §22-ран schema_version "1.0" (тот же блоб, что отдаёт GET /{cad}/forecast inline) и отдаёт его в нужной форме. @@ -1339,6 +1340,8 @@ def export_parcel_forecast( • format=json → сырой `run.result` — attachment (.json, download-вариант inline-чтения). • format=docx → `render_report_docx(run.result)` — attachment (.docx, Word-документ, зеркало содержания md/pdf; python-docx). + • format=pptx → `render_report_pptx(run.result)` — attachment (.pptx, презентация, + сжатое зеркало содержания docx; python-pptx). • format=tg → `render_report_telegram_summary(run.result)` — КРАТКАЯ plain-text сводка для копипаста в Telegram, INLINE (без Content-Disposition: это сниппет читать/копировать и отправить, а не файл-download). @@ -1350,10 +1353,10 @@ def export_parcel_forecast( Args: cad_num: кадастровый номер участка (в имени файла `:` → `_`). - format: "md" (default) | "json" | "tg" | "docx". + format: "md" (default) | "json" | "tg" | "docx" | "pptx". Returns: - Response — для md/json/docx attachment с Content-Disposition (имя + Response — для md/json/docx/pptx attachment с Content-Disposition (имя `gendesign_forecast__.`); для tg — inline text/plain сводка. """ run = latest_run_for(db, cad_num, schema_version=_FORECAST_SCHEMA_VERSION) @@ -1384,6 +1387,19 @@ def export_parcel_forecast( }, ) + # pptx — байты (PowerPoint OOXML), отдельной веткой (зеркало docx early-return), + # чтобы md/json string-ветки остались байт-в-байт прежними. + if format == "pptx": + return Response( + content=render_report_pptx(run.result), + media_type=( + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ), + headers={ + "Content-Disposition": f'attachment; filename="{base_name}.pptx"' + }, + ) + if format == "json": payload = json.dumps(run.result, ensure_ascii=False, default=str) media_type = "application/json" diff --git a/backend/app/services/exporters/report_pptx.py b/backend/app/services/exporters/report_pptx.py new file mode 100644 index 00000000..bffd34c0 --- /dev/null +++ b/backend/app/services/exporters/report_pptx.py @@ -0,0 +1,353 @@ +"""§22 PPTX-экспортёр итогового советующего отчёта Site Finder v2 (python-pptx). + +#959 (EPIC export) — ПОСЛЕДНИЙ формат набора выгрузки §22-форсайта (md/json/tg/docx +уже есть, этот завершает EPIC): рендерит `SiteFinderReport` (#987) в презентацию +(.pptx) и возвращает БАЙТЫ — готовые для +`Response(media_type="application/vnd.openxmlformats-officedocument.presentationml.presentation")`. +Аддитивный двойник DOCX/PDF/Markdown-экспортёров (`report_docx` / `report_pdf` / +`report_md`): то же содержание, НО presentation-форма — это колода слайдов, а не +документ, поэтому секции СЖАТЫ до ключевого (титул + 6 содержательных слайдов), без +длинных таблиц на каждый под-блок. + +DRY: PURE-хелперы нормализации/форматирования НЕ дублируем — импортируем из +`report_pdf` (там они уже есть: `_normalize` — приём инстанса ИЛИ `as_dict()`-словаря, +`_fmt` — строковое форматирование значения/None→"—", `_level_ru`, `_as_dict`/`_as_list`, +`_scenario_deficit_index`) + переиспользуем его named-константы +(`_DASH`/`_NO_DATA`/`_ADVISORY_MARKER`). Здесь добавлены ТОЛЬКО pptx-специфичные +микро-билдеры (слайды/буллеты/таблица python-pptx). + +ДЕТЕРМИНИРОВАННЫЙ, БЕЗ LLM, БЕЗ БД/сети: только ПОТРЕБЛЯЕТ уже-собранный отчёт (его +наполняет сборщик #988) и раскладывает по слайдам. Принимает КАК `SiteFinderReport`- +инстанс, ТАК и его `as_dict()`-словарь (нормализуется через `_normalize`). + +GRACEFUL (дух всего форсайт-стека): частичный/пустой/мусорный отчёт ВАЛИДЕН — пустой +блок рисует «нет данных», экспортёр НИКОГДА не падает (нет KeyError на тонком отчёте) +и возвращает минимальную валидную .pptx. Отчёт СОВЕТУЮЩИЙ: на титульном слайде — +заметный advisory-дисклеймер (оценка не основание для инвест-решения). + +`python-pptx` (PyPI `python-pptx`, import `pptx`) — чистый Python (lxml/Pillow), без +нативных системных библиотек: Dockerfile не трогаем. Импортируется ЛОКАЛЬНО внутри +функции (зеркало python-docx в `report_docx` / WeasyPrint в `report_pdf` — не нужен при +импорте модуля). +""" + +from __future__ import annotations + +import io +import logging +from typing import TYPE_CHECKING, Any + +from app.services.exporters.report_pdf import ( + _ADVISORY_MARKER, + _DASH, + _NO_DATA, + _as_dict, + _as_list, + _fmt, + _level_ru, + _normalize, + _scenario_deficit_index, +) + +if TYPE_CHECKING: + # Только для аннотаций: тяжёлый python-pptx импортируем ЛОКАЛЬНО в render_report_pptx + # (как python-docx в report_docx), чтобы импорт модуля был дешёвым и не требовал dep. + from pptx.presentation import Presentation as _PptxPresentation + from pptx.slide import Slide as _PptxSlide + +logger = logging.getLogger(__name__) + +# ── Named-константы: заголовки слайдов (зеркало report_docx — те же секции §22) ── + +_TITLE_DECK: str = "Site Finder v2 — прогноз" +_TITLE_SUMMARY: str = "Сводка" +_TITLE_FUTURE_MARKET: str = "Будущий рынок" +_TITLE_SCENARIOS: str = "Сценарии" +_TITLE_PRODUCT_TZ: str = "Продукт ТЗ" +_TITLE_CONFIDENCE: str = "Уверенность" + +# Краткий RU-дисклеймер (формулировка из ТЗ #959) — в подзаголовке титульного слайда. +_ADVISORY_DISCLAIMER: str = "Оценка advisory — не основание для инвест-решения." + +# Индексы дефолтных layout'ов встроенного шаблона python-pptx (стабильны): +# 0 — «Title Slide» (placeholder idx 0=title, 1=subtitle) +# 1 — «Title and Content» (placeholder idx 0=title, 1=body — буллеты) +# 5 — «Title Only» (placeholder idx 0=title — под кастомную таблицу/текст) +_LAYOUT_TITLE: int = 0 +_LAYOUT_TITLE_CONTENT: int = 1 +_LAYOUT_TITLE_ONLY: int = 5 + +# Основной продуктовый горизонт (мес) — подпись сводного дефицита сценария +# (зеркало report_docx._PRIMARY_HORIZON_MONTHS; само значение тянет _scenario_deficit_index). +_PRIMARY_HORIZON_MONTHS: int = 12 + +# Сколько USP-ниш выводить буллетами на слайд «Продукт ТЗ» (колода — держим компактно). +_USP_TOP_N: int = 4 +# Сколько факторов уверенности выводить буллетами на слайд «Уверенность». +_FACTORS_TOP_N: int = 6 + + +# ────────────────────────────────────────────────────────────────────────────── +# pptx-специфичные микро-билдеры. Принимают `prs`/`slide` python-pptx и пишут в них +# (мутируют презентацию). Хелперы нормализации/форматирования — импортированы из +# report_pdf, не дублируются (DRY). Числа/None уже причёсаны `_fmt` → str. +# ────────────────────────────────────────────────────────────────────────────── + + +def _add_title_slide(prs: _PptxPresentation, title: str, subtitle: str) -> _PptxSlide: + """Титульный слайд (layout «Title Slide»): заголовок + подзаголовок. Graceful.""" + slide = prs.slides.add_slide(prs.slide_layouts[_LAYOUT_TITLE]) + slide.shapes.title.text = title + # Подзаголовок (placeholder idx 1) есть в дефолтном layout; defensive на кастомный шаблон. + if len(slide.placeholders) > 1: + slide.placeholders[1].text = subtitle + return slide + + +def _add_bullets_slide( + prs: _PptxPresentation, title: str, bullets: list[tuple[str, Any]] +) -> _PptxSlide: + """Слайд «Title and Content»: заголовок + буллеты «метка: значение». Graceful. + + Метка — статичная RU-строка, значение — через `_fmt` (None → "—"). Пустой список → + один буллет «нет данных» (слайд всё равно валиден). Зеркало `report_docx._add_kv_lines` + (там docx-абзацы; здесь — параграфы text-frame'а content-плейсхолдера). + """ + slide = prs.slides.add_slide(prs.slide_layouts[_LAYOUT_TITLE_CONTENT]) + slide.shapes.title.text = title + body = slide.placeholders[1].text_frame + body.clear() + + if not bullets: + body.paragraphs[0].text = _NO_DATA + return slide + + for idx, (label, value) in enumerate(bullets): + # Первый параграф уже существует (после clear) — переиспользуем, дальше add_paragraph. + para = body.paragraphs[0] if idx == 0 else body.add_paragraph() + para.text = f"{label}: {_fmt(value)}" if label else _fmt(value) + para.level = 0 + return slide + + +def _add_table_slide( + prs: _PptxPresentation, title: str, headers: list[str], rows: list[list[Any]] +) -> _PptxSlide: + """Слайд «Title Only» + кастомная таблица: шапка (RU-метки) + строки (через `_fmt`). + + Пустой `rows` → одна строка-заглушка «нет данных» под шапкой (таблица валидна). + Все ячейки данных проходят `_fmt`. Зеркало `report_docx._add_table` (там docx Table; + здесь — python-pptx GraphicFrame table на отдельном слайде). Размеры — в EMU через + `pptx.util.Inches` (импортируем локально, как и сам Presentation). + """ + from pptx.util import Inches + + slide = prs.slides.add_slide(prs.slide_layouts[_LAYOUT_TITLE_ONLY]) + slide.shapes.title.text = title + + body_rows = rows if rows else [[_NO_DATA] + [_DASH] * (len(headers) - 1)] + n_rows = len(body_rows) + 1 # +1 на шапку + n_cols = len(headers) + + left, top, width, height = Inches(0.5), Inches(1.6), Inches(9.0), Inches(0.4) + table = slide.shapes.add_table(n_rows, n_cols, left, top, width, height).table + + for col_idx, header in enumerate(headers): + table.cell(0, col_idx).text = header + + for row_idx, row in enumerate(body_rows, start=1): + for col_idx in range(n_cols): + # Строка может оказаться короче шапки (defensive) — недостающее → "—". + value = row[col_idx] if col_idx < len(row) else None + table.cell(row_idx, col_idx).text = _fmt(value) + return slide + + +def _join_horizons(values: list[Any]) -> Any: + """Свернуть список горизонтов в «6, 12, 18» или None (→ `_fmt` отдаст "—"). PURE.""" + return ", ".join(str(v) for v in values) if values else None + + +# ────────────────────────────────────────────────────────────────────────────── +# Построители слайдов — по одному на содержательный слайд §22 (сжато: колода). +# Каждый graceful (пустой блок → «нет данных», не падает) и ПИШЕТ в `prs`. +# ────────────────────────────────────────────────────────────────────────────── + + +def _build_title(prs: _PptxPresentation, report: dict[str, Any]) -> None: + """Титульный слайд: «Site Finder v2 — прогноз» + cad/район/горизонты + дисклеймер.""" + meta = _as_dict(report.get("meta")) + cad = _fmt(meta.get("cad_num")) + district = _fmt(meta.get("district")) + horizons = _fmt(_join_horizons(_as_list(meta.get("horizons")))) + + subtitle = ( + f"Кадастровый номер: {cad}\n" + f"Район: {district}\n" + f"Горизонты (мес): {horizons}\n" + f"{_ADVISORY_DISCLAIMER} {_ADVISORY_MARKER}" + ) + _add_title_slide(prs, _TITLE_DECK, subtitle) + + +def _build_summary(prs: _PptxPresentation, report: dict[str, Any]) -> None: + """Слайд «Сводка»: headline + ключевые числа (deficit/MOI/overall/confidence).""" + exec_summary = _as_dict(report.get("exec_summary")) + headline = exec_summary.get("headline") + key_numbers = _as_dict(exec_summary.get("key_numbers")) + overall_conf = _level_ru(exec_summary.get("overall_confidence")) + + bullets: list[tuple[str, Any]] = [("Вывод", headline)] + bullets.extend((str(key), value) for key, value in key_numbers.items()) + bullets.append(("Общая уверенность", overall_conf)) + _add_bullets_slide(prs, _TITLE_SUMMARY, bullets) + + +def _build_future_market(prs: _PptxPresentation, report: dict[str, Any]) -> None: + """Слайд «Будущий рынок»: компактная таблица forecasts_by_horizon + давление.""" + future = _as_dict(report.get("future_market")) + forecasts = _as_list(future.get("forecasts_by_horizon")) + + headers = ["Горизонт, мес", "Дефицит", "Мес. запаса", "Ставка", "Уверенность"] + rows = [ + [ + f.get("horizon_months"), + f.get("deficit_index"), + f.get("months_of_inventory"), + f.get("rate_future"), + _level_ru(f.get("confidence")), + ] + for f in forecasts + if isinstance(f, dict) + ] + _add_table_slide(prs, _TITLE_FUTURE_MARKET, headers, rows) + + +def _build_scenarios(prs: _PptxPresentation, report: dict[str, Any]) -> None: + """Слайд «Сценарии»: base/aggressive/conservative — дефицит (12 мес) + rate_path.""" + scenarios = _as_dict(report.get("scenarios")) + by_scenario = _as_dict(scenarios.get("by_scenario")) + + bullets: list[tuple[str, Any]] = [] + for name, payload in by_scenario.items(): + data = _as_dict(payload) + rate_path = _as_dict(data.get("rate_path")) + rate_str = ( + ", ".join(f"{k}: {_fmt(v)}" for k, v in rate_path.items()) if rate_path else _DASH + ) + deficit = _fmt(_scenario_deficit_index(data)) + bullets.append( + (str(name), f"дефицит ({_PRIMARY_HORIZON_MONTHS} мес) {deficit}; ставка {rate_str}") + ) + _add_bullets_slide(prs, _TITLE_SCENARIOS, bullets) + + +def _build_product_tz(prs: _PptxPresentation, report: dict[str, Any]) -> None: + """Слайд «Продукт ТЗ»: класс + квартирография (mix) + USP-ниши (буллеты).""" + product = _as_dict(report.get("product_tz")) + mix = _as_list(product.get("mix")) + usp = _as_list(product.get("usp")) + + bullets: list[tuple[str, Any]] = [ + ("Рекомендованный класс", product.get("obj_class")), + ] + mix_str = ( + "; ".join( + f"{_fmt(m.get('bucket'))} — {_fmt(m.get('pct'))}%" for m in mix if isinstance(m, dict) + ) + or None + ) + bullets.append(("Квартирография", mix_str)) + for item in usp[:_USP_TOP_N]: + if isinstance(item, dict): + bullets.append((f"USP · {_fmt(item.get('segment'))}", item.get("usp_text"))) + _add_bullets_slide(prs, _TITLE_PRODUCT_TZ, bullets) + + +def _build_confidence(prs: _PptxPresentation, report: dict[str, Any]) -> None: + """Слайд «Уверенность»: уровень + топ-факторы (буллеты). Graceful.""" + confidence = _as_dict(report.get("confidence")) + level = _level_ru(confidence.get("level")) + factors = _as_dict(confidence.get("factors")) + + bullets: list[tuple[str, Any]] = [("Уровень", level)] + for name, payload in list(factors.items())[:_FACTORS_TOP_N]: + # Факторы #990: {name: {value, level, note}} ИЛИ плоское {name: value}. Defensive. + if isinstance(payload, dict): + detail = payload.get("note") or _level_ru(payload.get("level")) + bullets.append((str(name), detail)) + else: + bullets.append((str(name), payload)) + _add_bullets_slide(prs, _TITLE_CONFIDENCE, bullets) + + +# Реестр построителей. Порядок = порядок слайдов после титула (зеркало report_docx: +# Сводка → Будущий рынок → Сценарии → Продукт ТЗ → Уверенность; title идёт отдельно). +_SLIDE_BUILDERS: tuple[Any, ...] = ( + _build_summary, + _build_future_market, + _build_scenarios, + _build_product_tz, + _build_confidence, +) + + +# ────────────────────────────────────────────────────────────────────────────── +# Публичный API — рендер отчёта в PPTX-байты (без файлового I/O на диск). +# ────────────────────────────────────────────────────────────────────────────── + + +def render_report_pptx(report: Any) -> bytes: + """§22 Отрендерить `SiteFinderReport` (#987) в презентацию (.pptx) и вернуть БАЙТЫ. + + Титульный слайд («Site Finder v2 — прогноз» + cad/район/горизонты + advisory- + дисклеймер в подзаголовке), далее пять содержательных слайдов §22 (Сводка / Будущий + рынок / Сценарии / Продукт ТЗ / Уверенность) — содержание ЗЕРКАЛИТ + `report_docx`/`report_pdf`, но СЖАТО под формат колоды (буллеты + одна компактная + таблица прогноза). Числа форматируются через импортированный `_fmt` (None → "—"), + уровни уверенности — RU-метками. + + ДЕТЕРМИНИРОВАННО, БЕЗ LLM/БД/сети. Принимает КАК `SiteFinderReport`-инстанс, ТАК и + его `as_dict()`-словарь (нормализуется через импортированный `_normalize`). GRACEFUL: + частичный/пустой/мусорный отчёт → слайды с «нет данных», НИКОГДА не падает (нет + KeyError на тонком отчёте) — возвращает минимальную валидную .pptx. + + `python-pptx` импортируется ЛОКАЛЬНО (как python-docx в `report_docx`): не нужен при + импорте модуля. Презентация собирается в памяти (`io.BytesIO`) — без файлового I/O. + + Args: + report: `SiteFinderReport`-инстанс или его `as_dict()`-словарь (или мусор → {}). + + Returns: + Непустые PPTX-байты (OOXML — это zip, начинаются с `b"PK"`), готовые для + `Response(media_type= + "application/vnd.openxmlformats-officedocument.presentationml.presentation")`. + """ + # python-pptx импортируем локально — тяжёлый (lxml/Pillow); не нужен при импорте модуля. + try: + from pptx import Presentation + except ImportError as exc: + raise RuntimeError( + "python-pptx не установлен. Добавь 'python-pptx>=1.0.0' в pyproject.toml." + ) from exc + + data = _normalize(report) + prs = Presentation() + + _build_title(prs, data) + for builder in _SLIDE_BUILDERS: + builder(prs, data) + + buffer = io.BytesIO() + prs.save(buffer) + pptx_bytes = buffer.getvalue() + + meta = _as_dict(data.get("meta")) + logger.info( + "render_report_pptx: cad_num=%s slides=%d size=%d bytes advisory=%s", + meta.get("cad_num"), + len(_SLIDE_BUILDERS) + 1, # +1 титульный + len(pptx_bytes), + data.get("advisory"), + ) + return pptx_bytes diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 493be0c2..76ee41cd 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "ezdxf>=1.3.0", "openpyxl>=3.1.0", "python-docx>=1.1.0", # DOCX export (import name `docx`) — §22 forecast report (#959) + "python-pptx>=1.0.0", # PPTX export (import name `pptx`) — §22 forecast report (#959) "pandas>=2.2.0", "numpy>=2.0.0", "scikit-learn>=1.5.0", diff --git a/backend/tests/api/v1/test_parcels_forecast.py b/backend/tests/api/v1/test_parcels_forecast.py index 5136d5f0..019e069c 100644 --- a/backend/tests/api/v1/test_parcels_forecast.py +++ b/backend/tests/api/v1/test_parcels_forecast.py @@ -518,6 +518,53 @@ def test_export_forecast_docx_no_run_returns_404() -> None: app.dependency_overrides.clear() +def test_export_forecast_pptx_returns_presentation() -> None: + """format=pptx → 200 presentationml + attachment .pptx, тело — байты .pptx (PK).""" + from app.core.db import get_db + + fake_run = MagicMock() + fake_run.result = _REPORT_FIXTURE + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.latest_run_for", return_value=fake_run) as lrf: + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pptx") + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"] == ( + "application/vnd.openxmlformats-officedocument.presentationml.presentation" + ) + cd = resp.headers["content-disposition"] + assert "attachment;" in cd + assert cd.endswith('.pptx"') + # cad-двоеточия санитизированы в имени файла. + assert f"gendesign_forecast_{_CAD.replace(':', '_')}_" in cd + # OOXML/.pptx — zip: первые 2 байта «PK». + assert resp.content[:2] == b"PK" + # Читаем именно §22-схему "1.0". + _, kwargs = lrf.call_args + assert kwargs.get("schema_version") == "1.0" + finally: + app.dependency_overrides.clear() + + +def test_export_forecast_pptx_no_run_returns_404() -> None: + """format=pptx + рана нет → 404 (как md/json/docx export-вариант, НЕ 202/500).""" + from app.core.db import get_db + + db = MagicMock() + app.dependency_overrides[get_db] = _override_db(db) + try: + with patch("app.api.v1.parcels.latest_run_for", return_value=None): + client = TestClient(app) + resp = client.get(f"/api/v1/parcels/{_CAD}/forecast/export?format=pptx") + assert resp.status_code == 404, resp.text + assert "посчитан" in resp.json()["detail"] + finally: + app.dependency_overrides.clear() + + def test_export_forecast_no_run_returns_404() -> None: """Рана нет (latest_run_for → None) → 404 (download-вариант, НЕ 202/500).""" from app.core.db import get_db @@ -535,7 +582,7 @@ def test_export_forecast_no_run_returns_404() -> None: def test_export_forecast_invalid_format_returns_422() -> None: - """format вне {md,json} → 422 (Literal-валидация ДО работы с БД).""" + """format вне {md,json,tg,docx,pptx} → 422 (Literal-валидация ДО работы с БД).""" from app.core.db import get_db db = MagicMock() diff --git a/backend/tests/services/exporters/test_report_pptx.py b/backend/tests/services/exporters/test_report_pptx.py new file mode 100644 index 00000000..43fba9ae --- /dev/null +++ b/backend/tests/services/exporters/test_report_pptx.py @@ -0,0 +1,205 @@ +"""Unit-тесты §22 PPTX-экспортёра (#959, EPIC export) — `render_report_pptx`. + +Чистые тесты БЕЗ БД/сети (экспортёр только ПОТРЕБЛЯЕТ уже-собранный отчёт): + • реальный fixture-отчёт (frontend mock `parcel-forecast.json` → `report`) рендерится + в НЕПУСТЫЕ байты, начинающиеся с `b"PK"` (OOXML/.pptx — это zip-контейнер); + • презентация открываемая python-pptx и несёт заголовки слайдов + advisory-дисклеймер + + кадастр из meta; + • экспортёр принимает КАК `SiteFinderReport`-инстанс, ТАК и его `as_dict()`-словарь; + • пустой / частичный / мусорный отчёт → валидная минимальная .pptx БЕЗ падения (graceful). + +Детерминированно, без LLM. DATABASE_URL выставляем до импорта app-модулей (зеркало +test_report_docx.py) — на случай side-effect'ов импорта пакета. +""" + +from __future__ import annotations + +import io +import json +import os +from pathlib import Path +from typing import Any + +os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") + +from pptx import Presentation + +from app.services.exporters.report_pptx import ( + _ADVISORY_DISCLAIMER, + _TITLE_CONFIDENCE, + _TITLE_DECK, + _TITLE_FUTURE_MARKET, + _TITLE_PRODUCT_TZ, + _TITLE_SCENARIOS, + _TITLE_SUMMARY, + render_report_pptx, +) +from app.services.forecasting.report import ( + ReportExecSummary, + ReportMeta, + SiteFinderReport, +) + +# Реальный fixture: frontend mock §22-форсайта — берём вложенный `report` (форма #987 +# `as_dict()`). Так тест не разойдётся с реальной формой отчёта. +_FIXTURE_PATH = ( + Path(__file__).resolve().parents[4] + / "frontend" + / "src" + / "lib" + / "mocks" + / "parcel-forecast.json" +) + +# OOXML / .pptx — это zip-контейнер: первые два байта — сигнатура zip «PK». +_ZIP_SIGNATURE = b"PK" + +_EXPECTED_TITLES: tuple[str, ...] = ( + _TITLE_DECK, + _TITLE_SUMMARY, + _TITLE_FUTURE_MARKET, + _TITLE_SCENARIOS, + _TITLE_PRODUCT_TZ, + _TITLE_CONFIDENCE, +) + + +def _fixture_report() -> dict[str, Any]: + """`report`-словарь из frontend-мока §22 (форма SiteFinderReport.as_dict()).""" + data = json.loads(_FIXTURE_PATH.read_text(encoding="utf-8")) + report = data["report"] + assert isinstance(report, dict) + return report + + +def _pptx_text(pptx_bytes: bytes) -> str: + """Весь текст презентации (текст всех shape'ов + ячеек таблиц) одной строкой.""" + prs = Presentation(io.BytesIO(pptx_bytes)) + parts: list[str] = [] + for slide in prs.slides: + for shape in slide.shapes: + if shape.has_text_frame: + parts.append(shape.text_frame.text) + if shape.has_table: + for row in shape.table.rows: + parts.extend(cell.text for cell in row.cells) + return "\n".join(parts) + + +# ── Реальный fixture-отчёт: байты-сигнатура + структура + ключевые значения ────── + + +class TestFixtureReportRender: + def test_returns_non_empty_bytes(self) -> None: + out = render_report_pptx(_fixture_report()) + assert isinstance(out, bytes) + # Нетривиальная презентация — далеко не пустая. + assert len(out) > 1000 + + def test_starts_with_zip_signature(self) -> None: + # OOXML/.pptx — zip: первые 2 байта обязаны быть «PK». + out = render_report_pptx(_fixture_report()) + assert out[:2] == _ZIP_SIGNATURE + + def test_openable_by_python_pptx(self) -> None: + # Результат — валидная .pptx (открывается python-pptx без ошибки). + out = render_report_pptx(_fixture_report()) + prs = Presentation(io.BytesIO(out)) + # Титул + пять содержательных слайдов = шесть. + assert len(prs.slides) == 6 + + def test_advisory_disclaimer_present(self) -> None: + text = _pptx_text(render_report_pptx(_fixture_report())) + assert _ADVISORY_DISCLAIMER in text + + def test_all_slide_titles_present(self) -> None: + text = _pptx_text(render_report_pptx(_fixture_report())) + for title in _EXPECTED_TITLES: + assert title in text, f"отсутствует заголовок слайда «{title}»" + + def test_meta_cad_num_present(self) -> None: + text = _pptx_text(render_report_pptx(_fixture_report())) + # Кадастр из meta фикстуры попадает на титульный слайд. + assert "66:41:0702048:27" in text + + def test_headline_present(self) -> None: + report = _fixture_report() + headline = report["exec_summary"]["headline"] + text = _pptx_text(render_report_pptx(report)) + assert headline.split(".")[0] in text + + +# ── Вход: dataclass-инстанс ИЛИ as_dict()-словарь ─────────────────────────────── + + +class TestInputForms: + def test_accepts_dataclass_instance(self) -> None: + report = SiteFinderReport( + exec_summary=ReportExecSummary(headline="Строить комфорт-класс."), + meta=ReportMeta(cad_num="66:41:0000000:1", horizons=[12]), + ) + text = _pptx_text(render_report_pptx(report)) + assert "Строить комфорт" in text + assert "66:41:0000000:1" in text + + def test_dataclass_and_dict_equivalent_text(self) -> None: + # Байты могут расходиться (zip-метаданные/порядок), но ТЕКСТ — идентичен. + report = SiteFinderReport( + exec_summary=ReportExecSummary(headline="Один заголовок."), + meta=ReportMeta(cad_num="66:41:0000000:9"), + ) + assert _pptx_text(render_report_pptx(report)) == _pptx_text( + render_report_pptx(report.as_dict()) + ) + + +# ── Graceful: пустой / частичный / мусорный вход → валидная .pptx без падения ──── + + +class TestGraceful: + def test_empty_dict_does_not_crash(self) -> None: + # Пустой отчёт {} → валидная .pptx (no KeyError), сигнатура PK, есть дисклеймер. + out = render_report_pptx({}) + assert isinstance(out, bytes) + assert out[:2] == _ZIP_SIGNATURE + text = _pptx_text(out) + assert _ADVISORY_DISCLAIMER in text + assert "нет данных" in text + # Все слайды всё равно отрисованы (стабильный контракт). + for title in _EXPECTED_TITLES: + assert title in text + + def test_none_does_not_crash(self) -> None: + # None → нормализуется в {} → валидная минимальная .pptx, не падает. + out = render_report_pptx(None) + assert isinstance(out, bytes) + assert out[:2] == _ZIP_SIGNATURE + assert _ADVISORY_DISCLAIMER in _pptx_text(out) + + def test_default_report_does_not_crash(self) -> None: + # Полностью дефолтный (пустой) SiteFinderReport — валидная .pptx. + out = render_report_pptx(SiteFinderReport()) + assert out[:2] == _ZIP_SIGNATURE + text = _pptx_text(out) + assert _ADVISORY_DISCLAIMER in text + assert "нет данных" in text + + def test_garbage_input_returns_valid_pptx(self) -> None: + # Мусор (int / строка / list) → нормализуется в {} → валидная .pptx, не падает. + for junk in (123, "not a report", []): + out = render_report_pptx(junk) + assert isinstance(out, bytes) + assert out[:2] == _ZIP_SIGNATURE + + def test_partial_report_some_sections(self) -> None: + report = SiteFinderReport( + exec_summary=ReportExecSummary(headline="Тонкий анализ — только заголовок."), + meta=ReportMeta(cad_num="66:41:0000000:2", horizons=[12]), + ) + out = render_report_pptx(report) + assert out[:2] == _ZIP_SIGNATURE + text = _pptx_text(out) + assert "Тонкий анализ" in text + assert "66:41:0000000:2" in text + # Незаполненные слайды рисуют «нет данных», но .pptx валидна. + assert "нет данных" in text diff --git a/backend/uv.lock b/backend/uv.lock index d4e54132..8b18a7f2 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -579,6 +579,7 @@ dependencies = [ { name = "pydantic-settings" }, { name = "pyproj" }, { name = "python-docx" }, + { name = "python-pptx" }, { name = "pyyaml" }, { name = "redis" }, { name = "rosreestr2coord" }, @@ -622,6 +623,7 @@ requires-dist = [ { name = "pydantic-settings", specifier = ">=2.3.0" }, { name = "pyproj", specifier = ">=3.6.0" }, { name = "python-docx", specifier = ">=1.1.0" }, + { name = "python-pptx", specifier = ">=1.0.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, { name = "redis", specifier = ">=5.0.0" }, { name = "rosreestr2coord", specifier = ">=5.0.0" }, @@ -1860,6 +1862,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-pptx" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "pillow" }, + { name = "typing-extensions" }, + { name = "xlsxwriter" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297, upload-time = "2024-08-07T17:33:37.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788, upload-time = "2024-08-07T17:33:28.192Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -2590,6 +2607,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] +[[package]] +name = "xlsxwriter" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" }, +] + [[package]] name = "zopfli" version = "0.4.1" -- 2.45.3