astrololo: ekrany i eksport jako moduły, katalog z rejestracji (3/5)
Testy / Testy warstwy logicznej (silnik) (pull_request) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Failing after 3s
Testy / Testy astrodemo (pull_request) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (pull_request) Failing after 2s
Testy / Kontrola składni wszystkich warstw (pull_request) Failing after 3s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 3s
Testy / Testy astrodemo (push) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 2s
Testy / Kontrola składni wszystkich warstw (push) Failing after 3s
build / build (push) Successful in 9s

Trzeci z pięciu kroków. Zmieniłem jego zakres wobec planu i warto wiedzieć
dlaczego: pierwotnie miała to być deduplikacja pięciu kopii link_crypto.py, ale
po zrobieniu PR 2 widać, że astroklienta blokuje co innego — ekrany, których nie
ma mieć, siedzą wewnątrz jednego main.py. Deduplikacja kryptografii jest realnym
długiem, ale niczego nie blokuje.

PODZIAŁ. main.py (1023 linie) rozpadł się na podstawa.py (wspólne obiekty
i pomocnicy), jedenaście modułów w app/ekrany/ i main.py, który jest już samym
ZŁOŻENIEM: lista importów JEST definicją produktu. Podział zrobiony mechanicznie,
z osobnym sprawdzeniem, że żadna sekcja nie wołała pomocnika z innej (nie wołała).

KATALOG Z REJESTRACJI. Dotąd wszystkie funkcje były wypisane w features.py, więc
obraz produktu, który części z nich nie ma, i tak niósł ich nazwy — spis funkcji,
których nie ma jak włączyć. Teraz ekran zgłasza siebie, swoje trasy i swoje
zasoby przy imporcie własnego modułu, a features.py nie wymienia ani jednego
ekranu. Kolejność w nawigacji jest jawna (`kolejnosc`), żeby nie rządziła nią
kolejność importów.

To samo dotyczy nawigacji administratora: odsyłacz do ekranu kont był wpisany na
sztywno w base.html, więc w węższym produkcie zostawał martwy link i nazwa
ekranu, którego nie ma.

EKSPORT JAKO MODUŁ. Zgodnie z ustaleniem eksport jest funkcją administracyjną,
więc musi dać się usunąć. app/moduly/eksport/ zabiera arkusz, trasę PDF-a i akcję
formularza. „Można, ale nie temu kontu" i „nie ma takiej możliwości" to dwie
różne gwarancje, a eksport wynosi najwięcej treści baz naraz.

MOST ODKRYWA MODUŁY. Skoro modułów jest więcej niż jeden, most nie może ich znać
z nazwy — nazwa nieobecnego modułu jechałaby do obrazu, w którym go nie ma.
Przechodzi więc po podkatalogach app/moduly/ i pyta każdy, co wnosi. Katalog
generowania przeniesiony z app/dodatki na app/moduly/dodatki.

ZNALEZIONE PRZY OKAZJI. Po wydzieleniu eksportu okazało się, że jego ścieżki
SZCZĘŚLIWEJ nie sprawdzał żaden test — badano wyłącznie odmowę dla konta bez
uprawnienia. Moduł dostaje zależności z wywołania montującego, więc brak jednej
z nich wyszedłby dopiero przy pierwszym kliknięciu. Dopisany test funkcjonalny
(realny arkusz, sprawdzany aż do nagłówka ZIP-a) i brakujące zależności.

Cztery komentarze w plikach współdzielonych wymieniały zakładkę „Skompiluj",
w tym wheelzoom.js ze wzmianką o „przyszłej zakładce" — to samo zgłoszenie, które
audyt podnosił wcześniej.

TEST ZŁOŻENIA. Buduje węższy produkt NAPRAWDĘ: kopiuje drzewo, usuwa cztery
ekrany i oba moduły, uruchamia aplikację w OSOBNYM PROCESIE (importy są
zapamiętywane, więc sprawdzanie tego w procesie, który moduł już zaimportował,
dawałoby wynik fałszywie pozytywny) i sprawdza, że wstaje, że zachowane ekrany
oddają 200, że usunięte oddają 404 (nie 403 i nie 500), że katalog opisuje ten
obraz, i że w nawigacji nie ma martwych odsyłaczy.

Napisałem najpierw ostrzejszy test — „nazwa ekranu nie pada poza jego modułem" —
i go wyrzuciłem: zgłaszał wzmianki o Horoskopie w plikach współdzielonych, choć
astroklient Horoskop MA. Ślad ma znaczenie wyłącznie wobec konkretnego złożenia,
więc sprawdzenie należy do produktu, nie do mechanizmu.

Testy: presentation 368, logic 342, data 37, render 41.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit was merged in pull request #81.
This commit is contained in:
2026-08-26 13:50:34 +02:00
parent 62d6f9d4d5
commit fd79513ce2
64 changed files with 1799 additions and 1205 deletions
@@ -0,0 +1,13 @@
"""Wynoszenie treści na zewnątrz: arkusz z wynikami i raport w PDF-ie.
Moduł ODŁĄCZALNY. Jest w obrazie astrololo; w produktach, które nie mają wynosić
treści baz poza program, nie ma go wcale — a nie ma go jak włączyć, bo nie ma
czego. Eksport wynosi NAJWIĘCEJ treści naraz, więc „można, ale nie temu kontu"
i „nie ma takiej możliwości" to dwie różne gwarancje.
"""
from __future__ import annotations
from app.moduly.eksport.katalog import AKCJE, FUNKCJA, TRASY, obsluz
from app.moduly.eksport.trasy import zamontuj
__all__ = ["AKCJE", "FUNKCJA", "TRASY", "obsluz", "zamontuj"]
@@ -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()
@@ -0,0 +1,47 @@
"""Co moduł wnosi: funkcję, trasy i akcję formularza."""
from __future__ import annotations
from typing import Any
from fastapi.responses import Response
FUNKCJA: tuple[tuple[str, str, str, str], ...] = (
("export", "Eksport plików",
"Pobieranie raportu jako PDF i wyników jako arkusz.", "Rozszerzenia"),
)
TRASY: dict[tuple[str, str], str] = {("POST", "/compile/pdf"): "export"}
# Akcja formularza: nazwa → wymagane uprawnienie.
AKCJE: dict[str, str] = {"export": "export"}
# Wstrzykiwane przy montowaniu — moduł nie importuje aplikacji.
_logic: Any = None
_security: Any = None
_licz_rekordy: Any = None
def ustaw_zaleznosci(*, logic: Any, security: Any, licz_rekordy: Any) -> None:
global _logic, _security, _licz_rekordy
_logic, _security, _licz_rekordy = logic, security, licz_rekordy
def obsluz(akcja: str, *, iso_utc: str = "", lat: float = 0.0, lon: float = 0.0,
request: Any = None, group: bool = False, **_reszta) -> Response | None:
# Wszystko po `akcja` ma wartość domyślną CELOWO: most woła po kolei każdy
# moduł, także z ekranu, który tych danych nie ma. Brakujący argument
# wywracałby wtedy ekran, zamiast po prostu nie pasować do akcji.
if akcja not in AKCJE:
return None
# Arkusz roboczy z wynikami (DAN-23/PRE-10) — pobranie pliku, nie strona.
from app.moduly.eksport.arkusz import report_to_xlsx
report = _logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
# Eksport wynosi NAJWIĘCEJ treści baz naraz — tym bardziej ma zostawiać ślad
# w dzienniku (PRE-17): sama liczba rekordów, nigdy treść.
_security.audit_records(request, _licz_rekordy(report))
return Response(
content=report_to_xlsx(report),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": 'attachment; filename="interpretacje.xlsx"'},
)
@@ -0,0 +1,114 @@
"""Trasy wnoszone przez moduł. Bez tego katalogu w obrazie nie istnieją."""
from __future__ import annotations
from typing import Any
import httpx
from fastapi.responses import JSONResponse, Response
from app.moduly.eksport import katalog
_logic: Any = None
_szablony: Any = None
_buduj_utc: Any = None
_blad: Any = None
_kolo: Any = None
def zamontuj(app, *, logic: Any, templates: Any, buduj_utc: Any, blad: Any,
security: Any = None, licz_rekordy: Any = None, kolo: Any = None,
**_reszta) -> None:
"""Podpięcie trasy i przekazanie zależności. Idą z zewnątrz, bo zależność ma
biec w JEDNĄ stronę: aplikacja wie o modułach opcjonalnych (przez neutralny
most), moduł nie wie nic o aplikacji."""
global _logic, _szablony, _buduj_utc, _blad, _kolo
_logic, _szablony, _buduj_utc, _blad = logic, templates, buduj_utc, blad
_kolo = kolo
katalog.ustaw_zaleznosci(logic=logic, security=security, licz_rekordy=licz_rekordy)
# Nazwy, których używa przeniesiona trasa. `chartwheel` to czysty rysownik
# bez stanu, więc importujemy go wprost — wstrzykiwanie ma sens dla rzeczy
# związanych z aplikacją (klient, szablony), nie dla funkcji rysujących.
from app import chartwheel as chartwheel_mod
_build_utc = buduj_utc
_logic_error = blad
@app.post("/compile/pdf")
def compile_pdf(payload: dict):
"""Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu.
Wejście z przeglądarki, bo część kawałków (interpretacja natalna i predykcje)
mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF
zawierał dokładnie to, co widać na stronie.
Kosmogram idzie w motywie DRUKU: samodzielny konwerter SVG→PDF nie zna
naszego arkusza, więc zmienne CSS i font glifów muszą być w samym rysunku.
"""
from fastapi.responses import Response
from app.clients.render_client import RenderClient
data = payload.get("data") or {}
try:
iso_utc, label = _build_utc(
str(data.get("date") or ""), str(data.get("time") or ""),
float(data.get("tz_offset") or 0.0),
)
except (ValueError, TypeError) as e:
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
figures: list[dict] = []
warnings: list[str] = []
try:
chart = logic.positions(
when_utc_iso=iso_utc,
lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0),
house_system=str(data.get("house_system") or "whole_sign"),
house_systems=[s for s in (data.get("house_systems") or []) if s],
aspect_orb=float(data.get("aspect_orb") or 8.0),
aspect_luminary_bonus=float(data.get("aspect_luminary_bonus") or 2.0),
aspect_minor=bool(data.get("aspect_minor")),
stations=bool(data.get("stations")), tables=bool(data.get("tables")),
zodiac=str(data.get("zodiac") or "tropical"),
)
# Fallback systemu domów musi dojechać do PDF-a — z samego kosmogramu
# nie da się poznać, że podział jest z innego systemu, niż zamówiono.
warnings = [w for w in (chart.get("house_warnings") or []) if w]
from app import chartwheel
# Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki
# w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc
# zmienne CSS i font glifów muszą być wprost w rysunku.
for svg, caption in (
(chartwheel.render(chart, theme="print",
orientation=str(data.get("wheel_orientation") or chartwheel_mod.ASC_LEFT)),
"Kosmogram"),
(chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"),
(chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"),
(chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"),
):
if svg:
figures.append({"svg": svg, "caption": caption})
except httpx.HTTPError as e:
# Brak rysunków nie może zablokować raportu — tekst jest ważniejszy.
data = {**data, "wheel_error": _logic_error(e)}
report = {
"person": payload.get("person") or "",
"data": {**data, "moment_utc": label},
"figures": figures,
"warnings": warnings,
"natal": payload.get("natal") or {},
"predictions": payload.get("predictions") or [],
}
try:
pdf = RenderClient().pdf(report)
except httpx.HTTPError as e:
return JSONResponse(
{"detail": f"Usługa render niedostępna albo nie złożyła PDF-a: {e}"},
status_code=502,
)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})