astrololo: ekrany i eksport jako moduły, katalog z rejestracji (3/5)
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m32s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4m46s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m32s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m25s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
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 is contained in:
@@ -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"'})
|
||||
Reference in New Issue
Block a user