feat(sf-b7): GET /parcels/{cad}/snapshot.pdf — 1-page WeasyPrint export #334

Merged
lekss361 merged 1 commit from feat/sf-b7-snapshot-pdf into main 2026-05-17 21:11:04 +00:00
Owner

Summary

SF Wave 1 / Group B / sub-task B7. NEW snapshot PDF export.

Files

  • backend/app/services/exporters/snapshot_pdf.py — NEW WeasyPrint + Jinja2 generator
  • backend/app/templates/parcel_snapshot.html — NEW A4 template
  • backend/app/api/v1/parcels.py — NEW endpoint GET /{cad_num}/snapshot.pdf
  • backend/pyproject.toml + uv.lock — добавлен jinja2>=3.1.0

Content (1-page A4)

  1. Header — cad_num, address, district, area
  2. 5 KPI: площадь, кадастровая стоимость, разрешённое использование, ВРИ, last update
  3. Top-7 POI (weighted score formula matching B6)
  4. Top 5 competitors (3km radius)
  5. Footer — gendsgn.ru + timestamp

Dependencies

  • DejaVu Sans font auto-detected из 3 системных путей (/usr/share/fonts/truetype/dejavu/)
  • WeasyPrint lazy import
  • jinja2 — new dep

Possible conflict

Этот PR + B5 #332 + B6 #333 — все меняют parcels.py. Sequential merge через rebase.

Test plan

  • GET /api/v1/parcels/66:41:0204016:10/snapshot.pdf → valid PDF
  • Cyrillic renders correctly (DejaVu Sans loaded)

Part of plan vault code/patterns/SiteFinder_Backend_Migration_Plan_May17.md §B7.

## Summary SF Wave 1 / Group B / sub-task **B7**. NEW snapshot PDF export. ## Files - `backend/app/services/exporters/snapshot_pdf.py` — NEW WeasyPrint + Jinja2 generator - `backend/app/templates/parcel_snapshot.html` — NEW A4 template - `backend/app/api/v1/parcels.py` — NEW endpoint `GET /{cad_num}/snapshot.pdf` - `backend/pyproject.toml` + `uv.lock` — добавлен `jinja2>=3.1.0` ## Content (1-page A4) 1. Header — cad_num, address, district, area 2. 5 KPI: площадь, кадастровая стоимость, разрешённое использование, ВРИ, last update 3. Top-7 POI (weighted score formula matching B6) 4. Top 5 competitors (3km radius) 5. Footer — gendsgn.ru + timestamp ## Dependencies - DejaVu Sans font auto-detected из 3 системных путей (`/usr/share/fonts/truetype/dejavu/`) - WeasyPrint lazy import - jinja2 — new dep ## Possible conflict Этот PR + B5 #332 + B6 #333 — все меняют `parcels.py`. Sequential merge через rebase. ## Test plan - [ ] `GET /api/v1/parcels/66:41:0204016:10/snapshot.pdf` → valid PDF - [ ] Cyrillic renders correctly (DejaVu Sans loaded) Part of plan vault `code/patterns/SiteFinder_Backend_Migration_Plan_May17.md` §B7.
lekss361 added 1 commit 2026-05-17 21:08:12 +00:00
Add 1-page A4 snapshot PDF endpoint for cadastral parcels (SF task B7).

- New service: app/services/exporters/snapshot_pdf.py — WeasyPrint +
  Jinja2 renderer with DejaVu Sans Cyrillic font support
- New template: app/templates/parcel_snapshot.html — styled A4 layout
  with header, KPI block, POI top-7 table, competitors top-5 table, footer
- New endpoint GET /parcels/{cad}/snapshot.pdf in parcels.py — fetches
  parcel meta (cad_parcels), geometry (cad_parcels_geom), POI (osm_poi_ekb
  1km), competitors (domrf_kn_objects 3km), district (ekb_districts),
  renders PDF and returns application/pdf attachment
- Add jinja2>=3.1.0 to pyproject.toml + uv.lock

Disclaimer included: not an official EGRN extract.
Author
Owner

Deep Code Review — verdict: APPROVE

Files: 5 (+646/-0) — snapshot_pdf.py (NEW), parcel_snapshot.html (NEW), parcels.py, pyproject.toml, uv.lock.

Security

  • Jinja2 autoescape: enabled correctly. Environment(autoescape=select_autoescape(["html"])) + template name ends in .html → autoescape triggers. All user-controlled fields (cad_num, address, district, vri, land_category, poi.name, comm_name, dev_name) rendered via {{ }} without |safe. XSS-safe.
  • No SQL injection: all params use :bind + CAST(:c AS text) (psycopg v3 convention).
  • No hardcoded secrets, no print(), no psycopg2, no requests.

POI scoring dependency

  • _POI_WEIGHTS / _CATEGORY_RU duplicated inline in snapshot_pdf.py (lines 28-54) with explicit comment "exporter не импортирует из api-слоя". No dependency on PR #333 (B6) — this PR is independent. Acceptable; future follow-up: extract to app/services/site_finder/poi_weights.py and import from both places after #333 merges.

Correctness

  • WeasyPrint lazy import (inside function body).
  • Font fallback: _find_font_url() returns "" if DejaVu missing → @font-face CSS becomes invalid → WeasyPrint falls back to Arial, sans-serif from font stack. Graceful.
  • Sync endpoint + sync db.execute() — consistent with other exporters (trade_in_pdf, layout_tz_pdf).
  • 4 separate queries (parcel meta, geom, POI, competitors, district) — no N+1, single-shot each.
  • Try/except around PDF gen with 500 + logger .
  • Auth unauthenticated — matches existing PDF exporters pattern.

Minor nits (non-blocking, follow-up)

  1. Template typo parcel_snapshot.html header: Данные НСПД / ЕГРНsource: cad_parcels — missing space (ЕГРН source).
  2. — HTML entities in {{ x or '—' }} will be double-escaped by autoescape → user sees literal — instead of em-dash. Replace with (UTF-8 character) directly: {{ x or "—" }}.
  3. area_m2 = 0 edge case: area_m2 / 10_000 if area_m2 else "—" treats 0 as falsy → renders . Should be if area_m2 is not None (matches the cadastral_cost_rub is not None style used elsewhere in same call).
  4. Dedupe _POI_WEIGHTS after PR #333 merges — extract to shared module.

Verdict: APPROVE. Merging via squash.

## Deep Code Review — verdict: APPROVE **Files:** 5 (+646/-0) — `snapshot_pdf.py` (NEW), `parcel_snapshot.html` (NEW), `parcels.py`, `pyproject.toml`, `uv.lock`. ### Security - **Jinja2 autoescape: enabled correctly.** `Environment(autoescape=select_autoescape(["html"]))` + template name ends in `.html` → autoescape triggers. All user-controlled fields (`cad_num`, `address`, `district`, `vri`, `land_category`, `poi.name`, `comm_name`, `dev_name`) rendered via `{{ }}` without `|safe`. XSS-safe. - No SQL injection: all params use `:bind` + `CAST(:c AS text)` (psycopg v3 convention). - No hardcoded secrets, no `print()`, no `psycopg2`, no `requests`. ### POI scoring dependency - `_POI_WEIGHTS` / `_CATEGORY_RU` **duplicated inline** in `snapshot_pdf.py` (lines 28-54) with explicit comment "exporter не импортирует из api-слоя". **No dependency on PR #333 (B6)** — this PR is independent. Acceptable; future follow-up: extract to `app/services/site_finder/poi_weights.py` and import from both places after #333 merges. ### Correctness - WeasyPrint lazy import ✅ (inside function body). - Font fallback: `_find_font_url()` returns `""` if DejaVu missing → `@font-face` CSS becomes invalid → WeasyPrint falls back to `Arial, sans-serif` from font stack. Graceful. - Sync endpoint + sync `db.execute()` — consistent with other exporters (`trade_in_pdf`, `layout_tz_pdf`). - 4 separate queries (parcel meta, geom, POI, competitors, district) — no N+1, single-shot each. - Try/except around PDF gen with 500 + logger ✅. - Auth unauthenticated — matches existing PDF exporters pattern. ### Minor nits (non-blocking, follow-up) 1. **Template typo** `parcel_snapshot.html` header: `Данные НСПД / ЕГРНsource: cad_parcels` — missing space (`ЕГРН source`). 2. **`—` HTML entities** in `{{ x or '—' }}` will be **double-escaped** by autoescape → user sees literal `—` instead of em-dash. Replace with `—` (UTF-8 character) directly: `{{ x or "—" }}`. 3. **`area_m2 = 0` edge case**: `area_m2 / 10_000 if area_m2 else "—"` treats `0` as falsy → renders `—`. Should be `if area_m2 is not None` (matches the `cadastral_cost_rub is not None` style used elsewhere in same call). 4. **Dedupe `_POI_WEIGHTS`** after PR #333 merges — extract to shared module. Verdict: **APPROVE**. Merging via squash.
lekss361 merged commit 4040cba4c0 into main 2026-05-17 21:11:04 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#334
No description provided.