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

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:
2026-08-26 13:50:34 +02:00
parent d094a145b1
commit c1f5bea9f7
64 changed files with 1799 additions and 1205 deletions
@@ -0,0 +1,10 @@
"""Ekrany aplikacji — jeden moduł na ekran.
Ekran to trasy plus szablon. Import modułu rejestruje jego trasy (dekoratory
`@app.get`/`@app.post` wykonują się przy imporcie), więc o tym, CO aplikacja
umie, decyduje lista importów w `main.py` — a nie warunki rozsiane po kodzie.
Dzięki temu produkt buduje się przez ZŁOŻENIE: inny zestaw importów i inny
zestaw plików w obrazie to inny program, a nie ten sam program z wyłączonymi
kawałkami. Ekranu, którego moduł nie wszedł do obrazu, nie ma jak włączyć.
"""
@@ -0,0 +1,118 @@
"""Horoskop: pozycje, osie, domy, aspekty i kosmogram (strona główna)."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
chartwheel_mod,
DEFAULT_LOCATION_LABEL,
default_form,
rozszerzenia,
_perms,
_limit_options,
_landing,
_build_utc,
_logic_error,
)
# ---------------- 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)
# Ta sama odpowiedź co bramka dla trasy bez uprawnienia. Poprzednia treść
# mówiła o „przypisanych funkcjach" i o „osobie, która zakładała konto",
# czyli wprost: funkcje są komuś przydzielane, jest ktoś, kto to robi,
# a twoje konto zostało okrojone.
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
return templates.TemplateResponse(
request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
**rozszerzenia.kontekst_szablonu(request)},
)
@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)
# ── zgłoszenie do katalogu ─────────────────────────────────────────────────
# Ekran zgłasza siebie, swoje trasy i swoje zasoby. Wszystko o tym ekranie jest
# w tym pliku — więc obraz bez tego pliku nie wie o nim NIC.
features.zarejestruj(
ekran=Feature("chart", "Horoskop", "Pozycje, osie, domy, aspekty, kosmogram.",
"Ekrany", "/", kolejnosc=10),
trasy={("GET", "/"): None, # korzeń przekierowuje na pierwszy dostępny ekran
("POST", "/"): "chart"},
zasoby={"s-chart.css": "chart"},
)
features.zarejestruj(rozszerzenie=Feature(
"houses_compare", "Porównanie systemów domów",
"Wybór systemu innego niż domyślny i zestawienie kilku obok siebie.",
"Rozszerzenia", kolejnosc=10))
features.zarejestruj(rozszerzenie=Feature(
"extra_charts", "Wykresy dodatkowe",
"Aspektarian, wykres deklinacji, oś antyscji.", "Rozszerzenia", kolejnosc=20))
features.zarejestruj(rozszerzenie=Feature(
"advanced_calc", "Obliczenia zaawansowane",
"Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
"Rozszerzenia", kolejnosc=30))
@@ -0,0 +1,81 @@
"""Interpretacje: wynik obliczeń szukany w bazach."""
from __future__ import annotations
from fastapi import Depends, Form, Request
from fastapi.responses import HTMLResponse, Response
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
security,
DEFAULT_LOCATION_LABEL,
default_form,
rozszerzenia,
_build_utc,
_dozwolona_akcja,
_report_records,
_logic_error,
)
# ---------------- 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,
**rozszerzenia.kontekst_szablonu(request)},
)
@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"),
# Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
# nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
# funkcji wpisaną w kod. Zależność przenosi deklarację do modułu.
dodatki: dict = Depends(rozszerzenia.pola_formularza()),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "group": group, **dodatki}
action = _dozwolona_akcja(request, action, "report")
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
**rozszerzenia.kontekst_szablonu(request)}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
# Akcja może należeć do modułu opcjonalnego. `None` znaczy „nie jego",
# więc ekran robi to, co robi normalnie. Moduł może oddać słownik do
# kontekstu albo gotową odpowiedź — na przykład plik do pobrania.
wynik = rozszerzenia.obsluz(action, profil="natal", iso_utc=iso_utc,
lat=lat, lon=lon, dodatki=dodatki,
request=request, group=group)
if isinstance(wynik, Response):
return wynik
if wynik is not None:
ctx["wynik_dodatku"] = wynik
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)
features.zarejestruj(
ekran=Feature("interpret", "Interpretacje", "Interpretacja natalna z baz.",
"Ekrany", "/interpret", kolejnosc=20),
trasy={("GET", "/interpret"): "interpret", ("POST", "/interpret"): "interpret"},
zasoby={"s-interpret.css": "interpret"},
)
@@ -0,0 +1,78 @@
"""Kalendarz: oś czasu z technik wraz z interpretacjami."""
from __future__ import annotations
from fastapi import Depends, Form, Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
DEFAULT_LOCATION_LABEL,
default_form,
rozszerzenia,
_build_utc,
_dozwolona_akcja,
_logic_error,
)
# ---------------- 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,
**rozszerzenia.kontekst_szablonu(request)},
)
@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"),
# Pola wnoszone przez moduły opcjonalne. FastAPI czyta pola z sygnatury, a ta
# nie może ich wymieniać — w obrazie bez modułu byłyby nazwą nieistniejącej
# funkcji wpisaną w kod. Zależność przenosi deklarację do modułu.
dodatki: dict = Depends(rozszerzenia.pola_formularza()),
):
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
"lat": lat, "lon": lon, "from_date": from_date, "to_date": to_date,
**dodatki}
action = _dozwolona_akcja(request, action, "timeline")
ctx: dict = {"form": form, "result": None, "error": None, "moment": None,
**rozszerzenia.kontekst_szablonu(request)}
try:
iso_utc, label = _build_utc(date, time, tz_offset)
ctx["moment"] = label
wynik = rozszerzenia.obsluz(action, profil="period", iso_utc=iso_utc,
lat=lat, lon=lon, dodatki=dodatki,
od=from_date, do=to_date)
if wynik is not None:
ctx["wynik_dodatku"] = wynik
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)
features.zarejestruj(
ekran=Feature("timeline", "Kalendarz", "Predykcje na wybrany zakres dat.",
"Ekrany", "/timeline", kolejnosc=30),
trasy={("GET", "/timeline"): "timeline", ("POST", "/timeline"): "timeline"},
zasoby={"s-timeline.css": "timeline"},
)
+104
View File
@@ -0,0 +1,104 @@
"""Konta i uprawnienia (PRE-27) — ekran wyłącznie administracyjny."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.features import Feature
from app.podstawa import (app, templates, security, features, accounts_store)
# ---------------- 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:
# Problem z magazynem NIE MOŻE dawać gołego 500: to jedyny ekran, z którego
# administrator może go naprawić, więc musi na nim przeczytać, co i gdzie
# jest nie tak.
try:
users = accounts_store.all_users()
except accounts_store.AccountsUnavailable as e:
users, error = {}, str(e)
return {
"users": users,
"catalog": features.ALL,
"screens": features.SCREENS,
# Rejestr zawiera już wszystko, co ten obraz umie — łącznie z tym, co
# wniosły moduły opcjonalne. Administrator ma nadawać uprawnienia do
# funkcji, które REALNIE są, a nie do listy wpisanej kiedyś na sztywno.
"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, accounts_store.AccountsUnavailable) 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, accounts_store.AccountsUnavailable) 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/sesje")
def accounts_sesje(request: Request, login: str = Form(...)):
"""Unieważnia WSZYSTKIE otwarte sesje konta, na każdym urządzeniu.
Zmiana hasła robi to samo przy okazji, więc ta operacja jest na wypadek
„wyrzuć zewsząd, ale hasło zostaw" — na przykład gdy ktoś zostawił zalogowany
komputer i nie chce wpisywać nowego hasła wszędzie."""
try:
accounts_store.bump_session(login)
except (ValueError, accounts_store.AccountsUnavailable) as e:
return _accounts_redirect(error=str(e))
return _accounts_redirect(done=f"Zamknięto wszystkie sesje konta „{login}”.")
@app.post("/accounts/delete")
def accounts_delete(request: Request, login: str = Form(...)):
try:
accounts_store.delete(login)
except (ValueError, accounts_store.AccountsUnavailable) as e:
return _accounts_redirect(error=str(e))
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
# Ekran wyłącznie administracyjny — nie ma pozycji w katalogu funkcji, bo ADMIN
# nie jest uprawnieniem do nadania (patrz features.GRANTABLE).
features.zarejestruj(
nawigacja_admina=("accounts", "/accounts", "Konta"),
trasy={("GET", "/accounts"): features.ADMIN,
("POST", "/accounts/create"): features.ADMIN,
("POST", "/accounts/update"): features.ADMIN,
("POST", "/accounts/delete"): features.ADMIN,
("POST", "/accounts/sesje"): features.ADMIN},
zasoby={"s-accounts.css": features.ADMIN},
)
@@ -0,0 +1,79 @@
"""Logowanie i wylogowanie (LOG-34)."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.features import Feature
from app.podstawa import (features, app, templates, security, session)
# ---------------- Logowanie i wylogowanie (LOG-34) ----------------
# Zastępuje HTTP Basic, który nie miał wylogowania: przeglądarka zapamiętywała
# hasło i dosyłała je sama, więc serwer nie miał czego zapomnieć. Teraz to serwer
# decyduje, czy dana przeglądarka jest w środku — i może to cofnąć.
def _bezpieczne_dokad(dokad: str) -> str:
"""Adres powrotu po zalogowaniu, przepuszczony przez sito.
Bez tego `?dokad=https://obcy.pl` zamieniłby nasz ekran logowania w narzędzie
do przekierowywania ludzi gdzie indziej — klasyczna droga do wyłudzenia hasła,
bo odsyłacz prowadzi z zaufanego adresu. Wpuszczamy wyłącznie ścieżki
wewnętrzne: jeden ukośnik na początku i ani jednego znaku, który mógłby
zacząć adres obcego serwera."""
dokad = (dokad or "").strip()
if not dokad.startswith("/") or dokad.startswith("//") or "\\" in dokad:
return "/"
return dokad.split("?", 1)[0].split("#", 1)[0] or "/"
@app.get("/logowanie", response_class=HTMLResponse)
def logowanie_form(request: Request, dokad: str = "/", blad: str = ""):
# Zalogowanego nie ma po co pytać o hasło jeszcze raz.
if security.auth_enabled() and security.principal(request) is not None:
return RedirectResponse(_bezpieczne_dokad(dokad), status_code=303)
return templates.TemplateResponse(request, "logowanie.html",
{"dokad": _bezpieczne_dokad(dokad), "blad": blad})
@app.post("/logowanie")
def logowanie(request: Request, login: str = Form(""), haslo: str = Form(""),
dokad: str = Form("/")):
cel = _bezpieczne_dokad(dokad)
who = security.verify(login.strip(), haslo)
if who is None:
# JEDEN komunikat na złe hasło i nieznany login. Rozróżnienie mówiłoby,
# które konta istnieją — a to informacja warta czegoś dla zgadującego.
security.audit_login(request, login.strip(), udane=False)
return templates.TemplateResponse(
request, "logowanie.html",
{"dokad": cel, "blad": "Nieprawidłowy login lub hasło.", "login": login},
status_code=401)
security.audit_login(request, who.login, udane=True)
odpowiedz = RedirectResponse(cel, status_code=303)
odpowiedz.set_cookie(session.COOKIE, security.issue_session(who.login),
max_age=session.max_age(),
**session.cookie_params(secure=security.cookies_secure()))
return odpowiedz
@app.post("/wyloguj")
def wyloguj(request: Request):
"""Kasuje ciasteczko sesji. To jest całe wylogowanie — natychmiastowe
i bez żadnego magazynu, bo przeglądarka nie ma już czego dosyłać.
POST, nie GET: pod adresem GET wystarczyłby obrazek na obcej stronie, żeby
wylogować kogoś bez jego wiedzy. Uciążliwość, nie włamanie — ale darmowa
do uniknięcia."""
security.audit_logout(request)
odpowiedz = RedirectResponse("/logowanie?wylogowano=1", status_code=303)
odpowiedz.delete_cookie(session.COOKIE, **session.cookie_params(
secure=security.cookies_secure()))
return odpowiedz
# Wyjście musi być dostępne dla każdego zalogowanego, niezależnie od tego, co mu
# przyznano — konto bez ani jednego ekranu też ma prawo wyjść.
features.zarejestruj(
trasy={("GET", "/logowanie"): None, ("POST", "/logowanie"): None,
("POST", "/wyloguj"): None},
)
@@ -0,0 +1,58 @@
"""Wyszukiwarka miejsca i strefa czasowa (proxy OSM/Nominatim)."""
from __future__ import annotations
from datetime import timezone
from fastapi import HTTPException, Query
import httpx
from app.features import Feature
from app.podstawa import (features, app, geocode)
# ---------------- 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
# Wyszukiwarka miejsca siedzi na kilku ekranach naraz, więc jej zasoby otwiera
# KTÓREKOLWIEK z nich — wymaganie wszystkich byłoby bez sensu.
_LOKALIZACJA = frozenset({"chart", "interpret", "timeline"})
_FORMULARZE = frozenset({"chart", "interpret", "timeline", "compile"})
_KOLO = frozenset({"chart", "compile"})
features.zarejestruj(
trasy={("GET", "/geocode"): None, ("GET", "/reverse"): None,
("GET", "/timezone"): None},
zasoby={"geo.js": _LOKALIZACJA, "now.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.css": _LOKALIZACJA,
"formsync.js": _FORMULARZE,
"wheelzoom.js": _KOLO, "wheeltip.js": _KOLO,
"copy.js": frozenset({"interpret", "timeline"})},
)
+164
View File
@@ -0,0 +1,164 @@
"""Pliki baz: wybór, wgrywanie, archiwizacja i reguły przyjmowania (DAN-27)."""
from __future__ import annotations
from fastapi import File, Form, Request, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
import base64
import httpx
from app.features import Feature
from app.podstawa import (app, templates, logic, features, files_state, _perms, _logic_error)
# ---------------- Pliki baz (DAN-27) ----------------
# Trzy poziomy dostępu, opisane w features.ROUTES:
# „files" — widzi listę i decyduje, z czego program korzysta,
# „files_input" — dokłada wgrywanie i ARCHIWIZACJĘ (plik zostaje zamrożony
# ze znacznikiem czasu, znika tylko z użytku),
# administrator — kasowanie, przywracanie z archiwum i REGUŁY WALIDACJI.
#
# Kwarantanna (plik wgrany, ale odrzucony przez walidację) jest odsiewana W WARSTWIE
# DANYCH przy `for_admin=False`. Nie filtrujemy jej tutaj ani w szablonie: gdyby
# takie pliki dochodziły do przeglądarki, wystarczyłby podgląd źródła, żeby poznać
# reguły — a te ma znać wyłącznie administrator.
def _files_context(request: Request, error: str = "", done: str = "") -> dict:
is_admin = features.ADMIN in _perms(request)
try:
data = logic.files_list(for_admin=is_admin)
except httpx.HTTPError as e:
return {"files": [], "rules": {}, "is_admin": is_admin,
"error": _logic_error(e), "done": ""}
return {"files": data.get("files") or [], "rules": data.get("rules") or {},
"is_admin": is_admin, "error": error, "done": done}
@app.get("/files", response_class=HTMLResponse)
def files_view(request: Request, error: str = "", done: str = ""):
return templates.TemplateResponse(request, "files.html",
_files_context(request, error, done))
def _files_redirect(error: str = "", done: str = "") -> RedirectResponse:
from urllib.parse import urlencode
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
return RedirectResponse(f"/files{'?' + q if q else ''}", status_code=303)
def _who(request: Request) -> str:
return getattr(request.state, "user", "") or "-"
@app.post("/files/use")
def files_use(request: Request, path: str = Form(...), use: str = Form("")):
"""Włącza albo odstawia bazę. Włączenie przechodzi przez bramkę walidacji
w warstwie danych — odmowa wraca BEZ POWODU, bo powód zdradzałby reguły."""
want = files_state.ACTIVE if use.strip().lower() in {"1", "true", "on", "tak"} \
else files_state.READY
try:
logic.files_status(path, want, by=_who(request))
except httpx.HTTPStatusError as e:
detail = _http_detail(e)
if features.ADMIN not in _perms(request):
detail = "Tego pliku nie da się teraz włączyć do użytku."
return _files_redirect(error=detail)
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done="Zapisano." if want == files_state.READY
else "Plik włączony do użytku.")
@app.post("/files/upload")
async def files_upload(request: Request, upload: UploadFile = File(...)):
"""Wgranie nowej bazy. Plik zostaje NIEZALEŻNIE od wyniku walidacji —
nie tracimy niczego, co ktoś wgrał."""
raw = await upload.read()
if not raw:
return _files_redirect(error="Pusty plik.")
try:
out = logic.files_upload(upload.filename or "plik.xlsx",
base64.b64encode(raw).decode("ascii"), by=_who(request))
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
# DOKŁADNIE ten sam komunikat niezależnie od wyniku sprawdzenia. Dwa różne
# komunikaty były wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać
# odpowiedź, żeby odgadnąć reguły, które ma znać wyłącznie administrator.
# Poprzednia treść mówiła przy tym wprost, że istnieje „administrator", który
# musi plik „zatwierdzić" — czyli że konto wgrywającego jest ograniczone
# i że plik przechodzi przez jakąś bramkę.
return _files_redirect(done=f"Wgrano „{out.get('name')}”.")
@app.post("/files/archive")
def files_archive(request: Request, path: str = Form(...)):
"""Archiwizacja: plik ZOSTAJE, zamrożony, ze znacznikiem czasu — znika tylko
z użytku. To najdalej idąca operacja dostępna osobie wgrywającej dane."""
try:
logic.files_status(path, files_state.ARCHIVED, by=_who(request))
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done=f"Zarchiwizowano „{path}”. Plik został zachowany.")
@app.post("/files/restore")
def files_restore(request: Request, path: str = Form(...)):
try:
logic.files_status(path, files_state.READY, by=_who(request))
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done=f"Przywrócono „{path}” z archiwum.")
@app.post("/files/delete")
def files_delete(request: Request, path: str = Form(...)):
try:
logic.files_delete(path)
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done=f"Skasowano „{path}” bezpowrotnie.")
@app.post("/files/rules")
def files_rules(request: Request, extensions: str = Form(".xlsx"),
max_size_mb: float = Form(50), min_rows: int = Form(1),
required_columns: str = Form(""),
reject_duplicate_content: str = Form("")):
rules = {
"extensions": [e.strip() for e in extensions.split(",") if e.strip()],
"max_size_mb": max_size_mb,
"min_rows": min_rows,
"required_columns": [c.strip() for c in required_columns.split(",") if c.strip()],
"reject_duplicate_content": reject_duplicate_content.strip().lower()
in {"1", "true", "on", "tak"},
}
try:
logic.files_rules(rules)
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done="Zapisano reguły walidacji.")
def _http_detail(e: httpx.HTTPStatusError) -> str:
try:
return str(e.response.json().get("detail") or e)
except Exception: # noqa: BLE001
return str(e)
features.zarejestruj(
ekran=Feature("files", "Pliki", "Wybór plików, z których korzysta program.",
"Ekrany", "/files", kolejnosc=70),
# Kasowanie, przywracanie i REGUŁY PRZYJMOWANIA to wyłącznie administrator —
# o istnieniu reguł nikt poza nim nie ma skąd wiedzieć (DAN-27).
trasy={("GET", "/files"): "files", ("POST", "/files/use"): "files",
("POST", "/files/upload"): "files_input",
("POST", "/files/archive"): "files_input",
("POST", "/files/restore"): features.ADMIN,
("POST", "/files/delete"): features.ADMIN,
("POST", "/files/rules"): features.ADMIN},
zasoby={"s-files.css": "files"},
)
features.zarejestruj(rozszerzenie=Feature(
"files_input", "Wgrywanie i archiwizacja plików",
"Dodawanie nowych plików i wycofywanie ich z użytku (plik zostaje, zamrożony, "
"ze znacznikiem czasu). Kasować może wyłącznie administrator.",
"Rozszerzenia", kolejnosc=40))
@@ -0,0 +1,96 @@
"""Skompiluj: zbiorczy raport z policzonych części (PRE-23), wraz z PDF-em."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse, JSONResponse, Response
import httpx
from app.features import Feature
from app.podstawa import (features,
app,
templates,
logic,
chartwheel_mod,
DEFAULT_LOCATION_LABEL,
default_form,
_perms,
_limit_options,
_build_utc,
_logic_error,
)
# ---------------- 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 z przeglądarki (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)
features.zarejestruj(
ekran=Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.",
"Ekrany", "/compile", kolejnosc=60),
trasy={("GET", "/compile"): "compile", ("POST", "/compile"): "compile"},
zasoby={"s-compile.css": "compile", "compile.js": "compile"},
)
@@ -0,0 +1,41 @@
"""Sygnifikatory: wyszukiwarka po bazach."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features, app, templates, logic, security, _logic_error)
# ---------------- 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)
features.zarejestruj(
ekran=Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach.",
"Ekrany", "/significators", kolejnosc=50),
trasy={("GET", "/significators"): "significators",
("POST", "/significators"): "significators"},
zasoby={"s-significators.css": "significators"},
)
@@ -0,0 +1,58 @@
"""Synastria: porównanie dwóch horoskopów."""
from __future__ import annotations
from fastapi import Form, Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features, app, templates, logic, _build_utc, _logic_error)
# ---------------- 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)
features.zarejestruj(
ekran=Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.",
"Ekrany", "/synastry", kolejnosc=40),
trasy={("GET", "/synastry"): "synastry", ("POST", "/synastry"): "synastry"},
zasoby={"s-synastry.css": "synastry"},
)
@@ -0,0 +1,32 @@
"""Ustawienia: podgląd baz widocznych na udziale (DAN-15/PRE-09)."""
from __future__ import annotations
from fastapi import Request
from fastapi.responses import HTMLResponse
import httpx
from app.features import Feature
from app.podstawa import (features, app, templates, logic, _logic_error)
# ---------------- 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)
features.zarejestruj(
ekran=Feature("settings", "Ustawienia", "Podgląd plików widocznych na udziale.",
"Ekrany", "/settings", kolejnosc=80),
trasy={("GET", "/settings"): "settings"},
zasoby={"s-settings.css": "settings"},
)