From 09c3a39fa1b56fb328ef761fb98c6f4076857502 Mon Sep 17 00:00:00 2001 From: migatu Date: Thu, 30 Jul 2026 00:48:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(prezentacja):=20eksport=20wynik=C3=B3w=20d?= =?UTF-8?q?o=20Excela=20=E2=80=94=20tabela=20robocza=20(DAN-23/PRE-10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Astrolog chce PRACOWAĆ z dopasowaniami: filtrować, sortować, zaznaczać, usuwać — najwygodniej w Excelu. Raport z logiki jest zagnieżdżony (obiekt → fasety → próbki), więc spłaszczamy go do JEDNEJ płaskiej tabeli: wiersz = jedno dopasowanie z bazy. Kolumny: Obiekt · Faseta · Typ · Token · Sygnifikator · Rozwinięcie · Opis/efekt. - `report_export.py`: `report_to_xlsx(report)` (openpyxl). Auto-filtr + zamrożony nagłówek = filtrowanie/sortowanie od razu; „Opis" zawijany. Bez ozdób — materiał roboczy. Plik składamy TU, w prezentacji (jak PDF idzie przez render): logika liczy, prezentacja formatuje wyjście. - `/interpret` dostaje akcję `export` → pobranie `.xlsx` (nie strona). Przycisk „Pobierz Excel" obok „Szukaj interpretacji". - Zależność: openpyxl (czysty Python). Zero swissepha, zero walidacji krzyżowej, zero danych od użytkownika — pierwsza z iteracji „czysto". Testy: +7 (round-trip pliku: nagłówek, spłaszczenie, auto- filtr/zamrożenie, pusty-bezpieczny, %, wpięcie w UI). Prezentacja 180. Co-Authored-By: Claude Opus 4.8 --- services/presentation/app/main.py | 12 +++ services/presentation/app/report_export.py | 56 ++++++++++++++ .../presentation/app/templates/interpret.html | 1 + services/presentation/requirements.txt | 2 + .../presentation/tests/test_report_export.py | 76 +++++++++++++++++++ 5 files changed, 147 insertions(+) create mode 100644 services/presentation/app/report_export.py create mode 100644 services/presentation/tests/test_report_export.py diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index b77fbc0..88c821e 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -279,6 +279,18 @@ def interpret_run( try: iso_utc, label = _build_utc(date, time, tz_offset) ctx["moment"] = label + if action == "export": + # Tabela robocza do Excela (DAN-23/PRE-10) — pobranie pliku, nie strona. + from fastapi.responses import Response + + from app.report_export import report_to_xlsx + + report = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group) + return Response( + content=report_to_xlsx(report), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": 'attachment; filename="interpretacje.xlsx"'}, + ) if action == "prompt": ctx["prompt_result"] = logic.prompt( profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget, diff --git a/services/presentation/app/report_export.py b/services/presentation/app/report_export.py new file mode 100644 index 0000000..c5067a6 --- /dev/null +++ b/services/presentation/app/report_export.py @@ -0,0 +1,56 @@ +"""Eksport raportu interpretacji do Excela — „tabela robocza" (DAN-23 / PRE-10). + +Raport z logiki jest zagnieżdżony: obiekt → fasety → próbki (dopasowania z bazy). +Astrolog chce z tym PRACOWAĆ w Excelu: filtrować, sortować, zaznaczać, usuwać — +więc spłaszczamy go do JEDNEJ płaskiej tabeli (wiersz = jedno dopasowanie) z +auto-filtrem i zamrożonym nagłówkiem. Bez wykresów i ozdób — to materiał roboczy. + +Świadomie budujemy plik TU, w prezentacji (jak PDF idzie przez usługę render): +warstwa danych/logiki liczy, prezentacja formatuje wyjście. +""" +from __future__ import annotations + +import io + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font + +HEADERS = ["Obiekt", "Faseta", "Typ", "Token", "Sygnifikator", "Rozwinięcie", "Opis / efekt"] +_WIDTHS = [16, 24, 10, 20, 22, 28, 72] # „Opis" najszerszy — tam jest treść + + +def _rows(report: dict): + """Spłaszcza obiekt→fasety→próbki na wiersze płaskiej tabeli.""" + for o in report.get("objects") or []: + name = o.get("name") or "" + for f in o.get("facets") or []: + label, typ, token = f.get("label") or "", f.get("type") or "", f.get("token") or "" + for s in (f.get("samples") or []): + yield [name, label, typ, token, + s.get("significator") or "", s.get("expanded") or "", s.get("effect") or ""] + + +def report_to_xlsx(report: dict) -> bytes: + """Raport (dict z /chart/report) → bajty pliku .xlsx z jedną tabelą roboczą.""" + wb = Workbook() + ws = wb.active + ws.title = "Interpretacje" + + ws.append(HEADERS) + for c in ws[1]: + c.font = Font(bold=True) + + for row in _rows(report): + ws.append(row) + + # Auto-filtr + zamrożony nagłówek = filtrowanie/sortowanie od razu w Excelu (PRE-10). + ws.auto_filter.ref = f"A1:{chr(64 + len(HEADERS))}{ws.max_row}" + ws.freeze_panes = "A2" + for i, w in enumerate(_WIDTHS, start=1): + ws.column_dimensions[chr(64 + i)].width = w + for row in ws.iter_rows(min_row=2): # opis bywa długi — zawijamy + row[6].alignment = Alignment(wrap_text=True, vertical="top") + + buf = io.BytesIO() + wb.save(buf) + return buf.getvalue() diff --git a/services/presentation/app/templates/interpret.html b/services/presentation/app/templates/interpret.html index 07506b6..f3772ca 100644 --- a/services/presentation/app/templates/interpret.html +++ b/services/presentation/app/templates/interpret.html @@ -40,6 +40,7 @@
+ diff --git a/services/presentation/requirements.txt b/services/presentation/requirements.txt index faca6fc..7651693 100644 --- a/services/presentation/requirements.txt +++ b/services/presentation/requirements.txt @@ -5,3 +5,5 @@ jinja2>=3.1 python-multipart>=0.0.20 # Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF cryptography>=44.0 +# Eksport wyników do Excela — „tabela robocza" (DAN-23/PRE-10) +openpyxl>=3.1 diff --git a/services/presentation/tests/test_report_export.py b/services/presentation/tests/test_report_export.py new file mode 100644 index 0000000..184c15a --- /dev/null +++ b/services/presentation/tests/test_report_export.py @@ -0,0 +1,76 @@ +"""Eksport raportu do Excela — tabela robocza (DAN-23/PRE-10).""" +import io +import pathlib + +from openpyxl import load_workbook + +from app.report_export import HEADERS, report_to_xlsx + +APP = pathlib.Path(__file__).resolve().parents[1] / "app" +MAIN = (APP / "main.py").read_text(encoding="utf-8") +INTERPRET = (APP / "templates" / "interpret.html").read_text(encoding="utf-8") + + +def _report() -> dict: + return {"engine": "own", "objects": [ + {"name": "Sun", "facets": [ + {"type": "sign", "label": "w znaku Cancer", "token": "[Su + Can", "samples": [ + {"significator": "Su Can", "expanded": "Słońce w Raku", "effect": "opiekuńczy"}, + {"significator": "Su Can h10", "expanded": "Słońce w Raku, 10 dom", "effect": "kariera 50% publiczna"}]}, + {"type": "house", "label": "w 10 domu", "token": "10 H.", "samples": [ + {"significator": "Su h10", "expanded": "Słońce w 10 domu", "effect": "ambicja"}]}]}, + {"name": "Moon", "facets": [ + {"type": "aspect", "label": "trygon z Mars", "token": "[Mo tri [Ma", "samples": [ + {"significator": "Mo tri Ma", "expanded": "Księżyc trygon Mars", "effect": "energia"}]}]}, + ]} + + +def _load(report): + return load_workbook(io.BytesIO(report_to_xlsx(report))).active + + +def test_header_row_matches_columns(): + ws = _load(_report()) + assert [c.value for c in ws[1]] == HEADERS + + +def test_one_row_per_sample_flattened(): + """Zagnieżdżenie obiekt→fasety→próbki rozwijamy na płaskie wiersze.""" + ws = _load(_report()) + assert ws.max_row == 1 + 4 # nagłówek + 4 dopasowania + + +def test_row_carries_object_facet_and_effect(): + ws = _load(_report()) + vals = [[c.value for c in row] for row in ws.iter_rows(min_row=2)] + first = vals[0] + assert first[0] == "Sun" and first[1] == "w znaku Cancer" and first[2] == "sign" + assert first[6] == "opiekuńczy" # kolumna „Opis / efekt" + assert any("kariera 50% publiczna" in (r[6] or "") for r in vals) # % nie psuje niczego + + +def test_autofilter_and_frozen_header_for_working_table(): + """Filtrowanie/sortowanie od razu w Excelu (PRE-10).""" + ws = _load(_report()) + assert ws.auto_filter.ref == "A1:G5" + assert ws.freeze_panes == "A2" + + +def test_empty_report_is_safe(): + ws = _load({"objects": []}) + assert [c.value for c in ws[1]] == HEADERS + assert ws.max_row == 1 # sam nagłówek, bez wyjątku + assert ws.auto_filter.ref == "A1:G1" + + +# ── wpięcie w UI ── + +def test_interpret_has_export_button(): + assert 'name="action" value="export"' in INTERPRET + + +def test_handler_returns_xlsx_download(): + assert 'if action == "export":' in MAIN + assert "report_to_xlsx" in MAIN + assert "spreadsheetml.sheet" in MAIN # media type .xlsx + assert 'filename="interpretacje.xlsx"' in MAIN