62d6f9d4d5
build / build (push) Failing after 2s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 3s
Testy / Testy astrodemo (push) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 2s
Testy / Kontrola składni wszystkich warstw (push) Failing after 3s
Drugi z pięciu kroków budowy trzech produktów. Czysty refaktor — zachowanie
aplikacji się nie zmienia, liczba testów rośnie tylko o nowe.
DLACZEGO WARUNEK W SZABLONIE NIE WYSTARCZA. Dotąd generowanie było chowane przez
`{% if can(request, 'ai') %}`. To sprawia, że funkcji nie WIDAĆ, ale nie że jej
NIE MA: plik szablonu dalej leży w obrazie i dalej zawiera jej nazwy, więc `grep`
po kontenerze pokazuje wszystko, czego warunek nie pokazał na ekranie. Astroklient
ma nie mieć śladu, nie mieć wyłączoną funkcję — więc granica musi być KATALOGIEM.
CO SIĘ PRZENIOSŁO do app/dodatki/: dwa szablony, pięć plików statycznych, trasa
strumienia, cztery metody klienta warstwy logicznej, wpis w katalogu funkcji,
wpisy tras i zasobów, akcje formularza i reguły CSS nazywające funkcję
(`textarea.prompt`).
JAK APLIKACJA O TO PYTA. Neutralny most `app/rozszerzenia.py` odpowiada wyłącznie
na pytanie „czy coś jest podpięte i co wnosi". Sam nie wymienia ani jednej nazwy —
pilnuje tego osobny test, bo most jest w KAŻDYM obrazie. Dlatego też katalog
nazywa się `dodatki`, a nie `ai`: nazwa w instrukcji importu byłaby dokładnie tym
śladem, którego wydzielanie ma się pozbyć.
TRZY MIEJSCA, KTÓRE OKAZAŁY SIĘ TRUDNE:
1. Pola formularza. FastAPI czyta je z SYGNATURY, a wspólny handler nie może
wymieniać `prompt_budget` ani `llm_provider`. Rozwiązane zależnością: moduł
deklaruje własne pola u siebie, handler wie tylko, że dostaje słownik.
2. Klient warstwy logicznej jest wspólny, więc metody `prompt`/`horoscope`/
`llm_models` musiały z niego wyjść. Wspólny klient daje teraz samą drogę
w dół (`wywolaj`, `pobierz`, `strumien`) — z szyfrowaniem łącza i tokenem
międzywarstwowym; co nią pojedzie, jest sprawą modułu.
3. Podtytuł ekranu Skompiluj miał wariant „z AI" i wariant bez. Zamiast warunku
jedno zdanie prawdziwe niezależnie od tego, jakie moduły są w obrazie.
DOCKERFILE. `COPY . .` wnosiło do obrazu także testy — a plik testowy nazywa
funkcje wprost. Teraz wchodzi wyłącznie `app/`.
TEST GRANICY. `test_ai_tylko_w_module.py` przechodzi po wszystkim poza modułem
i szuka siedemnastu słów. Znalazł dwanaście resztek, których nie widziałem:
komentarze w main.py i security.py, przykład `models.js` w komentarzu features.py,
komentarze w compile.js i w trzech arkuszach, oraz regułę `textarea.prompt`,
która przy rozbijaniu CSS trafiła do arkuszy ekranów zamiast do modułu.
Ma też kontrolę pozytywną (te słowa MAJĄ padać w module — inaczej test
przechodziłby, gdyby funkcję wydrążono) i test odłączalności mostu.
SPRAWDZONE NA KOPII BEZ MODUŁU: aplikacja startuje, wszystkie ekrany oddają 200,
katalog ma 13 funkcji zamiast 14, trasy strumienia nie ma, spreparowane
`action=prompt` wraca do akcji domyślnej bez śladu, a `grep` po drzewie nie
znajduje ani jednego ze słów.
Testy: presentation 362, logic 342, data 37, render 41.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1024 lines
47 KiB
Python
1024 lines
47 KiB
Python
"""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
|
||
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("<h1>404 — nie znaleziono</h1>", status_code=404)
|
||
return templates.TemplateResponse(
|
||
request, "chart.html",
|
||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
||
**rozszerzenia.kontekst_szablonu(request)},
|
||
)
|
||
|
||
|
||
@app.post("/", response_class=HTMLResponse)
|
||
def chart_compute(
|
||
request: Request,
|
||
person: str = Form(""), # imie i nazwisko — do naglowka raportu (PRE-21/24)
|
||
date: str = Form(...),
|
||
time: str = Form(...),
|
||
tz_offset: float = Form(0.0),
|
||
lat: float = Form(0.0),
|
||
lon: float = Form(0.0),
|
||
house_system: str = Form("whole_sign"),
|
||
house_systems: list[str] = Form([]), # PRE-05: dodatkowe systemy do porównania
|
||
aspect_orb: float = Form(8.0), # PRE-06: konfigurowalny orb aspektów
|
||
aspect_luminary_bonus: float = Form(2.0),
|
||
aspect_minor: bool = Form(False),
|
||
stations: bool = Form(False),
|
||
zodiac: str = Form("tropical"),
|
||
tables: bool = Form(False),
|
||
wheel_orientation: str = Form(chartwheel_mod.ASC_LEFT),
|
||
):
|
||
form = {"person": person, "date": date, "time": time, "tz_offset": tz_offset,
|
||
"lat": lat, "lon": lon, "house_system": house_system,
|
||
"house_systems": house_systems, "aspect_orb": aspect_orb,
|
||
"aspect_luminary_bonus": aspect_luminary_bonus, "aspect_minor": aspect_minor,
|
||
"stations": stations, "zodiac": zodiac, "tables": tables,
|
||
"wheel_orientation": wheel_orientation}
|
||
form = _limit_options(request, form)
|
||
ctx: dict = {"form": form, "result": None, "error": None, "moment": None}
|
||
try:
|
||
iso_utc, label = _build_utc(date, time, tz_offset)
|
||
ctx["moment"] = label
|
||
ctx["result"] = logic.positions(
|
||
when_utc_iso=iso_utc, lat=lat, lon=lon,
|
||
house_system=form["house_system"], house_systems=form["house_systems"],
|
||
aspect_orb=aspect_orb, aspect_luminary_bonus=aspect_luminary_bonus,
|
||
aspect_minor=form["aspect_minor"],
|
||
stations=form["stations"], zodiac=form["zodiac"], tables=form["tables"],
|
||
)
|
||
from app import chartwheel # kosmogram (PRE-12), SVG po stronie serwera
|
||
ctx["wheel_svg"] = chartwheel.render(ctx["result"],
|
||
orientation=form["wheel_orientation"])
|
||
# Rysunki dodatkowe NIE POWSTAJĄ bez uprawnienia — nie chodzi o ukrycie
|
||
# ich w szablonie, tylko o to, żeby nie było ich nawet w źródle strony.
|
||
if "extra_charts" in _perms(request):
|
||
ctx["aspectarian_svg"] = chartwheel.render_aspectarian(ctx["result"]) # PRE-18
|
||
ctx["declination_svg"] = chartwheel.render_declination(ctx["result"]) # LOG-07
|
||
ctx["antiscia_svg"] = chartwheel.render_antiscia(ctx["result"]) # LOG-07
|
||
except (httpx.HTTPError,) as e:
|
||
ctx["error"] = _logic_error(e)
|
||
except ValueError as e:
|
||
ctx["error"] = f"Niepoprawne dane wejściowe: {e}"
|
||
return templates.TemplateResponse(request, "chart.html", ctx)
|
||
|
||
|
||
# ---------------- 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"}
|
||
|
||
|
||
# ── 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.
|
||
rozszerzenia.zamontuj(app, logic=logic, templates=templates,
|
||
buduj_utc=_build_utc, blad=_logic_error)
|