a833965909
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m58s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m32s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 3m37s
Testy / Kontrola składni wszystkich warstw (push) Successful in 9s
build / build (push) Successful in 18s
Ekran „Konta" dla administratora: zakładanie, kasowanie i nadawanie uprawnień. Zestaw funkcji zależy od konta, a konto ograniczone widzi program KOMPLETNY — tylko mniejszy. PODZIAŁ NA GRUPY. Ekrany to zakładki (7), bo zakładka jest naturalną jednostką — to ją widać w nawigacji. Rozszerzenia to POZIOMY ZŁOŻONOŚCI wewnątrz ekranów: porównanie systemów domów, wykresy dodatkowe, obliczenia zaawansowane, generowanie tekstu przez model (kosztuje pieniądze) i eksport plików. Konto bez porównania domów dostaje horoskop w Whole Sign i nie wie, że systemów jest trzynaście. NIC NIE ZDRADZA, ŻE JEST WIĘCEJ: - brak pozycji w menu zamiast pozycji wyszarzonej, - 404 zamiast 403 — odmowa z powodem sama mówi, że coś tam jest, - rysunki bez uprawnienia w OGÓLE NIE POWSTAJĄ, więc nie ma ich nawet w źródle, - automatyczna dokumentacja API wyłączona. /docs, /redoc i /openapi.json wypisują komplet tras, czyli spis wszystkich funkcji programu — ochrona zakładek nic by nie dała, gdyby obok leżał ich katalog. Znalezione TESTEM przechodzącym po trasach aplikacji, nie przeglądem kodu. KONTO ADMINISTRACYJNE zostaje w APP_USER/APP_PASSWORD, jak było. Nie leży w pliku kont, więc nie da się go skasować ani ograniczyć z ekranu. Konto założone w pliku o tym samym loginie NIE przesłoni administracyjnego — kolejność sprawdzania jest odwrotna, inaczej dałoby się odebrać uprawnienia jedynemu, kto może je nadawać. Uprawnienia administracyjnego nie da się też nadać z formularza: odsiewamy je w normalise(), a nie w handlerze, więc żadne spreparowane żądanie tam nie sięgnie. GRANICA JEST W HANDLERZE, NIE W SZABLONIE. Ukrycie pola chroni przed przypadkiem, nie przed kimś, kto zna nazwy pól — _limit_options() ścina opcje po stronie serwera i test wysyła spreparowane żądanie, żeby to potwierdzić. MAPA TRASA→UPRAWNIENIE JEST JEDNA (features.ROUTES). Rozproszenie jej po dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka dziura jest niewidoczna, dopóki ktoś jej nie znajdzie. Trasa bez wpisu wymaga administratora: przeoczenie ma ZAMYKAĆ, nie otwierać. Test idzie po trasach APLIKACJI, nie po wpisach mapy — inaczej potwierdzałby tylko sam siebie. Konta w pliku JSON na własnym podkatalogu NFS (nie tam, gdzie bazy — zamontowanie całego udziału obeszłoby bokiem DAN-25). Hasła wyłącznie jako hash scrypt, tym samym mechanizmem co APP_USERS. Zapis atomowy, bo przerwanie zapisu na NFS obcięłoby plik, czyli skasowało wszystkie konta naraz. Przy okazji przepisane trzy testy, które greppowały nawigację i main.py: menu powstaje teraz z katalogu funkcji, więc szukanie sztywnych linków w base.html niczego już nie sprawdzało. Wymaga wolumenu na konta — osobny PR w repo deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
739 lines
33 KiB
Python
739 lines
33 KiB
Python
"""Warstwa PREZENTACJI — usługa HTTP serwująca stronę WWW.
|
||
|
||
W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
|
||
wyniki. Nie zawiera logiki biznesowej ani dostępu do danych — tylko UI.
|
||
|
||
Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych pozycji
|
||
(do ręcznego testowania aplikacji). Wyszukiwarka sygnifikatorów przeniesiona pod
|
||
„/significators".
|
||
"""
|
||
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import json
|
||
from datetime import datetime, timedelta, timezone
|
||
from functools import lru_cache
|
||
from pathlib import Path
|
||
|
||
import httpx
|
||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.templating import Jinja2Templates
|
||
|
||
from app import accounts as accounts_store
|
||
from app import chartwheel as chartwheel_mod
|
||
from app import features
|
||
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
|
||
|
||
from app import geocode, security
|
||
from app.clients.logic_client import LogicClient
|
||
from app.config import DEFAULT_LOCATION_LABEL, default_form
|
||
|
||
# Automatyczna dokumentacja WYŁĄCZONA. /docs, /redoc i /openapi.json wypisują
|
||
# KOMPLET tras aplikacji — czyli spis wszystkich funkcji, łącznie z tymi, o których
|
||
# konto ograniczone nie ma prawa wiedzieć (PRE-27). Ochrona zakładek nic by nie dała,
|
||
# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji.
|
||
app = FastAPI(title="astrololo · warstwa prezentacji",
|
||
docs_url=None, redoc_url=None, openapi_url=None)
|
||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||
templates = Jinja2Templates(directory="app/templates")
|
||
logic = LogicClient()
|
||
security.install(app) # logowanie + limit żądań (LOG-32)
|
||
|
||
|
||
# ── cache-busting statyki (PRE-27) ─────────────────────────────────────────
|
||
# Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL → cache).
|
||
# Doklejamy do URL-a krótki HASH TREŚCI pliku: zmieni się plik → zmieni się URL →
|
||
# przeglądarka pobierze nowy; bez zmian URL zostaje ten sam (cache działa dalej).
|
||
# Hash liczony raz na proces (lru_cache) — nowy pod po deployu = świeży hash.
|
||
_STATIC_DIR = Path("app/static")
|
||
|
||
|
||
@lru_cache(maxsize=None)
|
||
def _asset_version(name: str) -> str:
|
||
try:
|
||
return hashlib.md5((_STATIC_DIR / name).read_bytes()).hexdigest()[:8]
|
||
except OSError:
|
||
return "0" # brak pliku nie może wywrócić strony
|
||
|
||
|
||
def static_url(name: str) -> str:
|
||
return f"/static/{name}?v={_asset_version(name)}"
|
||
|
||
|
||
templates.env.globals["static"] = static_url
|
||
# Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów.
|
||
templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
|
||
templates.env.globals["house_label"] = house_label
|
||
templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
|
||
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
|
||
|
||
|
||
def _perms(request: Request) -> frozenset[str]:
|
||
"""Uprawnienia zalogowanego konta — pusty zbiór, gdy nie wiadomo kto to."""
|
||
who = getattr(request.state, "principal", None)
|
||
return who.permissions if who is not None else frozenset()
|
||
|
||
|
||
# Szablony pytają o uprawnienia PRZEZ TE DWIE FUNKCJE, nigdy o konto wprost.
|
||
# Dzięki temu nie da się w szablonie napisać warunku na login — a to jest
|
||
# dokładnie ten rodzaj sprytu, który po roku zostawia kogoś z dostępem, o którym
|
||
# nikt już nie pamięta.
|
||
templates.env.globals["nav_screens"] = lambda request: features.screens_for(_perms(request))
|
||
templates.env.globals["can"] = lambda request, key: key in _perms(request)
|
||
|
||
|
||
def _limit_options(request: Request, opts: dict) -> dict:
|
||
"""Ścina opcje, których konto nie ma prawa użyć — PO STRONIE SERWERA.
|
||
|
||
Ukrycie pola w formularzu chroni tylko przed przypadkiem. Kto zna nazwy pól,
|
||
wyśle je wprost — a wtedy policzyłby funkcję, której mu nie przyznano, i to
|
||
jeszcze zobaczyłby ją w wyniku. Granicą jest ta funkcja, nie szablon.
|
||
|
||
Wartości zastępcze są DOMYŚLNE, nie puste: konto ograniczone ma dostać
|
||
działający horoskop w podstawowej postaci, a nie komunikat o braku czegoś.
|
||
"""
|
||
perms = _perms(request)
|
||
if "advanced_calc" not in perms:
|
||
opts.update(stations=False, tables=False, aspect_minor=False, zodiac="tropical")
|
||
if "houses_compare" not in perms:
|
||
opts.update(house_system="whole_sign", house_systems=[],
|
||
wheel_orientation=chartwheel_mod.ASC_LEFT)
|
||
return opts
|
||
|
||
|
||
def _landing(request: Request) -> str | None:
|
||
"""Pierwszy ekran dostępny dla konta — dokąd odesłać z korzenia."""
|
||
allowed = features.screens_for(_perms(request))
|
||
return allowed[0].href if allowed else None
|
||
|
||
|
||
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
||
"""Z lokalnej daty/godziny + przesunięcia strefy → moment UTC.
|
||
|
||
Zwraca (iso_utc, etykieta_czytelna). UTC = czas lokalny − offset.
|
||
"""
|
||
local = datetime.fromisoformat(f"{date}T{time}")
|
||
utc = (local - timedelta(hours=tz_offset)).replace(tzinfo=timezone.utc)
|
||
label = utc.strftime("%Y-%m-%d %H:%M UTC")
|
||
return utc.isoformat(), label
|
||
|
||
|
||
def _llm_catalog() -> dict:
|
||
"""Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony —
|
||
pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie."""
|
||
try:
|
||
return logic.llm_models()
|
||
except httpx.HTTPError:
|
||
return {"providers": {}, "defaults": {}}
|
||
|
||
|
||
def _report_records(report: dict | None) -> int:
|
||
"""Ile rekordów baz niesie raport — do dziennika audytowego (PRE-17).
|
||
|
||
Liczymy próbki we wszystkich fasetach: to one są treścią z baz. Sama liczba,
|
||
nigdy treść — dziennik ma pokazywać SKALĘ dostępu, nie powielać danych."""
|
||
total = 0
|
||
for o in (report or {}).get("objects") or []:
|
||
for f in o.get("facets") or []:
|
||
total += len(f.get("samples") or [])
|
||
return total
|
||
|
||
|
||
def _logic_error(e: Exception) -> str:
|
||
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
|
||
return (
|
||
"Warstwa logiczna działa, ale nie ma endpointu /chart/positions. "
|
||
"Uruchom warstwę logiczną z silnikiem efemeryd (gałąź feat/logic-engine)."
|
||
)
|
||
return f"Warstwa logiczna niedostępna: {e}"
|
||
|
||
|
||
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
||
@app.get("/", response_class=HTMLResponse)
|
||
def chart_form(request: Request):
|
||
# Korzeń jest dostępny dla każdego zalogowanego, ale ekran „Horoskop" już nie.
|
||
# Bez tego konto bez uprawnienia do horoskopu dostawałoby 404 pod adresem
|
||
# głównym — czyli wyglądałoby to na zepsuty program, a nie na węższy zestaw.
|
||
if "chart" not in _perms(request):
|
||
target = _landing(request)
|
||
if target:
|
||
return RedirectResponse(target, status_code=303)
|
||
return HTMLResponse("<h1>Brak przypisanych funkcji</h1>"
|
||
"<p>Skontaktuj się z osobą, która zakładała konto.</p>",
|
||
status_code=200)
|
||
return templates.TemplateResponse(
|
||
request, "chart.html",
|
||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||
"llm_catalog": _llm_catalog()},
|
||
)
|
||
|
||
|
||
@app.post("/", response_class=HTMLResponse)
|
||
def chart_compute(
|
||
request: Request,
|
||
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||
date: str = Form(...),
|
||
time: str = Form(...),
|
||
tz_offset: float = Form(0.0),
|
||
lat: float = Form(0.0),
|
||
lon: float = Form(0.0),
|
||
house_system: str = Form("whole_sign"),
|
||
house_systems: list[str] = Form([]), # PRE-05: dodatkowe systemy do porównania
|
||
aspect_orb: float = Form(8.0), # PRE-06: konfigurowalny orb aspektów
|
||
aspect_luminary_bonus: float = Form(2.0),
|
||
aspect_minor: bool = Form(False),
|
||
stations: bool = Form(False),
|
||
zodiac: str = Form("tropical"),
|
||
tables: bool = Form(False),
|
||
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT),
|
||
):
|
||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||
"lat": lat, "lon": lon, "house_system": house_system,
|
||
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
||
"stations": stations, "zodiac": zodiac, "tables": tables,
|
||
"wheel_orientation": wheel_orientation}
|
||
form = _limit_options(request, form)
|
||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||
try:
|
||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||
ctx["moment"] = label
|
||
ctx["result"] = logic.positions(
|
||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||
house_system=form["house_system"], house_systems=form["house_systems"],
|
||
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
||
aspect_minor=form["aspect_minor"],
|
||
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
|
||
)
|
||
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
||
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
|
||
orientation=form["wheel_orientation"])
|
||
# Rysunki dodatkowe NIE POWSTAJĄ bez uprawnienia — nie chodzi o ukrycie
|
||
# ich w szablonie, tylko o to, żeby nie było ich nawet w źródle strony.
|
||
if "extra_charts" in _perms(request):
|
||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
|
||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
|
||
except (httpx.HTTPError,) as e:
|
||
ctx["error"] = _logic_error(e)
|
||
except ValueError as e:
|
||
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||
return templates.TemplateResponse(request, "chart.html", ctx)
|
||
|
||
|
||
# ---------------- Skompiluj: zbiorczy raport (PRE-23) ----------------
|
||
@app.get("/compile", response_class=HTMLResponse)
|
||
def compile_form(request: Request):
|
||
return templates.TemplateResponse(
|
||
request, "compile.html",
|
||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL},
|
||
)
|
||
|
||
|
||
@app.post("/compile", response_class=HTMLResponse)
|
||
def compile_build(
|
||
request: Request,
|
||
person: str = Form(""),
|
||
date: str = Form(...),
|
||
time: str = Form(...),
|
||
tz_offset: float = Form(0.0),
|
||
lat: float = Form(0.0),
|
||
lon: float = Form(0.0),
|
||
house_system: str = Form("whole_sign"),
|
||
house_systems: list[str] = Form([]), # PRE-05 — porównanie domów w podsumowaniu
|
||
aspect_orb: float = Form(8.0), # PRE-06 — te same ustawienia aspektów
|
||
aspect_luminary_bonus: float = Form(2.0),
|
||
aspect_minor: bool = Form(False),
|
||
stations: bool = Form(False), # LOG-03 — stacje też w podsumowaniu
|
||
zodiac: str = Form("tropical"),
|
||
tables: bool = Form(False), # LOG-23 — żywioły/faza/godziny w podsumowaniu
|
||
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT), # LOG-05 — obrót koła
|
||
):
|
||
"""Składa raport: horoskop liczymy TU NA NOWO, a części od AI (interpretacja
|
||
natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23).
|
||
|
||
Horoskop liczymy ponownie (czysta funkcja wejścia — tanio powtórzyć, bez
|
||
trzymania dużego wyniku w przeglądarce), ale z TYMI SAMYMI opcjami, które
|
||
wybrano przy horoskopie (stacje, tabele, porównanie domów) — inaczej
|
||
podsumowanie miałoby braki względem tego, co policzono. Opcje wędrują między
|
||
zakładkami przez formsync.
|
||
"""
|
||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||
"lat": lat, "lon": lon, "house_system": house_system,
|
||
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
||
"stations": stations, "zodiac": zodiac, "tables": tables,
|
||
"wheel_orientation": wheel_orientation}
|
||
form = _limit_options(request, form)
|
||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||
try:
|
||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||
ctx["moment"] = label
|
||
ctx["result"] = logic.positions(
|
||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||
house_system=form["house_system"], house_systems=form["house_systems"],
|
||
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
||
aspect_minor=form["aspect_minor"],
|
||
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
|
||
)
|
||
from app import chartwheel
|
||
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
|
||
orientation=form["wheel_orientation"])
|
||
if "extra_charts" in _perms(request):
|
||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
|
||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
|
||
except (httpx.HTTPError,) as e:
|
||
ctx["error"] = _logic_error(e)
|
||
except ValueError as e:
|
||
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||
return templates.TemplateResponse(request, "compile.html", ctx)
|
||
|
||
|
||
@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ęści od AI (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"'})
|
||
|
||
|
||
# ---------------- Ustawienia: bazy na udziale (DAN-15/PRE-09) ----------------
|
||
@app.get("/settings", response_class=HTMLResponse)
|
||
def settings_view(request: Request):
|
||
"""Podgląd baz dostępnych na udziale + które biorą udział w interpretacji.
|
||
|
||
Same metadane (nazwa, rozmiar, data) — bez treści baz. Przełączniki są
|
||
DEKLARATYWNE (`DISABLED_BASES`), bo warstwa danych nie ma trwałego zapisu:
|
||
udział jest read-only, a cache to emptyDir. Zapis do pliku ginąłby po
|
||
restarcie, po cichu włączając z powrotem wyłączoną bazę."""
|
||
ctx: dict = {"result": None, "error": None}
|
||
try:
|
||
ctx["result"] = logic.bases()
|
||
except httpx.HTTPError as e:
|
||
ctx["error"] = _logic_error(e)
|
||
return templates.TemplateResponse(request, "settings.html", ctx)
|
||
|
||
|
||
# ---------------- Synastria (technika relacyjna) ----------------
|
||
@app.get("/synastry", response_class=HTMLResponse)
|
||
def synastry_form(request: Request):
|
||
# domyślne liczby, żeby pola number nie były puste (puste → błąd przy wysyłce)
|
||
form = {"a_tz_offset": 0, "a_lat": 0, "a_lon": 0, "b_tz_offset": 0, "b_lat": 0, "b_lon": 0,
|
||
"aspect_orb": 8, "aspect_luminary_bonus": 2}
|
||
return templates.TemplateResponse(request, "synastry.html", {"result": None, "form": form})
|
||
|
||
|
||
@app.post("/synastry", response_class=HTMLResponse)
|
||
def synastry_run(
|
||
request: Request,
|
||
a_person: str = Form(""), a_date: str = Form(...), a_time: str = Form(...),
|
||
a_tz_offset: float = Form(0.0), a_lat: float = Form(0.0), a_lon: float = Form(0.0),
|
||
b_person: str = Form(""), b_date: str = Form(...), b_time: str = Form(...),
|
||
b_tz_offset: float = Form(0.0), b_lat: float = Form(0.0), b_lon: float = Form(0.0),
|
||
zodiac: str = Form("tropical"),
|
||
aspect_orb: float = Form(8.0), aspect_luminary_bonus: float = Form(2.0),
|
||
aspect_minor: bool = Form(False),
|
||
):
|
||
"""Synastria (PRE-04): dwie osoby → aspekty między ich horoskopami."""
|
||
form = {"a_person": a_person, "a_date": a_date, "a_time": a_time, "a_tz_offset": a_tz_offset,
|
||
"a_lat": a_lat, "a_lon": a_lon, "b_person": b_person, "b_date": b_date,
|
||
"b_time": b_time, "b_tz_offset": b_tz_offset, "b_lat": b_lat, "b_lon": b_lon,
|
||
"zodiac": zodiac, "aspect_orb": aspect_orb,
|
||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor}
|
||
ctx: dict = {"form": form, "result": None, "error": None}
|
||
try:
|
||
iso_a, _ = _build_utc(a_date, a_time, a_tz_offset)
|
||
iso_b, _ = _build_utc(b_date, b_time, b_tz_offset)
|
||
ctx["result"] = logic.synastry(
|
||
{"when_utc": iso_a, "lat": a_lat, "lon": a_lon},
|
||
{"when_utc": iso_b, "lat": b_lat, "lon": b_lon},
|
||
zodiac=zodiac, aspect_orb=aspect_orb,
|
||
aspect_luminary_bonus=aspect_luminary_bonus, aspect_minor=aspect_minor,
|
||
)
|
||
except httpx.HTTPError as e:
|
||
ctx["error"] = _logic_error(e)
|
||
except ValueError as e:
|
||
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||
return templates.TemplateResponse(request, "synastry.html", ctx)
|
||
|
||
|
||
# ---------------- Sygnifikatory (wyszukiwarka w bazach) ----------------
|
||
@app.get("/significators", response_class=HTMLResponse)
|
||
def significators_form(request: Request):
|
||
return templates.TemplateResponse(request, "significators.html", {"result": None, "form": {}})
|
||
|
||
|
||
@app.post("/significators", response_class=HTMLResponse)
|
||
def significators_search(
|
||
request: Request,
|
||
query: str = Form(...),
|
||
field: str = Form("name"),
|
||
exact: bool = Form(False),
|
||
limit: int = Form(25),
|
||
):
|
||
form = {"query": query, "field": field, "exact": exact, "limit": limit}
|
||
ctx: dict = {"form": form, "result": None, "error": None}
|
||
try:
|
||
ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit)
|
||
# ile rekordów baz oddaliśmy — do dziennika audytowego (PRE-17), bez treści
|
||
security.audit_records(request, len((ctx["result"] or {}).get("rows") or []))
|
||
except httpx.HTTPError as e:
|
||
ctx["error"] = _logic_error(e)
|
||
return templates.TemplateResponse(request, "significators.html", ctx)
|
||
|
||
|
||
# ---------------- Interpretacje (wynik obliczeń szukany w bazie) ----------------
|
||
@app.get("/interpret", response_class=HTMLResponse)
|
||
def interpret_form(request: Request):
|
||
return templates.TemplateResponse(
|
||
request, "interpret.html",
|
||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||
"llm_catalog": _llm_catalog()},
|
||
)
|
||
|
||
|
||
@app.post("/interpret", response_class=HTMLResponse)
|
||
def interpret_run(
|
||
request: Request,
|
||
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||
date: str = Form(...),
|
||
time: str = Form(...),
|
||
tz_offset: float = Form(0.0),
|
||
lat: float = Form(0.0),
|
||
lon: float = Form(0.0),
|
||
group: bool = Form(False),
|
||
action: str = Form("report"),
|
||
prompt_budget: str = Form("medium"),
|
||
llm_provider: str = Form("local"),
|
||
llm_model: str = Form(""),
|
||
):
|
||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||
"lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget,
|
||
"llm_provider": llm_provider, "llm_model": llm_model}
|
||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||
"llm_catalog": _llm_catalog()}
|
||
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)
|
||
# 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, _report_records(report))
|
||
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,
|
||
provider=llm_provider, model=llm_model,
|
||
)
|
||
elif action == "horoscope":
|
||
ctx["prompt_result"] = logic.horoscope(
|
||
profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||
budget=prompt_budget, provider=llm_provider, model=llm_model,
|
||
)
|
||
else:
|
||
ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
|
||
security.audit_records(request, _report_records(ctx["result"]))
|
||
except httpx.HTTPError as e:
|
||
ctx["error"] = _logic_error(e)
|
||
except ValueError as e:
|
||
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||
return templates.TemplateResponse(request, "interpret.html", ctx)
|
||
|
||
|
||
# ---------------- Kalendarz (oś czasu z technik + interpretacje) ----------------
|
||
@app.get("/timeline", response_class=HTMLResponse)
|
||
def timeline_form(request: Request):
|
||
return templates.TemplateResponse(
|
||
request, "timeline.html",
|
||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||
"llm_catalog": _llm_catalog()},
|
||
)
|
||
|
||
|
||
@app.post("/timeline", response_class=HTMLResponse)
|
||
def timeline_run(
|
||
request: Request,
|
||
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||
date: str = Form(...),
|
||
time: str = Form(...),
|
||
tz_offset: float = Form(0.0),
|
||
lat: float = Form(0.0),
|
||
lon: float = Form(0.0),
|
||
from_date: str = Form(...),
|
||
to_date: str = Form(...),
|
||
action: str = Form("timeline"),
|
||
prompt_budget: str = Form("medium"),
|
||
llm_provider: str = Form("local"),
|
||
llm_model: str = Form(""),
|
||
):
|
||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon,
|
||
"from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget,
|
||
"llm_provider": llm_provider, "llm_model": llm_model}
|
||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
|
||
"llm_catalog": _llm_catalog()}
|
||
try:
|
||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||
ctx["moment"] = label
|
||
if action == "prompt":
|
||
ctx["prompt_result"] = logic.prompt(
|
||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||
budget=prompt_budget, from_date=from_date, to_date=to_date,
|
||
provider=llm_provider, model=llm_model,
|
||
)
|
||
elif action == "horoscope":
|
||
ctx["prompt_result"] = logic.horoscope(
|
||
profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||
budget=prompt_budget, provider=llm_provider, model=llm_model,
|
||
from_date=from_date, to_date=to_date,
|
||
)
|
||
else:
|
||
ctx["result"] = logic.timeline(
|
||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||
from_date=from_date, to_date=to_date, interpret=True,
|
||
)
|
||
except httpx.HTTPError as e:
|
||
ctx["error"] = _logic_error(e)
|
||
except ValueError as e:
|
||
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||
return templates.TemplateResponse(request, "timeline.html", ctx)
|
||
|
||
|
||
# ---------------- Postęp pisania horoskopu (strumień do okna z logiem) ----------------
|
||
@app.post("/horoscope/stream")
|
||
def horoscope_stream(
|
||
profile: str = Form("natal"),
|
||
date: str = Form(...),
|
||
time: str = Form(...),
|
||
tz_offset: float = Form(0.0),
|
||
lat: float = Form(0.0),
|
||
lon: float = Form(0.0),
|
||
prompt_budget: str = Form("medium"),
|
||
llm_provider: str = Form("local"),
|
||
llm_model: str = Form(""),
|
||
from_date: str = Form(""),
|
||
to_date: str = Form(""),
|
||
):
|
||
"""Przekazuje strumień postępu z logiki i DOKLEJA gotowy HTML wyniku.
|
||
|
||
Dzięki temu okno postępu wstawia dokładnie ten sam widok, który wyrenderowałoby
|
||
przeładowanie strony — jedno źródło prawdy dla wyglądu wyniku.
|
||
"""
|
||
from fastapi.responses import StreamingResponse
|
||
|
||
try:
|
||
iso_utc, _ = _build_utc(date, time, tz_offset)
|
||
except ValueError as e:
|
||
return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
|
||
|
||
payload: dict = {
|
||
"profile": profile, "when_utc": iso_utc, "lat": lat, "lon": lon,
|
||
"budget": prompt_budget, "provider": llm_provider, "model": llm_model,
|
||
}
|
||
if profile == "period" and from_date and to_date:
|
||
payload["from_date"], payload["to_date"] = from_date, to_date
|
||
|
||
def relay():
|
||
try:
|
||
for raw in logic.horoscope_stream(payload):
|
||
try:
|
||
event = json.loads(raw)
|
||
except ValueError:
|
||
continue
|
||
if event.get("type") == "result":
|
||
html = templates.get_template("_prompt_result.html").render(
|
||
prompt_result=event.get("result") or {}
|
||
)
|
||
event["html"] = html
|
||
yield json.dumps(event, ensure_ascii=False) + "\n"
|
||
except httpx.HTTPError as e:
|
||
yield json.dumps({"type": "error", "message": _logic_error(e)},
|
||
ensure_ascii=False) + "\n"
|
||
|
||
return StreamingResponse(relay(), media_type="application/x-ndjson",
|
||
headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"})
|
||
|
||
|
||
# ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ----------------
|
||
@app.get("/geocode")
|
||
def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")):
|
||
"""Nazwa miejsca → kandydaci ze współrzędnymi (dla pola „Szukaj miejsca")."""
|
||
try:
|
||
return {"results": geocode.search(q)}
|
||
except httpx.HTTPError as e:
|
||
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
|
||
|
||
|
||
@app.get("/reverse")
|
||
def geocode_reverse(lat: float, lon: float):
|
||
"""Punkt z mapy → nazwa miejsca (po przeciągnięciu pineski / kliknięciu)."""
|
||
try:
|
||
return geocode.reverse(lat, lon)
|
||
except httpx.HTTPError as e:
|
||
raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}")
|
||
|
||
|
||
@app.get("/timezone")
|
||
def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00"):
|
||
"""Współrzędne + data → strefa IANA i DST-świadomy offset GMT (PRE-03).
|
||
|
||
Liczone OFFLINE (tzfpy + zoneinfo), więc brak internetu nie przeszkadza. 404,
|
||
gdy strefy nie da się ustalić — front zostawia wtedy ręczny offset."""
|
||
from app import timezone as tzmod
|
||
|
||
res = tzmod.resolve(lat, lon, date, time)
|
||
if not res:
|
||
raise HTTPException(status_code=404, detail="Nie ustalono strefy dla tego punktu.")
|
||
return res
|
||
|
||
|
||
# ---------------- Konta i uprawnienia (PRE-27) ----------------
|
||
# Ochrona tych tras siedzi w features.ROUTES, nie w dekoratorze — jedna mapa
|
||
# dla całej aplikacji, sprawdzana testem, który przechodzi po WSZYSTKICH trasach.
|
||
|
||
def _accounts_context(request: Request, error: str = "", done: str = "") -> dict:
|
||
return {
|
||
"users": accounts_store.all_users(),
|
||
"catalog": features.ALL,
|
||
"screens": features.SCREENS,
|
||
"extras": features.EXTRAS,
|
||
"admin_login": security.app_user(),
|
||
"store_path": accounts_store.store_path(),
|
||
"error": error, "done": done,
|
||
}
|
||
|
||
|
||
@app.get("/accounts", response_class=HTMLResponse)
|
||
def accounts_view(request: Request, error: str = "", done: str = ""):
|
||
return templates.TemplateResponse(request, "accounts.html",
|
||
_accounts_context(request, error, done))
|
||
|
||
|
||
def _accounts_redirect(error: str = "", done: str = "") -> RedirectResponse:
|
||
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
|
||
powtórzyć zakładania ani kasowania konta."""
|
||
from urllib.parse import urlencode
|
||
|
||
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
||
return RedirectResponse(f"/accounts{'?' + q if q else ''}", status_code=303)
|
||
|
||
|
||
@app.post("/accounts/create")
|
||
def accounts_create(request: Request, login: str = Form(""), password: str = Form(""),
|
||
note: str = Form(""), granted: list[str] = Form([])):
|
||
try:
|
||
accounts_store.create(login, password, granted, note)
|
||
except ValueError as e:
|
||
return _accounts_redirect(error=str(e))
|
||
return _accounts_redirect(done=f"Założono konto „{login.strip()}”.")
|
||
|
||
|
||
@app.post("/accounts/update")
|
||
def accounts_update(request: Request, login: str = Form(...), password: str = Form(""),
|
||
note: str = Form(""), granted: list[str] = Form([])):
|
||
try:
|
||
accounts_store.update(login, granted=granted, password=password, note=note)
|
||
except ValueError as e:
|
||
return _accounts_redirect(error=str(e))
|
||
changed = "uprawnienia i hasło" if password.strip() else "uprawnienia"
|
||
return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.")
|
||
|
||
|
||
@app.post("/accounts/delete")
|
||
def accounts_delete(request: Request, login: str = Form(...)):
|
||
try:
|
||
accounts_store.delete(login)
|
||
except ValueError as e:
|
||
return _accounts_redirect(error=str(e))
|
||
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
|
||
|
||
|
||
@app.get("/health")
|
||
def health() -> dict:
|
||
return {"status": "ok", "layer": "presentation"}
|