feat(export): add PPTX presentation export of §22 forecast report (#959)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 8s
CI / backend-tests (push) Has been skipped
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 2m58s
Deploy / build-worker (push) Successful in 3m26s
Deploy / deploy (push) Successful in 1m26s
CI / backend-tests (pull_request) Successful in 6m7s

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.
This commit is contained in:
Light1YT 2026-06-07 14:09:35 +05:00
parent 3a89287c6b
commit 40b1f3ad69
6 changed files with 654 additions and 6 deletions

View file

@ -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"

View 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

View file

@ -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",

View file

@ -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()

View 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
View file

@ -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"