"""Warstwa PREZENTACJI — usługa HTTP serwująca stronę WWW. W dół: przekazuje dane z formularza do warstwy logicznej i odbiera opracowane wyniki. Nie zawiera logiki biznesowej ani dostępu do danych — tylko UI. Strona główna „/" = wprowadzenie danych horoskopu i podgląd policzonych pozycji (do ręcznego testowania aplikacji). Wyszukiwarka sygnifikatorów przeniesiona pod „/significators". """ # build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest from __future__ import annotations import hashlib import json from datetime import datetime, timedelta, timezone from functools import lru_cache from pathlib import Path import httpx from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates import base64 from app import accounts as accounts_store 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) app.mount("/static", StaticFiles(directory="app/static"), name="static") templates = Jinja2Templates(directory="app/templates") logic = LogicClient() security.install(app) # logowanie + limit żądań (LOG-32) # ── cache-busting statyki (PRE-27) ───────────────────────────────────────── # Po deployu przeglądarka trzymała stare styles.css / *.js (ten sam URL → cache). # Doklejamy do URL-a krótki HASH TREŚCI pliku: zmieni się plik → zmieni się URL → # przeglądarka pobierze nowy; bez zmian URL zostaje ten sam (cache działa dalej). # Hash liczony raz na proces (lru_cache) — nowy pod po deployu = świeży hash. _STATIC_DIR = Path("app/static") @lru_cache(maxsize=None) def _asset_version(name: str) -> str: try: return hashlib.md5((_STATIC_DIR / name).read_bytes()).hexdigest()[:8] except OSError: return "0" # brak pliku nie może wywrócić strony def static_url(name: str) -> str: return f"/static/{name}?v={_asset_version(name)}" templates.env.globals["static"] = static_url # Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów. templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS templates.env.globals["house_label"] = house_label templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS 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_POD_UPRAWNIENIEM = {"prompt": "ai", "horoscope": "ai", "export": "export"} 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 _llm_catalog_for(request: Request) -> dict: """Katalog modeli — pusty, gdy konto nie generuje tekstu przez model. Katalog trafia do strony jako blok JSON, więc bez tego ograniczenia nazwy dostawców, modeli i rozmiary okien kontekstu byłyby w źródle KAŻDEJ strony Interpretacji i Kalendarza, także dla kont, które o modelach nie mają prawa wiedzieć.""" return _llm_catalog() if "ai" in _perms(request) else {} def _llm_catalog() -> dict: """Podpowiedzi modeli dla pola wyboru. Awaria logiki nie może wywrócić strony — pole modelu jest tekstowe, więc bez katalogu nadal da się wpisać model ręcznie.""" try: return logic.llm_models() except httpx.HTTPError: return {"providers": {}, "defaults": {}} def _report_records(report: dict | None) -> int: """Ile rekordów baz niesie raport — do dziennika audytowego (PRE-17). Liczymy próbki we wszystkich fasetach: to one są treścią z baz. Sama liczba, nigdy treść — dziennik ma pokazywać SKALĘ dostępu, nie powielać danych.""" total = 0 for o in (report or {}).get("objects") or []: for f in o.get("facets") or []: total += len(f.get("samples") or []) return total def _logic_error(e: Exception) -> str: if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404: return ( "Warstwa logiczna działa, ale nie ma endpointu /chart/positions. " "Uruchom warstwę logiczną z silnikiem efemeryd (gałąź feat/logic-engine)." ) return f"Warstwa logiczna niedostępna: {e}" # ---------------- Horoskop: pozycje (strona główna) ---------------- @app.get("/", response_class=HTMLResponse) def chart_form(request: Request): # Korzeń jest dostępny dla każdego zalogowanego, ale ekran „Horoskop" już nie. # Bez tego konto bez uprawnienia do horoskopu dostawałoby 404 pod adresem # głównym — czyli wyglądałoby to na zepsuty program, a nie na węższy zestaw. if "chart" not in _perms(request): target = _landing(request) if target: return RedirectResponse(target, status_code=303) return HTMLResponse("

Brak przypisanych funkcji

" "

Skontaktuj się z osobą, która zakładała konto.

", status_code=200) return templates.TemplateResponse( request, "chart.html", {"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL, "llm_catalog": _llm_catalog_for(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 od AI (interpretacja natalna i predykcje okresowe) dokłada przeglądarka z magazynu (PRE-22/23). Horoskop liczymy ponownie (czysta funkcja wejścia — tanio powtórzyć, bez trzymania dużego wyniku w przeglądarce), ale z TYMI SAMYMI opcjami, które wybrano przy horoskopie (stacje, tabele, porównanie domów) — inaczej podsumowanie miałoby braki względem tego, co policzono. Opcje wędrują między zakładkami przez formsync. """ form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon, "house_system": house_system, "house_systems": house_systems, "aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor, "stations": stations, "zodiac": zodiac, "tables": tables, "wheel_orientation": wheel_orientation} form = _limit_options(request, form) ctx: dict = {"form": form, "result": None, "error": None, "moment": None} try: iso_utc, label = _build_utc(date, time, tz_offset) ctx["moment"] = label ctx["result"] = logic.positions( when_utc_iso=iso_utc, lat=lat, lon=lon, house_system=form["house_system"], house_systems=form["house_systems"], aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus, aspect_minor=form["aspect_minor"], stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"], ) from app import chartwheel ctx["wheel_svg"] = chartwheel.render(ctx["result"], orientation=form["wheel_orientation"]) if "extra_charts" in _perms(request): ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18 ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07 ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07 except (httpx.HTTPError,) as e: ctx["error"] = _logic_error(e) except ValueError as e: ctx["error"] = f"Niepoprawne dane wejściowe: {e}" return templates.TemplateResponse(request, "compile.html", ctx) @app.post("/compile/pdf") def compile_pdf(payload: dict): """Składa raport PDF (PRE-24) — woła usługę render po szyfrowanym łączu. Wejście z przeglądarki, bo części od AI (interpretacja natalna i predykcje) mieszkają w magazynie lokalnym. Kosmogram i tabele liczymy TU, żeby PDF zawierał dokładnie to, co widać na stronie. Kosmogram idzie w motywie DRUKU: samodzielny konwerter SVG→PDF nie zna naszego arkusza, więc zmienne CSS i font glifów muszą być w samym rysunku. """ from fastapi.responses import Response from app.clients.render_client import RenderClient data = payload.get("data") or {} try: iso_utc, label = _build_utc( str(data.get("date") or ""), str(data.get("time") or ""), float(data.get("tz_offset") or 0.0), ) except (ValueError, TypeError) as e: return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422) figures: list[dict] = [] warnings: list[str] = [] try: chart = logic.positions( when_utc_iso=iso_utc, lat=float(data.get("lat") or 0.0), lon=float(data.get("lon") or 0.0), house_system=str(data.get("house_system") or "whole_sign"), house_systems=[s for s in (data.get("house_systems") or []) if s], aspect_orb=float(data.get("aspect_orb") or 8.0), aspect_luminary_bonus=float(data.get("aspect_luminary_bonus") or 2.0), aspect_minor=bool(data.get("aspect_minor")), stations=bool(data.get("stations")), tables=bool(data.get("tables")), zodiac=str(data.get("zodiac") or "tropical"), ) # Fallback systemu domów musi dojechać do PDF-a — z samego kosmogramu # nie da się poznać, że podział jest z innego systemu, niż zamówiono. warnings = [w for w in (chart.get("house_warnings") or []) if w] from app import chartwheel # Zasada: co pokazujemy na stronie, ma trafić do PDF-a. Wszystkie rysunki # w motywie DRUKU — samodzielny konwerter SVG→PDF nie zna arkusza, więc # zmienne CSS i font glifów muszą być wprost w rysunku. for svg, caption in ( (chartwheel.render(chart, theme="print", orientation=str(data.get("wheel_orientation") or chartwheel_mod.ASC_LEFT)), "Kosmogram"), (chartwheel.render_aspectarian(chart, theme="print"), "Aspektarian — siatka aspektów"), (chartwheel.render_declination(chart, theme="print"), "Wykres deklinacji"), (chartwheel.render_antiscia(chart, theme="print"), "Oś antyscji"), ): if svg: figures.append({"svg": svg, "caption": caption}) except httpx.HTTPError as e: # Brak rysunków nie może zablokować raportu — tekst jest ważniejszy. data = {**data, "wheel_error": _logic_error(e)} report = { "person": payload.get("person") or "", "data": {**data, "moment_utc": label}, "figures": figures, "warnings": warnings, "natal": payload.get("natal") or {}, "predictions": payload.get("predictions") or [], } try: pdf = RenderClient().pdf(report) except httpx.HTTPError as e: return JSONResponse( {"detail": f"Usługa render niedostępna albo nie złożyła PDF-a: {e}"}, status_code=502, ) return Response(content=pdf, media_type="application/pdf", headers={"Content-Disposition": 'attachment; filename="raport.pdf"'}) # ---------------- Ustawienia: bazy na udziale (DAN-15/PRE-09) ---------------- @app.get("/settings", response_class=HTMLResponse) def settings_view(request: Request): """Podgląd baz dostępnych na udziale + które biorą udział w interpretacji. Same metadane (nazwa, rozmiar, data) — bez treści baz. Przełączniki są DEKLARATYWNE (`DISABLED_BASES`), bo warstwa danych nie ma trwałego zapisu: udział jest read-only, a cache to emptyDir. Zapis do pliku ginąłby po restarcie, po cichu włączając z powrotem wyłączoną bazę.""" ctx: dict = {"result": None, "error": None} try: ctx["result"] = logic.bases() except httpx.HTTPError as e: ctx["error"] = _logic_error(e) return templates.TemplateResponse(request, "settings.html", ctx) # ---------------- Synastria (technika relacyjna) ---------------- @app.get("/synastry", response_class=HTMLResponse) def synastry_form(request: Request): # domyślne liczby, żeby pola number nie były puste (puste → błąd przy wysyłce) form = {"a_tz_offset": 0, "a_lat": 0, "a_lon": 0, "b_tz_offset": 0, "b_lat": 0, "b_lon": 0, "aspect_orb": 8, "aspect_luminary_bonus": 2} return templates.TemplateResponse(request, "synastry.html", {"result": None, "form": form}) @app.post("/synastry", response_class=HTMLResponse) def synastry_run( request: Request, a_person: str = Form(""), a_date: str = Form(...), a_time: str = Form(...), a_tz_offset: float = Form(0.0), a_lat: float = Form(0.0), a_lon: float = Form(0.0), b_person: str = Form(""), b_date: str = Form(...), b_time: str = Form(...), b_tz_offset: float = Form(0.0), b_lat: float = Form(0.0), b_lon: float = Form(0.0), zodiac: str = Form("tropical"), aspect_orb: float = Form(8.0), aspect_luminary_bonus: float = Form(2.0), aspect_minor: bool = Form(False), ): """Synastria (PRE-04): dwie osoby → aspekty między ich horoskopami.""" form = {"a_person": a_person, "a_date": a_date, "a_time": a_time, "a_tz_offset": a_tz_offset, "a_lat": a_lat, "a_lon": a_lon, "b_person": b_person, "b_date": b_date, "b_time": b_time, "b_tz_offset": b_tz_offset, "b_lat": b_lat, "b_lon": b_lon, "zodiac": zodiac, "aspect_orb": aspect_orb, "aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor} ctx: dict = {"form": form, "result": None, "error": None} try: iso_a, _ = _build_utc(a_date, a_time, a_tz_offset) iso_b, _ = _build_utc(b_date, b_time, b_tz_offset) ctx["result"] = logic.synastry( {"when_utc": iso_a, "lat": a_lat, "lon": a_lon}, {"when_utc": iso_b, "lat": b_lat, "lon": b_lon}, zodiac=zodiac, aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus, aspect_minor=aspect_minor, ) except httpx.HTTPError as e: ctx["error"] = _logic_error(e) except ValueError as e: ctx["error"] = f"Niepoprawne dane wejściowe: {e}" return templates.TemplateResponse(request, "synastry.html", ctx) # ---------------- Sygnifikatory (wyszukiwarka w bazach) ---------------- @app.get("/significators", response_class=HTMLResponse) def significators_form(request: Request): return templates.TemplateResponse(request, "significators.html", {"result": None, "form": {}}) @app.post("/significators", response_class=HTMLResponse) def significators_search( request: Request, query: str = Form(...), field: str = Form("name"), exact: bool = Form(False), limit: int = Form(25), ): form = {"query": query, "field": field, "exact": exact, "limit": limit} ctx: dict = {"form": form, "result": None, "error": None} try: ctx["result"] = logic.query(query=query, field=field, exact=exact, limit=limit) # ile rekordów baz oddaliśmy — do dziennika audytowego (PRE-17), bez treści security.audit_records(request, len((ctx["result"] or {}).get("rows") or [])) except httpx.HTTPError as e: ctx["error"] = _logic_error(e) return templates.TemplateResponse(request, "significators.html", ctx) # ---------------- Interpretacje (wynik obliczeń szukany w bazie) ---------------- @app.get("/interpret", response_class=HTMLResponse) def interpret_form(request: Request): return templates.TemplateResponse( request, "interpret.html", {"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL, "llm_catalog": _llm_catalog_for(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"), prompt_budget: str = Form("medium"), llm_provider: str = Form("local"), llm_model: str = Form(""), ): form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon, "group": group, "prompt_budget": prompt_budget, "llm_provider": llm_provider, "llm_model": llm_model} action = _dozwolona_akcja(request, action, "report") ctx: dict = {"form": form, "result": None, "error": None, "moment": None, "llm_catalog": _llm_catalog_for(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"'}, ) if action == "prompt": ctx["prompt_result"] = logic.prompt( profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget, provider=llm_provider, model=llm_model, ) elif action == "horoscope": ctx["prompt_result"] = logic.horoscope( profile="natal", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget, provider=llm_provider, model=llm_model, ) else: ctx["result"] = logic.report(when_utc_iso=iso_utc, lat=lat, lon=lon, group=group) security.audit_records(request, _report_records(ctx["result"])) except httpx.HTTPError as e: ctx["error"] = _logic_error(e) except ValueError as e: ctx["error"] = f"Niepoprawne dane wejściowe: {e}" return templates.TemplateResponse(request, "interpret.html", ctx) # ---------------- Kalendarz (oś czasu z technik + interpretacje) ---------------- @app.get("/timeline", response_class=HTMLResponse) def timeline_form(request: Request): return templates.TemplateResponse( request, "timeline.html", {"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL, "llm_catalog": _llm_catalog_for(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"), prompt_budget: str = Form("medium"), llm_provider: str = Form("local"), llm_model: str = Form(""), ): form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon, "from_date": from_date, "to_date": to_date, "prompt_budget": prompt_budget, "llm_provider": llm_provider, "llm_model": llm_model} action = _dozwolona_akcja(request, action, "timeline") ctx: dict = {"form": form, "result": None, "error": None, "moment": None, "llm_catalog": _llm_catalog_for(request)} try: iso_utc, label = _build_utc(date, time, tz_offset) ctx["moment"] = label if action == "prompt": ctx["prompt_result"] = logic.prompt( profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget, from_date=from_date, to_date=to_date, provider=llm_provider, model=llm_model, ) elif action == "horoscope": ctx["prompt_result"] = logic.horoscope( profile="period", when_utc_iso=iso_utc, lat=lat, lon=lon, budget=prompt_budget, provider=llm_provider, model=llm_model, from_date=from_date, to_date=to_date, ) else: ctx["result"] = logic.timeline( when_utc_iso=iso_utc, lat=lat, lon=lon, from_date=from_date, to_date=to_date, interpret=True, ) except httpx.HTTPError as e: ctx["error"] = _logic_error(e) except ValueError as e: ctx["error"] = f"Niepoprawne dane wejściowe: {e}" return templates.TemplateResponse(request, "timeline.html", ctx) # ---------------- Postęp pisania horoskopu (strumień do okna z logiem) ---------------- @app.post("/horoscope/stream") def horoscope_stream( profile: str = Form("natal"), date: str = Form(...), time: str = Form(...), tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0), prompt_budget: str = Form("medium"), llm_provider: str = Form("local"), llm_model: str = Form(""), from_date: str = Form(""), to_date: str = Form(""), ): """Przekazuje strumień postępu z logiki i DOKLEJA gotowy HTML wyniku. Dzięki temu okno postępu wstawia dokładnie ten sam widok, który wyrenderowałoby przeładowanie strony — jedno źródło prawdy dla wyglądu wyniku. """ from fastapi.responses import StreamingResponse try: iso_utc, _ = _build_utc(date, time, tz_offset) except ValueError as e: return JSONResponse({"detail": f"Niepoprawne dane wejściowe: {e}"}, status_code=422) payload: dict = { "profile": profile, "when_utc": iso_utc, "lat": lat, "lon": lon, "budget": prompt_budget, "provider": llm_provider, "model": llm_model, } if profile == "period" and from_date and to_date: payload["from_date"], payload["to_date"] = from_date, to_date def relay(): try: for raw in logic.horoscope_stream(payload): try: event = json.loads(raw) except ValueError: continue if event.get("type") == "result": html = templates.get_template("_prompt_result.html").render( prompt_result=event.get("result") or {} ) event["html"] = html yield json.dumps(event, ensure_ascii=False) + "\n" except httpx.HTTPError as e: yield json.dumps({"type": "error", "message": _logic_error(e)}, ensure_ascii=False) + "\n" return StreamingResponse(relay(), media_type="application/x-ndjson", headers={"Cache-Control": "no-store", "X-Accel-Buffering": "no"}) # ---------------- Geokoder (proxy OSM/Nominatim dla wyszukiwarki lokalizacji) ---------------- @app.get("/geocode") def geocode_search(q: str = Query("", description="Nazwa / adres / POI do wyszukania")): """Nazwa miejsca → kandydaci ze współrzędnymi (dla pola „Szukaj miejsca").""" try: return {"results": geocode.search(q)} except httpx.HTTPError as e: raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}") @app.get("/reverse") def geocode_reverse(lat: float, lon: float): """Punkt z mapy → nazwa miejsca (po przeciągnięciu pineski / kliknięciu).""" try: return geocode.reverse(lat, lon) except httpx.HTTPError as e: raise HTTPException(status_code=502, detail=f"Geokoder (OSM) niedostępny: {e}") @app.get("/timezone") def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00"): """Współrzędne + data → strefa IANA i DST-świadomy offset GMT (PRE-03). Liczone OFFLINE (tzfpy + zoneinfo), więc brak internetu nie przeszkadza. 404, gdy strefy nie da się ustalić — front zostawia wtedy ręczny offset.""" from app import timezone as tzmod res = tzmod.resolve(lat, lon, date, time) if not res: raise HTTPException(status_code=404, detail="Nie ustalono strefy dla tego punktu.") return res # ---------------- 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 "Baza włączona 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)) if out.get("accepted"): return _files_redirect(done=f"Wgrano „{out.get('name')}”. Można ją włączyć do użytku.") # Bez powodu i bez słowa „walidacja" — poza administratorem nikt nie ma # skąd wiedzieć, że taki mechanizm istnieje. return _files_redirect(done=f"Wgrano „{out.get('name')}”. " f"Zanim trafi do użytku, musi ją zatwierdzić administrator.") @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, "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}”.") # ---------------- 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: return {"status": "ok", "layer": "presentation"}