feat(export): PPTX export — completes EPIC #959 #1111
6 changed files with 654 additions and 6 deletions
|
|
@ -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_<cad>_<YYYY-MM-DD>.<ext>`); для 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"
|
||||
|
|
|
|||
353
backend/app/services/exporters/report_pptx.py
Normal file
353
backend/app/services/exporters/report_pptx.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
205
backend/tests/services/exporters/test_report_pptx.py
Normal file
205
backend/tests/services/exporters/test_report_pptx.py
Normal file
|
|
@ -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
|
||||
26
backend/uv.lock
generated
26
backend/uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue