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,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"},
|
||||
)
|
||||
@@ -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"})},
|
||||
)
|
||||
@@ -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"},
|
||||
)
|
||||
Reference in New Issue
Block a user