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('