diff --git a/services/presentation/app/ekrany/__init__.py b/services/presentation/app/ekrany/__init__.py
new file mode 100644
index 0000000..ecd0663
--- /dev/null
+++ b/services/presentation/app/ekrany/__init__.py
@@ -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ć.
+"""
diff --git a/services/presentation/app/ekrany/horoskop.py b/services/presentation/app/ekrany/horoskop.py
new file mode 100644
index 0000000..03b801d
--- /dev/null
+++ b/services/presentation/app/ekrany/horoskop.py
@@ -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("
404 — nie znaleziono
", 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))
diff --git a/services/presentation/app/ekrany/interpretacje.py b/services/presentation/app/ekrany/interpretacje.py
new file mode 100644
index 0000000..5864860
--- /dev/null
+++ b/services/presentation/app/ekrany/interpretacje.py
@@ -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"},
+)
diff --git a/services/presentation/app/ekrany/kalendarz.py b/services/presentation/app/ekrany/kalendarz.py
new file mode 100644
index 0000000..23142fd
--- /dev/null
+++ b/services/presentation/app/ekrany/kalendarz.py
@@ -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"},
+)
diff --git a/services/presentation/app/ekrany/konta.py b/services/presentation/app/ekrany/konta.py
new file mode 100644
index 0000000..490b60f
--- /dev/null
+++ b/services/presentation/app/ekrany/konta.py
@@ -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},
+)
diff --git a/services/presentation/app/ekrany/logowanie.py b/services/presentation/app/ekrany/logowanie.py
new file mode 100644
index 0000000..27697df
--- /dev/null
+++ b/services/presentation/app/ekrany/logowanie.py
@@ -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},
+)
diff --git a/services/presentation/app/ekrany/lokalizacja.py b/services/presentation/app/ekrany/lokalizacja.py
new file mode 100644
index 0000000..f5349bd
--- /dev/null
+++ b/services/presentation/app/ekrany/lokalizacja.py
@@ -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"})},
+)
diff --git a/services/presentation/app/ekrany/pliki.py b/services/presentation/app/ekrany/pliki.py
new file mode 100644
index 0000000..67b60df
--- /dev/null
+++ b/services/presentation/app/ekrany/pliki.py
@@ -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))
diff --git a/services/presentation/app/ekrany/skompiluj.py b/services/presentation/app/ekrany/skompiluj.py
new file mode 100644
index 0000000..3f992ba
--- /dev/null
+++ b/services/presentation/app/ekrany/skompiluj.py
@@ -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"},
+)
diff --git a/services/presentation/app/ekrany/sygnifikatory.py b/services/presentation/app/ekrany/sygnifikatory.py
new file mode 100644
index 0000000..b6a5f3b
--- /dev/null
+++ b/services/presentation/app/ekrany/sygnifikatory.py
@@ -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"},
+)
diff --git a/services/presentation/app/ekrany/synastria.py b/services/presentation/app/ekrany/synastria.py
new file mode 100644
index 0000000..6e65422
--- /dev/null
+++ b/services/presentation/app/ekrany/synastria.py
@@ -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"},
+)
diff --git a/services/presentation/app/ekrany/ustawienia.py b/services/presentation/app/ekrany/ustawienia.py
new file mode 100644
index 0000000..f093240
--- /dev/null
+++ b/services/presentation/app/ekrany/ustawienia.py
@@ -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"},
+)
diff --git a/services/presentation/app/features.py b/services/presentation/app/features.py
index 4251c97..7ac7841 100644
--- a/services/presentation/app/features.py
+++ b/services/presentation/app/features.py
@@ -33,95 +33,68 @@ class Feature:
hint: str
group: str
href: str = "" # tylko ekrany; rozszerzenia nie mają własnej zakładki
+ kolejnosc: int = 0 # pozycja w nawigacji; nie zależy od kolejności importów
-# ── ekrany (zakładki) ────────────────────────────────────────────────────
-SCREENS: tuple[Feature, ...] = (
- Feature("chart", "Horoskop", "Pozycje, osie, domy, aspekty, kosmogram.", "Ekrany", "/"),
- Feature("interpret", "Interpretacje", "Interpretacja natalna z baz i od modelu.", "Ekrany", "/interpret"),
- Feature("timeline", "Kalendarz", "Predykcje na wybrany zakres dat.", "Ekrany", "/timeline"),
- Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.", "Ekrany", "/synastry"),
- Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach interpretacyjnych.", "Ekrany", "/significators"),
- Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.", "Ekrany", "/compile"),
- Feature("files", "Pliki", "Wybór baz, z których korzysta program.", "Ekrany", "/files"),
- Feature("settings", "Ustawienia", "Podgląd baz i konfiguracji modelu.", "Ekrany", "/settings"),
-)
+# ── rejestr: katalog składa się z tego, co zgłoszą ekrany ────────────────
+# Dawniej wszystkie funkcje były wypisane TUTAJ. To znaczyło, że obraz produktu,
+# który części z nich nie ma, i tak niósł ich nazwy — czyli spis funkcji, których
+# nie ma jak włączyć. Teraz ekran zgłasza siebie sam, przy imporcie swojego
+# modułu, więc katalog opisuje dokładnie ten produkt, który się zbudowało.
+#
+# `kolejnosc` jest jawna, bo o zawartości decyduje lista importów w `main.py`,
+# a kolejność importów nie może rządzić kolejnością zakładek w nawigacji.
+SCREENS: list[Feature] = []
+# Pozycje nawigacji dla administratora. Osobno od SCREENS, bo ADMIN nie jest
+# uprawnieniem do nadania — a wpisane na sztywno w szablonie zostawiały nazwę
+# ekranu (i martwy odsyłacz) w produkcie, który tego ekranu nie ma.
+NAWIGACJA_ADMINA: list[tuple[str, str, str]] = [] # (klucz, adres, etykieta)
+EXTRAS: list[Feature] = []
+ALL: tuple[Feature, ...] = ()
+BY_KEY: dict[str, Feature] = {}
+GRANTABLE: frozenset[str] = frozenset()
-# ── rozszerzenia (poziomy złożoności wewnątrz ekranów) ───────────────────
-EXTRAS: tuple[Feature, ...] = (
- Feature("houses_compare", "Porównanie systemów domów",
- "Wybór systemu innego niż domyślny i zestawienie kilku obok siebie.",
- "Rozszerzenia"),
- Feature("extra_charts", "Wykresy dodatkowe",
- "Aspektarian, wykres deklinacji, oś antyscji.", "Rozszerzenia"),
- Feature("advanced_calc", "Obliczenia zaawansowane",
- "Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
- "Rozszerzenia"),
- Feature("files_input", "Wgrywanie i archiwizacja baz",
- "Dodawanie nowych plików baz i wycofywanie ich z użytku (plik zostaje, "
- "zamrożony, ze znacznikiem czasu). Kasować może wyłącznie administrator.",
- "Rozszerzenia"),
- Feature("export", "Eksport plików",
- "Pobieranie raportu jako PDF i wyników jako Excel.", "Rozszerzenia"),
-)
+ROUTES: dict[tuple[str, str], str | None] = {}
+STATIC: dict[str, frozenset[str] | str | None] = {}
-# Funkcje wnoszone przez moduły opcjonalne. Katalog nie wymienia ich z nazwy —
-# gdyby wymieniał, nazwa funkcji siedziałaby w kodzie także w obrazie, który tego
-# modułu nie zawiera, i `grep` po kontenerze pokazałby coś, czego tam nie ma.
-DOKLADANE: tuple[Feature, ...] = tuple(Feature(*k) for k in rozszerzenia.funkcje())
-ALL: tuple[Feature, ...] = SCREENS + EXTRAS + DOKLADANE
-BY_KEY: dict[str, Feature] = {f.key: f for f in ALL}
-GRANTABLE: frozenset[str] = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem
+def _przelicz() -> None:
+ global ALL, BY_KEY, GRANTABLE
+ SCREENS.sort(key=lambda f: f.kolejnosc)
+ EXTRAS.sort(key=lambda f: f.kolejnosc)
+ ALL = tuple(SCREENS) + tuple(EXTRAS)
+ BY_KEY = {f.key: f for f in ALL}
+ GRANTABLE = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem
+
+
+def zarejestruj(*, ekran: Feature | None = None, rozszerzenie: Feature | None = None,
+ trasy: dict[tuple[str, str], str | None] | None = None,
+ zasoby: dict | None = None,
+ nawigacja_admina: tuple[str, str, str] | None = None) -> None:
+ """Zgłoszenie funkcji, jej tras i jej zasobów.
+
+ Idempotentne po kluczu: `export` zgłaszają dwa ekrany (raport do PDF-a
+ i wyniki do arkusza), a wpis ma powstać raz."""
+ for pozycja, zbior in ((ekran, SCREENS), (rozszerzenie, EXTRAS)):
+ if pozycja and pozycja.key not in {f.key for f in zbior}:
+ zbior.append(pozycja)
+ if nawigacja_admina and nawigacja_admina[0] not in {p[0] for p in NAWIGACJA_ADMINA}:
+ NAWIGACJA_ADMINA.append(nawigacja_admina)
+ ROUTES.update(trasy or {})
+ STATIC.update(zasoby or {})
+ _przelicz()
+
+
+# Funkcje wnoszone przez moduły opcjonalne — zgłaszane tak samo jak ekrany,
+# tylko przez neutralny most, żeby katalog nie musiał ich wymieniać z nazwy.
+for _krotka in rozszerzenia.funkcje():
+ zarejestruj(rozszerzenie=Feature(*_krotka))
+zarejestruj(trasy=rozszerzenia.trasy(), zasoby=rozszerzenia.zasoby())
# ── mapa trasa → wymagane uprawnienie ────────────────────────────────────
# None = dostępne każdemu zalogowanemu. Dotyczy to pomocników (geokoder, strefa
# czasowa, health), które same w sobie NICZEGO nie zdradzają o funkcjach programu.
-ROUTES: dict[tuple[str, str], str | None] = {
- ("GET", "/"): None, # korzeń przekierowuje na pierwszy dostępny ekran
- ("POST", "/"): "chart",
- ("GET", "/interpret"): "interpret",
- ("POST", "/interpret"): "interpret",
- ("GET", "/timeline"): "timeline",
- ("POST", "/timeline"): "timeline",
- ("GET", "/synastry"): "synastry",
- ("POST", "/synastry"): "synastry",
- ("GET", "/significators"): "significators",
- ("POST", "/significators"): "significators",
- ("GET", "/compile"): "compile",
- ("POST", "/compile"): "compile",
- ("POST", "/compile/pdf"): "export",
- ("GET", "/settings"): "settings",
- # Zarządzanie plikami baz (DAN-27). Trzy poziomy: „files" wybiera, z czego
- # program korzysta; „files_input" dokłada wgrywanie i archiwizację;
- # kasowanie, przywracanie i REGUŁY WALIDACJI to wyłącznie administrator —
- # o istnieniu walidacji nikt poza nim nie ma skąd wiedzieć.
- ("GET", "/files"): "files",
- ("POST", "/files/use"): "files",
- ("POST", "/files/upload"): "files_input",
- ("POST", "/files/archive"): "files_input",
- ("POST", "/files/restore"): ADMIN,
- ("POST", "/files/delete"): ADMIN,
- ("POST", "/files/rules"): ADMIN,
- ("GET", "/accounts"): ADMIN,
- ("POST", "/accounts/create"): ADMIN,
- ("POST", "/accounts/update"): ADMIN,
- ("POST", "/accounts/delete"): ADMIN,
- ("GET", "/geocode"): None,
- ("GET", "/reverse"): None,
- ("GET", "/timezone"): None,
- ("GET", "/health"): None,
- # Wylogowanie musi być dostępne dla każdego zalogowanego, niezależnie od tego,
- # co mu przyznano — konto bez ani jednego ekranu też ma prawo wyjść.
- ("GET", "/logowanie"): None,
- ("POST", "/logowanie"): None,
- ("POST", "/wyloguj"): None,
- ("POST", "/accounts/sesje"): ADMIN,
-}
-# Trasy modułów opcjonalnych. Dokładane po utworzeniu mapy, żeby wpis o module
-# nie musiał być wpisany w tym pliku z nazwy.
-ROUTES.update(rozszerzenia.trasy())
# ── zasoby statyczne ─────────────────────────────────────────────────────
@@ -137,35 +110,6 @@ _FORMULARZE = frozenset({"chart", "interpret", "timeline", "compile"})
_LOKALIZACJA = frozenset({"chart", "interpret", "timeline"})
_KOLO = frozenset({"chart", "compile"})
-STATIC: dict[str, frozenset[str] | str | None] = {
- # arkusze ekranów — jeden ekran, jedno uprawnienie
- "s-chart.css": "chart",
- "s-interpret.css": "interpret",
- "s-timeline.css": "timeline",
- "s-synastry.css": "synastry",
- "s-significators.css": "significators",
- "s-compile.css": "compile",
- "s-files.css": "files",
- "s-settings.css": "settings",
- "s-accounts.css": ADMIN,
- # skrypty
- "formsync.js": _FORMULARZE,
- "now.js": _LOKALIZACJA,
- "geo.js": _LOKALIZACJA,
- "vendor/leaflet/leaflet.js": _LOKALIZACJA,
- "vendor/leaflet/leaflet.css": _LOKALIZACJA,
- "wheelzoom.js": _KOLO,
- "wheeltip.js": _KOLO,
- "copy.js": frozenset({"interpret", "timeline"}),
- "compile.js": "compile",
- # base.css celowo NIE MA tu wpisu: potrzebuje go ekran logowania, więc jest
- # publiczny (PUBLIC_PATHS w security.py). Dlatego nie wolno w nim trzymać
- # niczego, co nazywa jakąkolwiek funkcję.
-}
-
-
-# Zasoby modułów opcjonalnych — tak samo jak trasy.
-STATIC.update(rozszerzenia.zasoby())
def static_required(name: str) -> frozenset[str] | str | None:
@@ -197,7 +141,8 @@ def can(perms: frozenset[str], feature: frozenset[str] | str | None) -> bool:
def screens_for(perms: frozenset[str]) -> list[Feature]:
- """Zakładki do pokazania w nawigacji — w stałej kolejności katalogu."""
+ """Zakładki do pokazania w nawigacji — w kolejności zadeklarowanej, nie w tej,
+ w której akurat wykonały się importy."""
return [f for f in SCREENS if f.key in perms]
diff --git a/services/presentation/app/house_systems.py b/services/presentation/app/house_systems.py
index 3475948..0c9d8d2 100644
--- a/services/presentation/app/house_systems.py
+++ b/services/presentation/app/house_systems.py
@@ -1,7 +1,7 @@
"""Katalog systemów domów dla warstwy prezentacji — JEDNO źródło nazw.
Nazwy były zaszyte w czterech szablonach naraz (selektor na „Horoskop", selektor
-na „Skompiluj", checkboxy porównania, nagłówki tabeli). Przy trzech systemach
+w raporcie zbiorczym, checkboxy porównania, nagłówki tabeli). Przy trzech systemach
uchodziło to na sucho; przy dziesięciu rozjazd jest kwestią czasu — wystarczy
dopisać system w jednym miejscu i zapomnieć o trzech pozostałych.
diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py
index 93ea4f5..11caf71 100644
--- a/services/presentation/app/main.py
+++ b/services/presentation/app/main.py
@@ -1,1023 +1,39 @@
-"""Warstwa PREZENTACJI — usługa HTTP serwująca stronę WWW.
+"""Astrololo — ZŁOŻENIE aplikacji z ekranów i modułów opcjonalnych.
-W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane
-wyniki. Nie zawiera logiki biznesowej ani dostępu do danych — tylko UI.
+Ten plik nie zawiera już żadnej trasy. Odpowiada wyłącznie na pytanie „z czego
+składa się ten produkt": które ekrany wchodzą i co jest podpięte dodatkowo.
-Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych pozycji
-(do ręcznego testowania aplikacji). Wyszukiwarka sygnifikatorów przeniesiona pod
-„/significators".
+Import ekranu rejestruje jego trasy, więc lista poniżej JEST definicją produktu.
+Inny produkt to inna lista i inny zestaw plików w obrazie — nie ten sam program
+z wyłączonymi kawałkami.
"""
-# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
from __future__ import annotations
-import hashlib
-import json
-import logging
-import re
-from datetime import datetime, timedelta, timezone
-from functools import lru_cache
-from pathlib import Path
-
-import httpx
-from fastapi import (FastAPI, Depends, File, Form, HTTPException, Query, Request,
- UploadFile)
-from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
-from fastapi.templating import Jinja2Templates
-
-import base64
-
-from app import accounts as accounts_store
from app import rozszerzenia
-from app import chartwheel as chartwheel_mod
-from app import features
-from app import session
-from app import files_state
-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)
-# Katalogi modułów opcjonalnych idą PRZED wspólnym: moduł może dołożyć szablon,
-# którego wspólny zestaw nie ma. Wspólne szablony wołają je przez neutralne
-# gniazda `{% include "_dodatki_*" ignore missing %}` — bez modułu gniazdo jest
-# po prostu puste, a nie „wyłączone".
-templates = Jinja2Templates(directory=[*rozszerzenia.katalog_szablonow(), "app/templates"])
-# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _logic_error).
-log = logging.getLogger("astrololo.prezentacja")
-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")
-
-
-_TYPY_ZASOBOW = {
- ".css": "text/css; charset=utf-8",
- ".js": "application/javascript; charset=utf-8",
- ".svg": "image/svg+xml",
- ".png": "image/png",
- ".woff2": "font/woff2",
-}
-
-
-def _bez_komentarzy(tresc: str, sufiks: str) -> str:
- """Usuwa komentarze z arkusza albo skryptu przed wysłaniem.
-
- Komentarz w pliku statycznym jedzie do przeglądarki bajt w bajt, a te
- komentarze opisują funkcje pełnym zdaniem po polsku — łącznie z tym, czego
- konto ograniczone NIE widzi i kto to widzi. Zostają w repozytorium, bo tam
- są potrzebne; do odpowiedzi nie mają po co trafiać.
-
- Kasujemy tylko komentarze blokowe i CAŁE wiersze `//`. Komentarz na końcu
- wiersza zostaje CELOWO: żeby go usunąć, trzeba rozpoznać, czy `//` nie jest
- w napisie albo w wyrażeniu regularnym — a pomyłka tutaj psuje skrypt po
- cichu. Test pilnuje słownika zakazanych słów, więc reszta by i tak wyszła.
- """
- tresc = re.sub(r"/\*.*?\*/", "", tresc, flags=re.S)
- if sufiks == ".js":
- tresc = re.sub(r"(?m)^[ \t]*//.*$\n?", "", tresc)
- return re.sub(r"\n{3,}", "\n\n", tresc).strip() + "\n"
-
-
-@lru_cache(maxsize=None)
-def _asset_body(name: str) -> bytes | None:
- """Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
- for katalog in (*rozszerzenia.katalog_zasobow(), str(_STATIC_DIR)):
- korzen = Path(katalog).resolve()
- sciezka = (Path(katalog) / name).resolve()
- # Wyjście poza katalog zasobów: `..` w ścieżce albo dowiązanie na zewnątrz.
- if korzen in sciezka.parents and sciezka.is_file():
- break
- else:
- return None
- surowe = sciezka.read_bytes()
- sufiks = sciezka.suffix.lower()
- # Kod obcy zostaje nietknięty: minifikowany plik potrafi mieć `//` w napisie
- # albo w wyrażeniu regularnym, a on i tak nie mówi nic o naszym programie.
- if sufiks in (".css", ".js") and not name.startswith("vendor/"):
- try:
- return _bez_komentarzy(surowe.decode("utf-8"), sufiks).encode("utf-8")
- except UnicodeDecodeError:
- return surowe
- return surowe
-
-
-@lru_cache(maxsize=None)
-def _asset_version(name: str) -> str:
- tresc = _asset_body(name)
- # Hash liczony z treści WYSYŁANEJ, nie z pliku na dysku: inaczej zmiana
- # samego komentarza podbijałaby adres, choć odpowiedź jest ta sama.
- return hashlib.md5(tresc).hexdigest()[:8] if tresc is not None else "0"
-
-
-def static_url(name: str) -> str:
- return f"/static/{name}?v={_asset_version(name)}"
-
-
-@app.get("/static/{nazwa:path}")
-def static_file(nazwa: str):
- """Zasoby idą przez bramkę uprawnień jak każda inna trasa.
-
- Dopóki `/static/` było w PUBLIC_PREFIXES, katalog omijał całe sprawdzanie:
- ukrycie przycisku w szablonie nic nie dawało, bo obok leżał skrypt tej samej
- funkcji, pod nazwą, którą wystarczyło zgadnąć. Uprawnienie zasobu sprawdza
- middleware (features.static_required); tutaj zostaje samo wydanie pliku.
- """
- tresc = _asset_body(nazwa)
- if tresc is None:
- raise HTTPException(status_code=404)
- return Response(
- tresc,
- media_type=_TYPY_ZASOBOW.get(Path(nazwa).suffix.lower(), "application/octet-stream"),
- # Adres niesie hash treści, więc wersja pod danym adresem jest niezmienna.
- headers={"Cache-Control": "public, max-age=31536000, immutable"},
- )
-
-
-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
-templates.env.globals["STATUS_LABELS"] = files_state.LABELS
-
-
-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))
-# Kto jest zalogowany i czy logowanie w ogóle działa — pod przycisk wyjścia.
-templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
-templates.env.globals["auth_on"] = security.auth_enabled
-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
-
-
-# Akcja formularza → uprawnienie, którego wymaga. Akcji spoza tej mapy nie
-# ograniczamy; akcja Z mapy, na którą konto nie ma prawa, jest traktowana jak
-# NIEZNANA — czyli wraca do zachowania domyślnego ekranu.
-# Akcje własne tego ekranu plus te, które wnoszą moduły opcjonalne. Aplikacja
-# nie wypisuje ich z nazwy — gdyby wypisywała, nazwa funkcji byłaby w kodzie
-# także tam, gdzie modułu nie ma.
-_AKCJE_POD_UPRAWNIENIEM = {"export": "export", **rozszerzenia.akcje()}
-
-
-def _dozwolona_akcja(request: Request, action: str, domyslna: str) -> str:
- """Akcja, której konto nie ma prawa użyć, zachowuje się jak literówka.
-
- NIE zwracamy błędu ani 404: komunikat „brak uprawnień do generowania" sam
- w sobie mówiłby, że taka funkcja istnieje (PRE-27). Ukrycie przycisku chroni
- tylko przed przypadkiem — kto zna nazwę pola, wyśle ją wprost, i to TU musi
- się zatrzymać."""
- wymagane = _AKCJE_POD_UPRAWNIENIEM.get(action)
- if wymagane and wymagane not in _perms(request):
- return domyslna
- return action
-
-
-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
-
-
-# Jedno zdanie dla wszystkich awarii — i CELOWO żadnego szczegółu.
-# Poprzednia treść wypisywała na ekran nazwę endpointu (/chart/positions), nazwę
-# podsystemu („silnik efemeryd"), nazwę gałęzi rozwojowej i — przez str(wyjątku
-# httpx) — wewnętrzny adres z portem sąsiedniej usługi. Dla konta, które ma
-# widzieć wyłącznie Pliki, to był komplet: co program liczy, że stoi za nim
-# łańcuch usług, i gdzie zapukać.
-_AWARIA = "Nie udało się teraz wykonać tej operacji. Spróbuj ponownie za chwilę."
-
-
-def _logic_error(e: Exception) -> str:
- """Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran.
-
- Odsiew siedzi TUTAJ, a nie w siedemnastu miejscach wywołania: gdyby zależał
- od tego, czy ktoś pamiętał przekazać uprawnienia, prędzej czy później jedno
- wywołanie zostałoby pominięte — i nie byłoby tego jak zauważyć.
- """
- log.warning("warstwa logiczna: %s: %s", type(e).__name__, e)
- return _AWARIA
-
-
-# ---------------- 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("
404 — nie znaleziono
", 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)
-
-
-# ---------------- 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)
-
-
-@app.post("/compile/pdf")
-def compile_pdf(payload: dict):
- """Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu.
-
- Wejście z przeglądarki, bo część kawałków (interpretacja natalna i predykcje)
- mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF
- zawierał dokładnie to, co widać na stronie.
-
- Kosmogram idzie w motywie DRUKU: samodzielny konwerter SVG→PDF nie zna
- naszego arkusza, więc zmienne CSS i font glifów muszą być w samym rysunku.
- """
- from fastapi.responses import Response
-
- from app.clients.render_client import RenderClient
-
- data = payload.get("data") or {}
- try:
- iso_utc, label = _build_utc(
- str(data.get("date") or ""), str(data.get("time") or ""),
- float(data.get("tz_offset") or 0.0),
- )
- except (ValueError, TypeError) as e:
- return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
-
- figures: list[dict] = []
- warnings: list[str] = []
- try:
- chart = logic.positions(
- when_utc_iso=iso_utc,
- lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0),
- house_system=str(data.get("house_system") or "whole_sign"),
- house_systems=[s for s in (data.get("house_systems") or []) if s],
- aspect_orb=float(data.get("aspect_orb") or 8.0),
- aspect_luminary_bonus=float(data.get("aspect_luminary_bonus") or 2.0),
- aspect_minor=bool(data.get("aspect_minor")),
- stations=bool(data.get("stations")), tables=bool(data.get("tables")),
- zodiac=str(data.get("zodiac") or "tropical"),
- )
- # Fallback systemu domów musi dojechać do PDF-a — z samego kosmogramu
- # nie da się poznać, że podział jest z innego systemu, niż zamówiono.
- warnings = [w for w in (chart.get("house_warnings") or []) if w]
-
- from app import chartwheel
-
- # Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki
- # w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc
- # zmienne CSS i font glifów muszą być wprost w rysunku.
- for svg, caption in (
- (chartwheel.render(chart, theme="print",
- orientation=str(data.get("wheel_orientation") or chartwheel_mod.ASC_LEFT)),
- "Kosmogram"),
- (chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"),
- (chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"),
- (chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"),
- ):
- if svg:
- figures.append({"svg": svg, "caption": caption})
- except httpx.HTTPError as e:
- # Brak rysunków nie może zablokować raportu — tekst jest ważniejszy.
- data = {**data, "wheel_error": _logic_error(e)}
-
- report = {
- "person": payload.get("person") or "",
- "data": {**data, "moment_utc": label},
- "figures": figures,
- "warnings": warnings,
- "natal": payload.get("natal") or {},
- "predictions": payload.get("predictions") or [],
- }
- try:
- pdf = RenderClient().pdf(report)
- except httpx.HTTPError as e:
- return JSONResponse(
- {"detail": f"Usługa render niedostępna albo nie złożyła PDF-a: {e}"},
- status_code=502,
- )
- return Response(content=pdf, media_type="application/pdf",
- headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
-
-
-# ---------------- 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,
- **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
- 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"'},
- )
- # Akcja może należeć do modułu opcjonalnego. `None` znaczy „nie jego",
- # więc ekran robi to, co robi normalnie.
- wynik = rozszerzenia.obsluz(action, profil="natal", iso_utc=iso_utc,
- lat=lat, lon=lon, dodatki=dodatki)
- 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)
-
-
-# ---------------- 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)
-
-
-# ---------------- 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
-
-
-# ---------------- 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)
-
-
-# ---------------- 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,
- # Rozszerzenia własne PLUS wniesione przez moduły opcjonalne — inaczej
- # administrator nie mógłby nadać uprawnienia do funkcji, która w tym
- # obrazie istnieje.
- "extras": features.EXTRAS + features.DOKLADANE,
- "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}”.")
-
-
-# ---------------- 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
-
-
-@app.get("/health")
-def health() -> dict:
- # Sama „ok" — nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa.
- # Kubernetes i compose czytają wyłącznie kod odpowiedzi.
- return {"status": "ok"}
-
+from app.podstawa import (_asset_body, _asset_version, _build_utc, _logic_error,
+ _report_records, app, logic, security, static_url,
+ templates)
+from app.ekrany import ( # noqa: F401 - import rejestruje trasy
+ horoskop,
+ interpretacje,
+ kalendarz,
+ konta,
+ logowanie,
+ lokalizacja,
+ pliki,
+ skompiluj,
+ sygnifikatory,
+ synastria,
+ ustawienia,
+)
+
+# Nazwy przekazywane dalej dla wygody wołających (testy, uvicorn).
+__all__ = ["app", "logic", "templates", "static_url", "_asset_body", "_asset_version"]
# ── moduły opcjonalne ──────────────────────────────────────────────────────
-# Na końcu pliku, bo moduł dostaje gotowe zależności (klienta logiki, szablony,
-# pomocnicze funkcje), a te muszą już istnieć. Bez modułu w obrazie to wywołanie
-# nie robi nic i aplikacja nie ma pojęcia, że czegoś brakuje.
+# Na końcu, bo moduł dostaje gotowe zależności, a te muszą już istnieć. Bez
+# modułu w obrazie to wywołanie nie robi nic i aplikacja nie ma pojęcia,
+# że czegoś brakuje.
rozszerzenia.zamontuj(app, logic=logic, templates=templates,
- buduj_utc=_build_utc, blad=_logic_error)
+ buduj_utc=_build_utc, blad=_logic_error,
+ security=security, licz_rekordy=_report_records)
diff --git a/services/presentation/app/moduly/__init__.py b/services/presentation/app/moduly/__init__.py
new file mode 100644
index 0000000..8f650ff
--- /dev/null
+++ b/services/presentation/app/moduly/__init__.py
@@ -0,0 +1,11 @@
+"""Moduły opcjonalne — każdy podkatalog to jedna odłączalna część produktu.
+
+O tym, co produkt umie, decyduje ZAWARTOŚĆ tego katalogu w obrazie. Most
+(`app.rozszerzenia`) odkrywa moduły, przechodząc po podkatalogach, i nie zna
+żadnej nazwy — gdyby znał, nazwa nieobecnego modułu i tak jechałaby do obrazu,
+w którym go nie ma.
+
+Moduł może wnieść: pozycję do katalogu funkcji, trasy, zasoby, akcje formularza,
+pola formularza, wpisy do kontekstu szablonu, własne szablony i własne statyki.
+Wszystko jest opcjonalne — most pyta o to, czego moduł nie ma, i dostaje pusto.
+"""
diff --git a/services/presentation/app/dodatki/__init__.py b/services/presentation/app/moduly/dodatki/__init__.py
similarity index 88%
rename from services/presentation/app/dodatki/__init__.py
rename to services/presentation/app/moduly/dodatki/__init__.py
index ac16f43..0829ea6 100644
--- a/services/presentation/app/dodatki/__init__.py
+++ b/services/presentation/app/moduly/dodatki/__init__.py
@@ -16,8 +16,8 @@ gniazdo sam sprawdza uprawnienie. Pilnuje tego test `test_ai_tylko_w_module.py`.
"""
from __future__ import annotations
-from app.dodatki.katalog import AKCJE, FUNKCJA, TRASY, ZASOBY, katalog_dla, obsluz, pola
-from app.dodatki.trasy import zamontuj
+from app.moduly.dodatki.katalog import AKCJE, FUNKCJA, TRASY, ZASOBY, katalog_dla, obsluz, pola
+from app.moduly.dodatki.trasy import zamontuj
__all__ = ["AKCJE", "FUNKCJA", "TRASY", "ZASOBY", "katalog_dla", "obsluz", "pola",
"zamontuj"]
diff --git a/services/presentation/app/dodatki/katalog.py b/services/presentation/app/moduly/dodatki/katalog.py
similarity index 98%
rename from services/presentation/app/dodatki/katalog.py
rename to services/presentation/app/moduly/dodatki/katalog.py
index 63ea367..82b8538 100644
--- a/services/presentation/app/dodatki/katalog.py
+++ b/services/presentation/app/moduly/dodatki/katalog.py
@@ -11,7 +11,7 @@ from typing import Any
import httpx
from fastapi import Form, Request
-from app.dodatki import klient
+from app.moduly.dodatki import klient
# Pozycja w katalogu funkcji — surowa krotka, żeby nie importować `features`
# (to zamknęłoby cykl features → most → moduł → features).
diff --git a/services/presentation/app/dodatki/klient.py b/services/presentation/app/moduly/dodatki/klient.py
similarity index 100%
rename from services/presentation/app/dodatki/klient.py
rename to services/presentation/app/moduly/dodatki/klient.py
diff --git a/services/presentation/app/dodatki/static/models.js b/services/presentation/app/moduly/dodatki/static/models.js
similarity index 100%
rename from services/presentation/app/dodatki/static/models.js
rename to services/presentation/app/moduly/dodatki/static/models.js
diff --git a/services/presentation/app/dodatki/static/natal.js b/services/presentation/app/moduly/dodatki/static/natal.js
similarity index 100%
rename from services/presentation/app/dodatki/static/natal.js
rename to services/presentation/app/moduly/dodatki/static/natal.js
diff --git a/services/presentation/app/dodatki/static/predictions.js b/services/presentation/app/moduly/dodatki/static/predictions.js
similarity index 100%
rename from services/presentation/app/dodatki/static/predictions.js
rename to services/presentation/app/moduly/dodatki/static/predictions.js
diff --git a/services/presentation/app/dodatki/static/progress.js b/services/presentation/app/moduly/dodatki/static/progress.js
similarity index 100%
rename from services/presentation/app/dodatki/static/progress.js
rename to services/presentation/app/moduly/dodatki/static/progress.js
diff --git a/services/presentation/app/dodatki/static/x-ai.css b/services/presentation/app/moduly/dodatki/static/x-ai.css
similarity index 100%
rename from services/presentation/app/dodatki/static/x-ai.css
rename to services/presentation/app/moduly/dodatki/static/x-ai.css
diff --git a/services/presentation/app/dodatki/templates/_dodatki_compile_skrypty.html b/services/presentation/app/moduly/dodatki/templates/_dodatki_compile_skrypty.html
similarity index 100%
rename from services/presentation/app/dodatki/templates/_dodatki_compile_skrypty.html
rename to services/presentation/app/moduly/dodatki/templates/_dodatki_compile_skrypty.html
diff --git a/services/presentation/app/dodatki/templates/_dodatki_compile_style.html b/services/presentation/app/moduly/dodatki/templates/_dodatki_compile_style.html
similarity index 100%
rename from services/presentation/app/dodatki/templates/_dodatki_compile_style.html
rename to services/presentation/app/moduly/dodatki/templates/_dodatki_compile_style.html
diff --git a/services/presentation/app/dodatki/templates/_dodatki_compile_wyniki.html b/services/presentation/app/moduly/dodatki/templates/_dodatki_compile_wyniki.html
similarity index 100%
rename from services/presentation/app/dodatki/templates/_dodatki_compile_wyniki.html
rename to services/presentation/app/moduly/dodatki/templates/_dodatki_compile_wyniki.html
diff --git a/services/presentation/app/dodatki/templates/_dodatki_interpret_akcje.html b/services/presentation/app/moduly/dodatki/templates/_dodatki_interpret_akcje.html
similarity index 100%
rename from services/presentation/app/dodatki/templates/_dodatki_interpret_akcje.html
rename to services/presentation/app/moduly/dodatki/templates/_dodatki_interpret_akcje.html
diff --git a/services/presentation/app/dodatki/templates/_dodatki_interpret_formularz.html b/services/presentation/app/moduly/dodatki/templates/_dodatki_interpret_formularz.html
similarity index 100%
rename from services/presentation/app/dodatki/templates/_dodatki_interpret_formularz.html
rename to services/presentation/app/moduly/dodatki/templates/_dodatki_interpret_formularz.html
diff --git a/services/presentation/app/dodatki/templates/_dodatki_interpret_skrypty.html b/services/presentation/app/moduly/dodatki/templates/_dodatki_interpret_skrypty.html
similarity index 100%
rename from services/presentation/app/dodatki/templates/_dodatki_interpret_skrypty.html
rename to services/presentation/app/moduly/dodatki/templates/_dodatki_interpret_skrypty.html
diff --git a/services/presentation/app/dodatki/templates/_dodatki_interpret_style.html b/services/presentation/app/moduly/dodatki/templates/_dodatki_interpret_style.html
similarity index 100%
rename from services/presentation/app/dodatki/templates/_dodatki_interpret_style.html
rename to services/presentation/app/moduly/dodatki/templates/_dodatki_interpret_style.html
diff --git a/services/presentation/app/dodatki/templates/_dodatki_timeline_akcje.html b/services/presentation/app/moduly/dodatki/templates/_dodatki_timeline_akcje.html
similarity index 100%
rename from services/presentation/app/dodatki/templates/_dodatki_timeline_akcje.html
rename to services/presentation/app/moduly/dodatki/templates/_dodatki_timeline_akcje.html
diff --git a/services/presentation/app/dodatki/templates/_dodatki_timeline_formularz.html b/services/presentation/app/moduly/dodatki/templates/_dodatki_timeline_formularz.html
similarity index 100%
rename from services/presentation/app/dodatki/templates/_dodatki_timeline_formularz.html
rename to services/presentation/app/moduly/dodatki/templates/_dodatki_timeline_formularz.html
diff --git a/services/presentation/app/dodatki/templates/_dodatki_timeline_skrypty.html b/services/presentation/app/moduly/dodatki/templates/_dodatki_timeline_skrypty.html
similarity index 100%
rename from services/presentation/app/dodatki/templates/_dodatki_timeline_skrypty.html
rename to services/presentation/app/moduly/dodatki/templates/_dodatki_timeline_skrypty.html
diff --git a/services/presentation/app/dodatki/templates/_dodatki_timeline_style.html b/services/presentation/app/moduly/dodatki/templates/_dodatki_timeline_style.html
similarity index 100%
rename from services/presentation/app/dodatki/templates/_dodatki_timeline_style.html
rename to services/presentation/app/moduly/dodatki/templates/_dodatki_timeline_style.html
diff --git a/services/presentation/app/dodatki/templates/_prompt_block.html b/services/presentation/app/moduly/dodatki/templates/_prompt_block.html
similarity index 100%
rename from services/presentation/app/dodatki/templates/_prompt_block.html
rename to services/presentation/app/moduly/dodatki/templates/_prompt_block.html
diff --git a/services/presentation/app/dodatki/templates/_prompt_result.html b/services/presentation/app/moduly/dodatki/templates/_prompt_result.html
similarity index 100%
rename from services/presentation/app/dodatki/templates/_prompt_result.html
rename to services/presentation/app/moduly/dodatki/templates/_prompt_result.html
diff --git a/services/presentation/app/dodatki/trasy.py b/services/presentation/app/moduly/dodatki/trasy.py
similarity index 91%
rename from services/presentation/app/dodatki/trasy.py
rename to services/presentation/app/moduly/dodatki/trasy.py
index 607bf21..37b4f63 100644
--- a/services/presentation/app/dodatki/trasy.py
+++ b/services/presentation/app/moduly/dodatki/trasy.py
@@ -8,7 +8,7 @@ import httpx
from fastapi import Form
from fastapi.responses import JSONResponse, StreamingResponse
-from app.dodatki import katalog, klient
+from app.moduly.dodatki import katalog, klient
_logic: Any = None
_szablony: Any = None
@@ -16,7 +16,8 @@ _buduj_utc: Any = None
_blad: Any = None
-def zamontuj(app, *, logic: Any, templates: Any, buduj_utc: Any, blad: Any) -> None:
+def zamontuj(app, *, logic: Any, templates: Any, buduj_utc: Any, blad: Any,
+ **_reszta) -> None:
"""Podpięcie tras i przekazanie modułowi tego, czego potrzebuje.
Zależności przychodzą z zewnątrz, zamiast być importowane: moduł nie może
@@ -24,6 +25,9 @@ def zamontuj(app, *, logic: Any, templates: Any, buduj_utc: Any, blad: Any) -> N
ma iść w JEDNĄ stronę. Aplikacja wie o istnieniu modułów opcjonalnych
(przez neutralny most), moduł nie wie nic o aplikacji.
"""
+ # `**_reszta` CELOWO: most podaje wszystkim modułom ten sam zestaw zależności,
+ # a każdy bierze z niego to, czego potrzebuje. Bez tego dołożenie zależności
+ # dla jednego modułu wywracałoby pozostałe.
global _logic, _szablony, _buduj_utc, _blad
_logic, _szablony, _buduj_utc, _blad = logic, templates, buduj_utc, blad
katalog.ustaw_zaleznosci(logic=logic, templates=templates)
diff --git a/services/presentation/app/moduly/eksport/__init__.py b/services/presentation/app/moduly/eksport/__init__.py
new file mode 100644
index 0000000..304ce84
--- /dev/null
+++ b/services/presentation/app/moduly/eksport/__init__.py
@@ -0,0 +1,13 @@
+"""Wynoszenie treści na zewnątrz: arkusz z wynikami i raport w PDF-ie.
+
+Moduł ODŁĄCZALNY. Jest w obrazie astrololo; w produktach, które nie mają wynosić
+treści baz poza program, nie ma go wcale — a nie ma go jak włączyć, bo nie ma
+czego. Eksport wynosi NAJWIĘCEJ treści naraz, więc „można, ale nie temu kontu"
+i „nie ma takiej możliwości" to dwie różne gwarancje.
+"""
+from __future__ import annotations
+
+from app.moduly.eksport.katalog import AKCJE, FUNKCJA, TRASY, obsluz
+from app.moduly.eksport.trasy import zamontuj
+
+__all__ = ["AKCJE", "FUNKCJA", "TRASY", "obsluz", "zamontuj"]
diff --git a/services/presentation/app/report_export.py b/services/presentation/app/moduly/eksport/arkusz.py
similarity index 100%
rename from services/presentation/app/report_export.py
rename to services/presentation/app/moduly/eksport/arkusz.py
diff --git a/services/presentation/app/moduly/eksport/katalog.py b/services/presentation/app/moduly/eksport/katalog.py
new file mode 100644
index 0000000..1b1176a
--- /dev/null
+++ b/services/presentation/app/moduly/eksport/katalog.py
@@ -0,0 +1,47 @@
+"""Co moduł wnosi: funkcję, trasy i akcję formularza."""
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi.responses import Response
+
+FUNKCJA: tuple[tuple[str, str, str, str], ...] = (
+ ("export", "Eksport plików",
+ "Pobieranie raportu jako PDF i wyników jako arkusz.", "Rozszerzenia"),
+)
+
+TRASY: dict[tuple[str, str], str] = {("POST", "/compile/pdf"): "export"}
+
+# Akcja formularza: nazwa → wymagane uprawnienie.
+AKCJE: dict[str, str] = {"export": "export"}
+
+# Wstrzykiwane przy montowaniu — moduł nie importuje aplikacji.
+_logic: Any = None
+_security: Any = None
+_licz_rekordy: Any = None
+
+
+def ustaw_zaleznosci(*, logic: Any, security: Any, licz_rekordy: Any) -> None:
+ global _logic, _security, _licz_rekordy
+ _logic, _security, _licz_rekordy = logic, security, licz_rekordy
+
+
+def obsluz(akcja: str, *, iso_utc: str = "", lat: float = 0.0, lon: float = 0.0,
+ request: Any = None, group: bool = False, **_reszta) -> Response | None:
+ # Wszystko po `akcja` ma wartość domyślną CELOWO: most woła po kolei każdy
+ # moduł, także z ekranu, który tych danych nie ma. Brakujący argument
+ # wywracałby wtedy ekran, zamiast po prostu nie pasować do akcji.
+ if akcja not in AKCJE:
+ return None
+ # Arkusz roboczy z wynikami (DAN-23/PRE-10) — pobranie pliku, nie strona.
+ from app.moduly.eksport.arkusz import report_to_xlsx
+
+ report = _logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group)
+ # Eksport wynosi NAJWIĘCEJ treści baz naraz — tym bardziej ma zostawiać ślad
+ # w dzienniku (PRE-17): sama liczba rekordów, nigdy treść.
+ _security.audit_records(request, _licz_rekordy(report))
+ return Response(
+ content=report_to_xlsx(report),
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={"Content-Disposition": 'attachment; filename="interpretacje.xlsx"'},
+ )
diff --git a/services/presentation/app/moduly/eksport/trasy.py b/services/presentation/app/moduly/eksport/trasy.py
new file mode 100644
index 0000000..79d4556
--- /dev/null
+++ b/services/presentation/app/moduly/eksport/trasy.py
@@ -0,0 +1,114 @@
+"""Trasy wnoszone przez moduł. Bez tego katalogu w obrazie nie istnieją."""
+from __future__ import annotations
+
+from typing import Any
+
+import httpx
+from fastapi.responses import JSONResponse, Response
+
+from app.moduly.eksport import katalog
+
+_logic: Any = None
+_szablony: Any = None
+_buduj_utc: Any = None
+_blad: Any = None
+_kolo: Any = None
+
+
+def zamontuj(app, *, logic: Any, templates: Any, buduj_utc: Any, blad: Any,
+ security: Any = None, licz_rekordy: Any = None, kolo: Any = None,
+ **_reszta) -> None:
+ """Podpięcie trasy i przekazanie zależności. Idą z zewnątrz, bo zależność ma
+ biec w JEDNĄ stronę: aplikacja wie o modułach opcjonalnych (przez neutralny
+ most), moduł nie wie nic o aplikacji."""
+ global _logic, _szablony, _buduj_utc, _blad, _kolo
+ _logic, _szablony, _buduj_utc, _blad = logic, templates, buduj_utc, blad
+ _kolo = kolo
+ katalog.ustaw_zaleznosci(logic=logic, security=security, licz_rekordy=licz_rekordy)
+
+ # Nazwy, których używa przeniesiona trasa. `chartwheel` to czysty rysownik
+ # bez stanu, więc importujemy go wprost — wstrzykiwanie ma sens dla rzeczy
+ # związanych z aplikacją (klient, szablony), nie dla funkcji rysujących.
+ from app import chartwheel as chartwheel_mod
+
+ _build_utc = buduj_utc
+ _logic_error = blad
+
+ @app.post("/compile/pdf")
+ def compile_pdf(payload: dict):
+ """Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu.
+
+ Wejście z przeglądarki, bo część kawałków (interpretacja natalna i predykcje)
+ mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF
+ zawierał dokładnie to, co widać na stronie.
+
+ Kosmogram idzie w motywie DRUKU: samodzielny konwerter SVG→PDF nie zna
+ naszego arkusza, więc zmienne CSS i font glifów muszą być w samym rysunku.
+ """
+ from fastapi.responses import Response
+
+ from app.clients.render_client import RenderClient
+
+ data = payload.get("data") or {}
+ try:
+ iso_utc, label = _build_utc(
+ str(data.get("date") or ""), str(data.get("time") or ""),
+ float(data.get("tz_offset") or 0.0),
+ )
+ except (ValueError, TypeError) as e:
+ return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422)
+
+ figures: list[dict] = []
+ warnings: list[str] = []
+ try:
+ chart = logic.positions(
+ when_utc_iso=iso_utc,
+ lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0),
+ house_system=str(data.get("house_system") or "whole_sign"),
+ house_systems=[s for s in (data.get("house_systems") or []) if s],
+ aspect_orb=float(data.get("aspect_orb") or 8.0),
+ aspect_luminary_bonus=float(data.get("aspect_luminary_bonus") or 2.0),
+ aspect_minor=bool(data.get("aspect_minor")),
+ stations=bool(data.get("stations")), tables=bool(data.get("tables")),
+ zodiac=str(data.get("zodiac") or "tropical"),
+ )
+ # Fallback systemu domów musi dojechać do PDF-a — z samego kosmogramu
+ # nie da się poznać, że podział jest z innego systemu, niż zamówiono.
+ warnings = [w for w in (chart.get("house_warnings") or []) if w]
+
+ from app import chartwheel
+
+ # Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki
+ # w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc
+ # zmienne CSS i font glifów muszą być wprost w rysunku.
+ for svg, caption in (
+ (chartwheel.render(chart, theme="print",
+ orientation=str(data.get("wheel_orientation") or chartwheel_mod.ASC_LEFT)),
+ "Kosmogram"),
+ (chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"),
+ (chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"),
+ (chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"),
+ ):
+ if svg:
+ figures.append({"svg": svg, "caption": caption})
+ except httpx.HTTPError as e:
+ # Brak rysunków nie może zablokować raportu — tekst jest ważniejszy.
+ data = {**data, "wheel_error": _logic_error(e)}
+
+ report = {
+ "person": payload.get("person") or "",
+ "data": {**data, "moment_utc": label},
+ "figures": figures,
+ "warnings": warnings,
+ "natal": payload.get("natal") or {},
+ "predictions": payload.get("predictions") or [],
+ }
+ try:
+ pdf = RenderClient().pdf(report)
+ except httpx.HTTPError as e:
+ return JSONResponse(
+ {"detail": f"Usługa render niedostępna albo nie złożyła PDF-a: {e}"},
+ status_code=502,
+ )
+ return Response(content=pdf, media_type="application/pdf",
+ headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
diff --git a/services/presentation/app/podstawa.py b/services/presentation/app/podstawa.py
new file mode 100644
index 0000000..e1700a1
--- /dev/null
+++ b/services/presentation/app/podstawa.py
@@ -0,0 +1,275 @@
+"""Podstawa aplikacji: wspólne obiekty i pomocnicy, z których korzystają ekrany.
+
+Tu mieszka to, co ma KAŻDY produkt zbudowany z tego kodu: obiekt aplikacji,
+szablony, klient warstwy logicznej, serwowanie zasobów i garść funkcji
+pomocniczych. Ani jednej trasy ekranu — te są w `app/ekrany/*.py`, po jednym
+module na ekran, a o tym, które wchodzą, decyduje lista importów w `main.py`.
+
+Dlaczego tak: dopóki wszystko siedziało w jednym pliku, „produkt bez tego ekranu"
+znaczyło „produkt z ekranem, tylko zablokowanym". Plik i tak jechał do obrazu,
+razem z nazwami funkcji, których nie było jak włączyć.
+"""
+# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
+from __future__ import annotations
+
+import hashlib
+import logging
+import re
+from datetime import datetime, timedelta, timezone
+from functools import lru_cache
+from pathlib import Path
+
+from fastapi import FastAPI, HTTPException, Request
+from fastapi.responses import Response
+from fastapi.templating import Jinja2Templates
+
+
+from app import accounts as accounts_store
+from app import rozszerzenia
+from app import chartwheel as chartwheel_mod
+from app import features
+from app import session
+from app import files_state
+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)
+# Katalogi modułów opcjonalnych idą PRZED wspólnym: moduł może dołożyć szablon,
+# którego wspólny zestaw nie ma. Wspólne szablony wołają je przez neutralne
+# gniazda `{% include "_dodatki_*" ignore missing %}` — bez modułu gniazdo jest
+# po prostu puste, a nie „wyłączone".
+templates = Jinja2Templates(directory=[*rozszerzenia.katalog_szablonow(), "app/templates"])
+# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _logic_error).
+log = logging.getLogger("astrololo.prezentacja")
+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")
+
+
+_TYPY_ZASOBOW = {
+ ".css": "text/css; charset=utf-8",
+ ".js": "application/javascript; charset=utf-8",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".woff2": "font/woff2",
+}
+
+
+def _bez_komentarzy(tresc: str, sufiks: str) -> str:
+ """Usuwa komentarze z arkusza albo skryptu przed wysłaniem.
+
+ Komentarz w pliku statycznym jedzie do przeglądarki bajt w bajt, a te
+ komentarze opisują funkcje pełnym zdaniem po polsku — łącznie z tym, czego
+ konto ograniczone NIE widzi i kto to widzi. Zostają w repozytorium, bo tam
+ są potrzebne; do odpowiedzi nie mają po co trafiać.
+
+ Kasujemy tylko komentarze blokowe i CAŁE wiersze `//`. Komentarz na końcu
+ wiersza zostaje CELOWO: żeby go usunąć, trzeba rozpoznać, czy `//` nie jest
+ w napisie albo w wyrażeniu regularnym — a pomyłka tutaj psuje skrypt po
+ cichu. Test pilnuje słownika zakazanych słów, więc reszta by i tak wyszła.
+ """
+ tresc = re.sub(r"/\*.*?\*/", "", tresc, flags=re.S)
+ if sufiks == ".js":
+ tresc = re.sub(r"(?m)^[ \t]*//.*$\n?", "", tresc)
+ return re.sub(r"\n{3,}", "\n\n", tresc).strip() + "\n"
+
+
+@lru_cache(maxsize=None)
+def _asset_body(name: str) -> bytes | None:
+ """Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
+ for katalog in (*rozszerzenia.katalog_zasobow(), str(_STATIC_DIR)):
+ korzen = Path(katalog).resolve()
+ sciezka = (Path(katalog) / name).resolve()
+ # Wyjście poza katalog zasobów: `..` w ścieżce albo dowiązanie na zewnątrz.
+ if korzen in sciezka.parents and sciezka.is_file():
+ break
+ else:
+ return None
+ surowe = sciezka.read_bytes()
+ sufiks = sciezka.suffix.lower()
+ # Kod obcy zostaje nietknięty: minifikowany plik potrafi mieć `//` w napisie
+ # albo w wyrażeniu regularnym, a on i tak nie mówi nic o naszym programie.
+ if sufiks in (".css", ".js") and not name.startswith("vendor/"):
+ try:
+ return _bez_komentarzy(surowe.decode("utf-8"), sufiks).encode("utf-8")
+ except UnicodeDecodeError:
+ return surowe
+ return surowe
+
+
+@lru_cache(maxsize=None)
+def _asset_version(name: str) -> str:
+ tresc = _asset_body(name)
+ # Hash liczony z treści WYSYŁANEJ, nie z pliku na dysku: inaczej zmiana
+ # samego komentarza podbijałaby adres, choć odpowiedź jest ta sama.
+ return hashlib.md5(tresc).hexdigest()[:8] if tresc is not None else "0"
+
+
+def static_url(name: str) -> str:
+ return f"/static/{name}?v={_asset_version(name)}"
+
+
+@app.get("/static/{nazwa:path}")
+def static_file(nazwa: str):
+ """Zasoby idą przez bramkę uprawnień jak każda inna trasa.
+
+ Dopóki `/static/` było w PUBLIC_PREFIXES, katalog omijał całe sprawdzanie:
+ ukrycie przycisku w szablonie nic nie dawało, bo obok leżał skrypt tej samej
+ funkcji, pod nazwą, którą wystarczyło zgadnąć. Uprawnienie zasobu sprawdza
+ middleware (features.static_required); tutaj zostaje samo wydanie pliku.
+ """
+ tresc = _asset_body(nazwa)
+ if tresc is None:
+ raise HTTPException(status_code=404)
+ return Response(
+ tresc,
+ media_type=_TYPY_ZASOBOW.get(Path(nazwa).suffix.lower(), "application/octet-stream"),
+ # Adres niesie hash treści, więc wersja pod danym adresem jest niezmienna.
+ headers={"Cache-Control": "public, max-age=31536000, immutable"},
+ )
+
+
+templates.env.globals["static"] = static_url
+templates.env.globals["nav_admin"] = lambda: features.NAWIGACJA_ADMINA
+# 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
+templates.env.globals["STATUS_LABELS"] = files_state.LABELS
+
+
+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))
+# Kto jest zalogowany i czy logowanie w ogóle działa — pod przycisk wyjścia.
+templates.env.globals["current_user"] = lambda request: getattr(request.state, "user", "") or ""
+templates.env.globals["auth_on"] = security.auth_enabled
+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
+
+
+# Akcja formularza → uprawnienie, którego wymaga. Akcji spoza tej mapy nie
+# ograniczamy; akcja Z mapy, na którą konto nie ma prawa, jest traktowana jak
+# NIEZNANA — czyli wraca do zachowania domyślnego ekranu.
+# Akcje własne tego ekranu plus te, które wnoszą moduły opcjonalne. Aplikacja
+# nie wypisuje ich z nazwy — gdyby wypisywała, nazwa funkcji byłaby w kodzie
+# także tam, gdzie modułu nie ma.
+_AKCJE_POD_UPRAWNIENIEM = {"export": "export", **rozszerzenia.akcje()}
+
+
+def _dozwolona_akcja(request: Request, action: str, domyslna: str) -> str:
+ """Akcja, której konto nie ma prawa użyć, zachowuje się jak literówka.
+
+ NIE zwracamy błędu ani 404: komunikat „brak uprawnień do generowania" sam
+ w sobie mówiłby, że taka funkcja istnieje (PRE-27). Ukrycie przycisku chroni
+ tylko przed przypadkiem — kto zna nazwę pola, wyśle ją wprost, i to TU musi
+ się zatrzymać."""
+ wymagane = _AKCJE_POD_UPRAWNIENIEM.get(action)
+ if wymagane and wymagane not in _perms(request):
+ return domyslna
+ return action
+
+
+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
+
+
+# Jedno zdanie dla wszystkich awarii — i CELOWO żadnego szczegółu.
+# Poprzednia treść wypisywała na ekran nazwę endpointu (/chart/positions), nazwę
+# podsystemu („silnik efemeryd"), nazwę gałęzi rozwojowej i — przez str(wyjątku
+# httpx) — wewnętrzny adres z portem sąsiedniej usługi. Dla konta, które ma
+# widzieć wyłącznie Pliki, to był komplet: co program liczy, że stoi za nim
+# łańcuch usług, i gdzie zapukać.
+_AWARIA = "Nie udało się teraz wykonać tej operacji. Spróbuj ponownie za chwilę."
+
+
+def _logic_error(e: Exception) -> str:
+ """Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran.
+
+ Odsiew siedzi TUTAJ, a nie w siedemnastu miejscach wywołania: gdyby zależał
+ od tego, czy ktoś pamiętał przekazać uprawnienia, prędzej czy później jedno
+ wywołanie zostałoby pominięte — i nie byłoby tego jak zauważyć.
+ """
+ log.warning("warstwa logiczna: %s: %s", type(e).__name__, e)
+ return _AWARIA
+
+
+
+
+features.zarejestruj(trasy={("GET", "/health"): None})
+
+
+@app.get("/health")
+def health() -> dict:
+ # Sama „ok" — nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa.
+ # Kubernetes i compose czytają wyłącznie kod odpowiedzi.
+ return {"status": "ok"}
+
+
diff --git a/services/presentation/app/rozszerzenia.py b/services/presentation/app/rozszerzenia.py
index 17f694b..642ab04 100644
--- a/services/presentation/app/rozszerzenia.py
+++ b/services/presentation/app/rozszerzenia.py
@@ -1,23 +1,44 @@
-"""Most do modułów opcjonalnych — CELOWO nic o nich nie wie.
+"""Most do modułów opcjonalnych — CELOWO nie zna ani jednej ich nazwy.
-Ten plik jest w każdym obrazie, także tam, gdzie żadnego modułu nie ma. Dlatego
-nie wolno w nim wymienić z nazwy ani jednej funkcji, którą moduł wnosi: nazwa
-w moście byłaby dokładnie tym śladem, którego wydzielanie ma się pozbyć.
+Ten plik jest w KAŻDYM obrazie, także tam, gdzie nie ma żadnego modułu. Gdyby
+wymieniał moduły z nazwy, nazwa nieobecnej funkcji i tak jechałaby do obrazu,
+w którym tej funkcji nie ma — czyli dokładnie ten ślad, którego całe wydzielanie
+ma się pozbyć. Dlatego moduły są ODKRYWANE: most przechodzi po podkatalogach
+`app/moduly/` i pyta każdy, co wnosi.
-Most odpowiada wyłącznie na pytanie „czy coś jest podpięte i co wnosi". Gdy nie
-ma nic, wszystkie odpowiedzi są puste, a aplikacja zachowuje się tak, jakby
-takiego pojęcia nigdy nie było — nie jak aplikacja z wyłączoną funkcją.
+Gdy nie ma żadnego, wszystkie odpowiedzi są puste, a aplikacja zachowuje się
+tak, jakby takiego pojęcia nigdy nie było — nie jak aplikacja z wyłączoną
+funkcją.
"""
from __future__ import annotations
+import importlib
+import pkgutil
from typing import Any
-try: # pragma: no cover - zależy od obrazu
- from app import dodatki as _modul
-except ImportError: # obraz zbudowany bez tego katalogu
- _modul = None # type: ignore[assignment]
-DOSTEPNY = _modul is not None
+def _znalezione() -> list[Any]:
+ """Moduły obecne w tym obrazie, w kolejności alfabetycznej katalogów.
+
+ Kolejność jest stała, żeby dwa obrazy z tym samym zestawem zachowywały się
+ identycznie — inaczej wynik zależałby od kolejności odczytu katalogu."""
+ try:
+ paczka = importlib.import_module("app.moduly")
+ except ImportError:
+ return []
+ znalezione = []
+ for info in sorted(pkgutil.iter_modules(paczka.__path__), key=lambda i: i.name):
+ znalezione.append(importlib.import_module(f"app.moduly.{info.name}"))
+ return znalezione
+
+
+_MODULY = _znalezione()
+DOSTEPNE = bool(_MODULY)
+
+
+def _zbierz(nazwa: str, domyslne):
+ """Wartość atrybutu z każdego modułu, który go ma."""
+ return [getattr(m, nazwa) for m in _MODULY if hasattr(m, nazwa)] or domyslne
def funkcje() -> tuple[tuple[str, str, str, str], ...]:
@@ -25,62 +46,91 @@ def funkcje() -> tuple[tuple[str, str, str, str], ...]:
Surowe, bo katalog buduje `features`, a gdyby moduł importował stamtąd klasę,
powstałby cykl: features → most → moduł → features."""
- return _modul.FUNKCJA if _modul else ()
+ out: list[tuple[str, str, str, str]] = []
+ for wartosc in _zbierz("FUNKCJA", []):
+ out.extend(wartosc)
+ return tuple(out)
def trasy() -> dict[tuple[str, str], str]:
- return _modul.TRASY if _modul else {}
+ out: dict[tuple[str, str], str] = {}
+ for wartosc in _zbierz("TRASY", []):
+ out.update(wartosc)
+ return out
def zasoby() -> dict[str, Any]:
- return _modul.ZASOBY if _modul else {}
+ out: dict[str, Any] = {}
+ for wartosc in _zbierz("ZASOBY", []):
+ out.update(wartosc)
+ return out
def akcje() -> dict[str, str]:
- """Akcje formularza wnoszone przez moduł: nazwa → wymagane uprawnienie."""
- return _modul.AKCJE if _modul else {}
+ """Akcje formularza wnoszone przez moduły: nazwa → wymagane uprawnienie."""
+ out: dict[str, str] = {}
+ for wartosc in _zbierz("AKCJE", []):
+ out.update(wartosc)
+ return out
def pola_formularza():
"""Zależność FastAPI deklarująca dodatkowe pola formularza.
- Pola muszą być zadeklarowane w SYGNATURZE, żeby FastAPI je odczytało, a
- sygnatura wspólnego handlera nie może ich wymieniać. Zależność rozwiązuje
- jedno i drugie: moduł deklaruje własne pola u siebie, a handler wie tylko,
- że dostaje słownik."""
- if _modul:
- return _modul.pola
- return lambda: {}
+ Pola muszą być w SYGNATURZE, żeby FastAPI je odczytało, a sygnatura wspólnego
+ handlera nie może ich wymieniać. Zależność przenosi deklarację do modułu.
+
+ Gdy pola deklaruje więcej niż jeden moduł, trzeba je złożyć — a składanie
+ sygnatur w locie to kod, który psuje się cicho. Póki nikt tego nie potrzebuje,
+ mówimy o tym wprost przy starcie, zamiast budować machinerię na zapas.
+ """
+ deklarujace = _zbierz("pola", [])
+ if not deklarujace:
+ return lambda: {}
+ if len(deklarujace) > 1:
+ raise RuntimeError(
+ "Więcej niż jeden moduł deklaruje pola formularza. Trzeba je złożyć "
+ "w jedną zależność — patrz komentarz w app/rozszerzenia.py.")
+ return deklarujace[0]
def kontekst_szablonu(request) -> dict:
- """Co moduł dokłada do kontekstu szablonu. Bez modułu — nic."""
- return _modul.katalog_dla(request) if _modul else {}
+ """Co moduły dokładają do kontekstu szablonu. Bez modułów — nic."""
+ out: dict = {}
+ for funkcja in _zbierz("katalog_dla", []):
+ out.update(funkcja(request))
+ return out
-def obsluz(akcja: str, **dane) -> dict | None:
- """Wykonanie akcji modułu. None = to nie jest jego akcja."""
- return _modul.obsluz(akcja, **dane) if _modul else None
+def obsluz(akcja: str, **dane):
+ """Wykonanie akcji modułu. None = żaden się do niej nie przyznaje.
+
+ Moduł może oddać słownik (trafi do kontekstu szablonu) albo gotową
+ odpowiedź HTTP — na przykład plik do pobrania, który nie jest stroną."""
+ for funkcja in _zbierz("obsluz", []):
+ wynik = funkcja(akcja, **dane)
+ if wynik is not None:
+ return wynik
+ return None
def zamontuj(app, **zaleznosci) -> None:
- """Podpięcie tras modułu i przekazanie mu tego, czego potrzebuje."""
- if _modul:
- _modul.zamontuj(app, **zaleznosci)
+ """Podpięcie tras modułów i przekazanie im tego, czego potrzebują."""
+ for funkcja in _zbierz("zamontuj", []):
+ funkcja(app, **zaleznosci)
+
+
+def _katalogi(nazwa: str) -> list[str]:
+ import pathlib
+
+ return [str(pathlib.Path(m.__file__).parent / nazwa) for m in _MODULY
+ if (pathlib.Path(m.__file__).parent / nazwa).is_dir()]
def katalog_szablonow() -> list[str]:
- """Katalogi szablonów modułu — nakładane PRZED wspólnymi."""
- if not _modul:
- return []
- import pathlib
-
- return [str(pathlib.Path(_modul.__file__).parent / "templates")]
+ """Katalogi szablonów modułów — nakładane PRZED wspólnymi."""
+ return _katalogi("templates")
def katalog_zasobow() -> list[str]:
- if not _modul:
- return []
- import pathlib
-
- return [str(pathlib.Path(_modul.__file__).parent / "static")]
+ return _katalogi("static")
diff --git a/services/presentation/app/static/formsync.js b/services/presentation/app/static/formsync.js
index 68e28df..f72d998 100644
--- a/services/presentation/app/static/formsync.js
+++ b/services/presentation/app/static/formsync.js
@@ -23,7 +23,7 @@
// type: brak = zwykła wartość (.value); 'check' = pojedynczy checkbox (.checked);
// 'multi' = kilka checkboxów o tej samej nazwie (lista zaznaczonych wartości).
// Opcje (stacje, tabele, porównanie domów) są tu, żeby wybór z „Horoskopu"
- // wędrował na „Skompiluj" — inaczej podsumowanie liczyłoby się bez nich.
+ // wędrował na kolejny ekran — inaczej podsumowanie liczyłoby się bez nich.
var FIELDS = [
{ key: 'person', sel: 'input[name=person]' },
{ key: 'date', sel: 'input[name=date]' },
diff --git a/services/presentation/app/static/wheelzoom.js b/services/presentation/app/static/wheelzoom.js
index 22a3e26..8d6191a 100644
--- a/services/presentation/app/static/wheelzoom.js
+++ b/services/presentation/app/static/wheelzoom.js
@@ -6,7 +6,7 @@
// rysunku ani żadnej biblioteki.
//
// Podpięte globalnie i osłonięte sprawdzeniem, czy koło w ogóle jest na stronie
-// — dzięki temu zadziała też na przyszłej zakładce „Skompiluj", bez zmian tutaj.
+// — dzięki temu zadziała wszędzie, gdzie koło się pojawi, bez zmian tutaj.
(function () {
'use strict';
diff --git a/services/presentation/app/templates/base.html b/services/presentation/app/templates/base.html
index 4eb5bcf..b4bcd5a 100644
--- a/services/presentation/app/templates/base.html
+++ b/services/presentation/app/templates/base.html
@@ -28,8 +28,13 @@
{% for tab in nav_screens(request) %}
{{ tab.label }}
{% endfor %}
+ {# Pozycje administracyjne z REJESTRU, nie z listy w szablonie: wpisany tu
+ na sztywno odsyłacz zostawał w produkcie, który tego ekranu nie ma —
+ razem z nazwą ekranu i martwym adresem. #}
{% if can(request, 'admin') %}
- Konta
+ {% for klucz, adres, etykieta in nav_admin() %}
+ {{ etykieta }}
+ {% endfor %}
{% endif %}
{# Wyjście na końcu paska i odsunięte: to nie jest zakładka, tylko
czynność — pomylenie go z zakładką kosztuje przypadkowe wylogowania.
diff --git a/services/presentation/app/templates/timeline.html b/services/presentation/app/templates/timeline.html
index bc1c8c7..aa02b43 100644
--- a/services/presentation/app/templates/timeline.html
+++ b/services/presentation/app/templates/timeline.html
@@ -91,7 +91,7 @@
{% endfor %}
{% endif %}
-{# Zapamiętane predykcje okresowe (PRE-22) — materiał dla zakładki „Skompiluj" #}
+{# Zapamiętane predykcje okresowe (PRE-22) — materiał do raportu zbiorczego #}
diff --git a/services/presentation/tests/skladanie.py b/services/presentation/tests/skladanie.py
index 024d8a8..ce3d31d 100644
--- a/services/presentation/tests/skladanie.py
+++ b/services/presentation/tests/skladanie.py
@@ -19,7 +19,27 @@ def zloz(nazwa: str) -> str:
tresc = (APP / "templates" / nazwa).read_text(encoding="utf-8")
def wstaw(m: re.Match) -> str:
- plik = APP / "dodatki" / "templates" / m.group(1)
- return plik.read_text(encoding="utf-8") if plik.exists() else ""
+ for katalog in sorted((APP / "moduly").glob("*/templates")):
+ plik = katalog / m.group(1)
+ if plik.exists():
+ return plik.read_text(encoding="utf-8")
+ return ""
return GNIAZDO.sub(wstaw, tresc)
+
+
+def zloz_kod() -> str:
+ """Cały kod tras aplikacji jako jeden tekst — podstawa plus wszystkie ekrany.
+
+ Trasy mieszkają teraz w `app/ekrany/*.py`, a `main.py` jest samym złożeniem.
+ Testy strukturalne pilnują reguł, które mają obowiązywać W CAŁEJ aplikacji
+ („każde wejście do baz zostawia ślad w dzienniku", „orb jedzie do logiki"),
+ więc muszą widzieć całość — inaczej sprawdzałyby jeden plik i przepuściły
+ regułę złamaną w drugim.
+ """
+ czesci = [(APP / "podstawa.py").read_text(encoding="utf-8")]
+ czesci += [f.read_text(encoding="utf-8")
+ for f in sorted((APP / "ekrany").glob("*.py"))]
+ czesci += [f.read_text(encoding="utf-8")
+ for f in sorted((APP / "moduly").rglob("*.py"))]
+ return "\n".join(czesci)
diff --git a/services/presentation/tests/test_accounts_audit.py b/services/presentation/tests/test_accounts_audit.py
index 1843b9e..6ee6d5b 100644
--- a/services/presentation/tests/test_accounts_audit.py
+++ b/services/presentation/tests/test_accounts_audit.py
@@ -201,5 +201,9 @@ def test_handlers_report_record_counts():
wyniósł 5000 rekordów" — czyli nie wykryłby powolnego wypompowywania bazy."""
import pathlib
- main = (pathlib.Path(security.__file__).parent / "main.py").read_text(encoding="utf-8")
+ # Reguła obowiązuje w CAŁEJ aplikacji, więc patrzymy na wszystkie trasy,
+ # nie na jeden plik — po podziale main.py jest samym złożeniem.
+ from tests.skladanie import zloz_kod
+
+ main = zloz_kod()
assert main.count("security.audit_records(") >= 3 # sygnifikatory, raport, eksport
diff --git a/services/presentation/tests/test_ai_tylko_w_module.py b/services/presentation/tests/test_ai_tylko_w_module.py
index 05664b2..509867c 100644
--- a/services/presentation/tests/test_ai_tylko_w_module.py
+++ b/services/presentation/tests/test_ai_tylko_w_module.py
@@ -17,7 +17,7 @@ import re
import pytest
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
-MODUL = APP / "dodatki"
+MODUL = APP / "moduly" / "dodatki"
SLOWA = [
# nazwa funkcji i jej słownictwo
@@ -79,14 +79,11 @@ def test_removing_the_module_leaves_a_working_application():
Most (`app.rozszerzenia`) musi odpowiadać sensownie, gdy modułu nie ma —
inaczej astroklient nie wystartuje, a dowiemy się o tym dopiero przy
budowaniu jego obrazu."""
- import importlib
- import sys
-
from app import rozszerzenia
- zapamietane = rozszerzenia._modul
+ zapamietane = rozszerzenia._MODULY
try:
- rozszerzenia._modul = None
+ rozszerzenia._MODULY = []
assert rozszerzenia.funkcje() == ()
assert rozszerzenia.trasy() == {}
assert rozszerzenia.zasoby() == {}
@@ -98,8 +95,7 @@ def test_removing_the_module_leaves_a_working_application():
assert rozszerzenia.pola_formularza()() == {}
rozszerzenia.zamontuj(None) # ma nie wybuchnąć
finally:
- rozszerzenia._modul = zapamietane
- importlib.reload(sys.modules["app.rozszerzenia"]) if False else None
+ rozszerzenia._MODULY = zapamietane
def test_the_bridge_itself_names_nothing():
diff --git a/services/presentation/tests/test_aspect_settings.py b/services/presentation/tests/test_aspect_settings.py
index a163fc2..0168ae6 100644
--- a/services/presentation/tests/test_aspect_settings.py
+++ b/services/presentation/tests/test_aspect_settings.py
@@ -7,11 +7,14 @@ regresja podsumowania).
"""
import pathlib
+from tests.skladanie import zloz_kod
+
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
CHART = (APP / "templates" / "chart.html").read_text(encoding="utf-8")
COMPILE = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
OPTS = (APP / "templates" / "_form_options.html").read_text(encoding="utf-8") # wspólne opcje obu formularzy
-MAIN = (APP / "main.py").read_text(encoding="utf-8")
+# Trasy mieszkają w app/ekrany/* — patrz tests/skladanie.py.
+MAIN = zloz_kod()
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
SYNC = (APP / "static" / "formsync.js").read_text(encoding="utf-8")
COMPILE_JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
diff --git a/services/presentation/tests/test_compile.py b/services/presentation/tests/test_compile.py
index 77c220f..df906d3 100644
--- a/services/presentation/tests/test_compile.py
+++ b/services/presentation/tests/test_compile.py
@@ -11,7 +11,7 @@ import pathlib
from starlette.testclient import TestClient
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
-from tests.skladanie import zloz
+from tests.skladanie import zloz, zloz_kod
# Szablon z rozwiniętymi wstawkami modułów — patrz tests/skladanie.py.
TPL = zloz("compile.html")
@@ -19,9 +19,10 @@ BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
# Skrypty generowania mieszkają w module `app/ai/` — katalogu, którego
# w obrazie bez tej funkcji po prostu nie ma.
-NATAL_JS = (APP / "dodatki" / "static" / "natal.js").read_text(encoding="utf-8")
+NATAL_JS = (APP / "moduly" / "dodatki" / "static" / "natal.js").read_text(encoding="utf-8")
INTERPRET = zloz("interpret.html")
-MAIN = (APP / "main.py").read_text(encoding="utf-8")
+# Trasy mieszkają w app/ekrany/* — patrz tests/skladanie.py.
+MAIN = zloz_kod()
def _client():
diff --git a/services/presentation/tests/test_ekrany_bez_sladu.py b/services/presentation/tests/test_ekrany_bez_sladu.py
new file mode 100644
index 0000000..6d8737b
--- /dev/null
+++ b/services/presentation/tests/test_ekrany_bez_sladu.py
@@ -0,0 +1,113 @@
+"""Produkt składa się z modułów — i węższe złożenie naprawdę działa.
+
+Ekrany mieszkają w `app/ekrany/*.py`, moduły opcjonalne w `app/moduly/*`, a
+`main.py` jest samym złożeniem. Sens tego podziału jest jeden: z tego samego
+kodu ma dać się zbudować WĘŻSZY produkt, w którym pozostałych ekranów nie ma —
+nie są wyłączone, tylko ich nie ma.
+
+Test buduje takie złożenie naprawdę: kopiuje drzewo, usuwa moduły, uruchamia
+aplikację w osobnym procesie i sprawdza, co się stało. Robi to w podprocesie,
+bo importy Pythona są zapamiętywane w procesie — sprawdzanie „aplikacji bez
+modułu" w tym samym procesie, w którym moduł już się zaimportował, dawałoby
+wynik fałszywie pozytywny.
+
+Czego ten test NIE sprawdza: czy w drzewie nie ma wzmianek o nieobecnych
+ekranach. To ma sens dopiero wobec KONKRETNEGO produktu (wiadomo wtedy, których
+ekranów nie ma) i tam jest sprawdzane — patrz zapory słownikowe.
+"""
+import json
+import pathlib
+import shutil
+import subprocess
+import sys
+
+import pytest
+
+APP = pathlib.Path(__file__).resolve().parents[1] / "app"
+
+# Złożenie węższe: same funkcje astrologiczne, bez raportu zbiorczego,
+# bez administracji, bez generowania i bez wynoszenia treści na zewnątrz.
+EKRANY_WASKIE = ["horoskop", "interpretacje", "kalendarz", "logowanie",
+ "lokalizacja", "sygnifikatory", "synastria"]
+USUWANE = ["ekrany/skompiluj.py", "ekrany/ustawienia.py", "ekrany/konta.py",
+ "ekrany/pliki.py", "moduly/dodatki", "moduly/eksport"]
+
+SONDA = '''
+import json, os, tempfile
+os.environ.update(ACCOUNTS_FILE=tempfile.mkdtemp()+"/a.json", APP_USER="szef",
+ APP_PASSWORD="t", RATE_LIMIT_PER_MIN="0",
+ SESSION_SECRET="t"*64, COOKIES_INSECURE="1")
+from starlette.testclient import TestClient
+from app.main import app
+from app import security, session, features
+c = TestClient(app, follow_redirects=False)
+H = {"Cookie": f"{session.COOKIE}={security.issue_session('szef')}"}
+wynik = {
+ "ekrany": [f.label for f in features.SCREENS],
+ "rozszerzenia": [f.label for f in features.EXTRAS],
+ "dziala": {a: c.get(a, headers=H).status_code
+ for a in ("/", "/interpret", "/timeline", "/synastry", "/significators")},
+ "nie_ma": {a: c.get(a, headers=H).status_code
+ for a in ("/compile", "/settings", "/accounts", "/files")},
+ # Liczymy odsyłacze WEWNĄTRZ paska nawigacji. Zliczanie wszystkich na stronie
+ # łapało też przypis OSM z wyszukiwarki miejsca — czyli test mierzyłby
+ # co innego, niż nazwa mu każe.
+ "nawigacja": (lambda h: h.split("")[0].count('