caf4fd80d1
Demo ma być rozdawane szeroko i różnym osobom, więc pierwsza wersja — jedno konto na produkcyjnej warstwie danych — nie nadawała się do użycia: każdy dostawałby dostęp do oryginalnych baz, a wgrania jednego klienta widzieliby wszyscy. IZOLACJA OD PRODUKCJI. Warstwa danych i logiczna demo są osobne (manifesty w repo deploy). Osobna musi być TEŻ LOGICZNA, bo zna ona jeden adres warstwy danych — demo korzystające z produkcyjnej logiki i tak trafiłoby na produkcyjne bazy. PULE PER KONTO w warstwie danych. Zapytanie i lista plików niosą nazwę puli; puste = cały udział, czyli produkcja działa dokładnie jak dotąd i o pulach nic nie wie. Nazwa puli przechodzi przez sito dopuszczające wyłącznie znaki bezpieczne w nazwie katalogu — „../..” albo ukośnik wyprowadziłyby zapytanie wprost do cudzych baz, więc sito ZAMIENIA podejrzane znaki zamiast ufać, że nikt ich nie poda. PULA MUSI BYĆ W KLUCZU CACHE ZAPYTAŃ. Bez tego wynik policzony dla jednego konta trafiłby z cache do drugiego — cicha wymiana treści baz między klientami, niewidoczna w logach i nie do wykrycia z zewnątrz. Osobny test tego pilnuje. PULA WYNIKA Z LOGINU, nigdy z żądania. Klient warstwy logicznej jest budowany per żądanie i związany z pulą zalogowanej osoby; gdyby nazwa przychodziła z formularza, wystarczyłoby podstawić cudzy login. Test wysyła `tenant`, `user` i `login` w polach formularza i sprawdza, że nie mają na nią wpływu. Pulę wstrzykujemy w INSTANCJĘ klienta, nie w sygnatury metod. Argumentem trzeba by ją przeprowadzić przez protokół DataSource i build_report — kod, który o kontach nie ma prawa nic wiedzieć — a każde nowe wywołanie byłoby okazją, żeby o nią zapomnieć i sięgnąć nie tam. Konta demo to lista `login:sekret` (DEMO_USERS), bo jedno wspólne konto oznaczałoby wspólną pulę. Format i skrypt haseł te same, co w głównej aplikacji. Pula klienta to JEDEN KATALOG, więc przejście na pełną wersję nie oznacza utraty wgrań — procedurę importu opisuje runbook w repo deploy. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
156 lines
7.0 KiB
Python
156 lines
7.0 KiB
Python
"""astroklient — wersja DEMONSTRACYJNA (PRE-28).
|
|
|
|
DWIE FUNKCJE I ANI JEDNEJ WIĘCEJ:
|
|
1. dodanie pliku bazy (wgranie + włączenie do użytku),
|
|
2. zapytanie o interpretację urodzeniową dla podanych danych wejściowych.
|
|
|
|
DLACZEGO OSOBNA USŁUGA, A NIE KONTO Z OGRANICZENIAMI. Mechanizm uprawnień z PRE-27
|
|
umiałby to ukryć w pełnej aplikacji, ale ukrycie a nieobecność to dwie różne rzeczy.
|
|
Tutaj pozostałych funkcji NIE MA W OBRAZIE: nie ma tras, nie ma szablonów, nie ma
|
|
nawet metod w kliencie warstwy logicznej. Demo można komuś oddać, nie oddając
|
|
przy okazji kodu reszty programu.
|
|
|
|
UWAGA, ŚWIADOMA DECYZJA WŁAŚCICIELA: astroklient pracuje na TEJ SAMEJ warstwie
|
|
danych co produkcja. Kto ma do niego dostęp, czyta oryginalne bazy interpretacyjne,
|
|
a pliki przez niego wgrane trafiają do produkcyjnego zbioru. Dlatego konto jest
|
|
osobne (DEMO_USER/DEMO_PASSWORD) — żeby dało się odciąć demo jedną zmienną, bez
|
|
ruszania kont głównej aplikacji.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, File, Form, Request, UploadFile
|
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
|
|
from app import link_crypto, security
|
|
from app.clients.logic_client import LogicClient
|
|
|
|
# Automatyczna dokumentacja WYŁĄCZONA — tak samo jak w pełnej aplikacji. /docs
|
|
# wypisałoby komplet tras, a demo ma nie zdradzać nawet własnej powierzchni.
|
|
app = FastAPI(title="astroklient · demo", docs_url=None, redoc_url=None, openapi_url=None)
|
|
security.install(app)
|
|
|
|
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
|
templates = Jinja2Templates(directory="app/templates")
|
|
|
|
def _logic(request: Request) -> LogicClient:
|
|
"""Klient związany z pulą ZALOGOWANEGO konta.
|
|
|
|
Budowany PER ŻĄDANIE, nie raz na moduł: pula musi wynikać z tego, kto się
|
|
zalogował, a nie z globalnego stanu procesu. Login bierzemy z request.state,
|
|
które ustawia warstwa logowania — nigdy z pola formularza, bo wtedy
|
|
wystarczyłoby podstawić cudzy login, żeby czytać cudze bazy."""
|
|
return LogicClient(tenant=getattr(request.state, "user", "") or "")
|
|
|
|
# Stan pliku „w użyciu" — ta sama nazwa, co w warstwie danych (DAN-27). Wpisana
|
|
# tutaj wprost, bo astroklient nie importuje tamtej warstwy; literówka objawiłaby
|
|
# się dopiero na żywo, więc pilnuje jej test.
|
|
ACTIVE = "active"
|
|
|
|
|
|
def _build_utc(date: str, time: str, tz_offset: float) -> tuple[str, str]:
|
|
"""Data + godzina lokalna + offset → chwila w UTC.
|
|
|
|
Offset podaje się wprost, bez wyszukiwania strefy z współrzędnych — demo ma
|
|
dwie funkcje, a strefy to trzecia."""
|
|
try:
|
|
local = datetime.strptime(f"{date} {time}", "%Y-%m-%d %H:%M")
|
|
except ValueError as e:
|
|
raise ValueError("Podaj datę jako RRRR-MM-DD i godzinę jako GG:MM.") from e
|
|
moment = local.replace(tzinfo=timezone(timedelta(hours=tz_offset)))
|
|
return moment.astimezone(timezone.utc).isoformat(), local.strftime("%Y-%m-%d %H:%M")
|
|
|
|
|
|
def _blad(e: Exception) -> str:
|
|
if isinstance(e, httpx.HTTPStatusError):
|
|
try:
|
|
return str(e.response.json().get("detail") or e)
|
|
except Exception: # noqa: BLE001
|
|
return str(e)
|
|
if isinstance(e, link_crypto.LinkError):
|
|
return f"Łącze do warstwy logicznej nie działa: {e}"
|
|
return f"Warstwa logiczna niedostępna: {e}"
|
|
|
|
|
|
def _ile_rekordow(report: dict) -> int:
|
|
return sum(len(s.get("samples") or []) for p in (report.get("positions") or [])
|
|
for s in (p.get("significators") or []))
|
|
|
|
|
|
@app.get("/", response_class=HTMLResponse)
|
|
def formularz(request: Request, error: str = "", done: str = ""):
|
|
return templates.TemplateResponse(request, "index.html",
|
|
{"result": None, "form": {}, "error": error, "done": done})
|
|
|
|
|
|
@app.post("/", response_class=HTMLResponse)
|
|
def interpretacja(request: Request, date: str = Form(...), time: str = Form(...),
|
|
tz_offset: float = Form(0.0), lat: float = Form(0.0), lon: float = Form(0.0)):
|
|
"""Interpretacja urodzeniowa dla podanych danych. Jedyne pytanie o treść."""
|
|
form = {"date": date, "time": time, "tz_offset": tz_offset, "lat": lat, "lon": lon}
|
|
ctx: dict = {"form": form, "result": None, "error": None, "done": "", "moment": None}
|
|
try:
|
|
iso_utc, label = _build_utc(date, time, tz_offset)
|
|
ctx["moment"] = label
|
|
ctx["result"] = _logic(request).report(when_utc_iso=iso_utc, lat=lat, lon=lon)
|
|
# Ile treści baz wyszło jednym zapytaniem — sama liczba, nigdy treść (PRE-17).
|
|
security.audit_records(request, _ile_rekordow(ctx["result"]))
|
|
except ValueError as e:
|
|
ctx["error"] = str(e)
|
|
except (httpx.HTTPError, link_crypto.LinkError) as e:
|
|
ctx["error"] = _blad(e)
|
|
return templates.TemplateResponse(request, "index.html", ctx)
|
|
|
|
|
|
@app.post("/plik")
|
|
async def dodaj_plik(request: Request, upload: UploadFile = File(...)):
|
|
"""Dodanie pliku bazy: wgranie ORAZ włączenie do użytku.
|
|
|
|
W pełnej aplikacji to dwie osobne decyzje (DAN-27), bo tam ktoś nad tym panuje.
|
|
Tutaj „dodać do bazy" ma znaczyć, że plik od razu bierze udział w wyszukiwaniu —
|
|
inaczej po wgraniu nic by się nie zmieniło i demo wyglądałoby na zepsute.
|
|
|
|
Walidacja zostaje: plik o złym układzie NIE wchodzi do użytku, ale też NIE JEST
|
|
tracony — leży dalej na udziale i decyzję o nim podejmuje administrator
|
|
w pełnej aplikacji."""
|
|
raw = await upload.read()
|
|
if not raw:
|
|
return RedirectResponse("/?error=Pusty+plik.", status_code=303)
|
|
|
|
who = getattr(request.state, "user", "") or "demo"
|
|
try:
|
|
out = _logic(request).files_upload(upload.filename or "baza.xlsx",
|
|
base64.b64encode(raw).decode("ascii"), by=who)
|
|
except (httpx.HTTPError, link_crypto.LinkError) as e:
|
|
return _wroc(error=_blad(e))
|
|
|
|
nazwa = out.get("name") or "plik"
|
|
if not out.get("accepted"):
|
|
# Bez słowa o regułach — te zna wyłącznie administrator (DAN-27).
|
|
return _wroc(done=f"Plik „{nazwa}” został przyjęty, ale nie nadaje się "
|
|
f"do użycia w tej postaci.")
|
|
try:
|
|
_logic(request).files_status(out["path"], ACTIVE, by=who)
|
|
except (httpx.HTTPError, link_crypto.LinkError) as e:
|
|
return _wroc(error=f"Plik „{nazwa}” wgrany, ale nie udało się go włączyć: {_blad(e)}")
|
|
return _wroc(done=f"Dodano „{nazwa}”. Baza jest już używana przy wyszukiwaniu.")
|
|
|
|
|
|
def _wroc(error: str = "", done: str = "") -> RedirectResponse:
|
|
"""Po zapisie PRZEKIEROWANIE, nie render — odświeżenie strony nie może
|
|
wgrać pliku drugi raz."""
|
|
from urllib.parse import urlencode
|
|
|
|
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
|
return RedirectResponse(f"/{'?' + q if q else ''}", status_code=303)
|
|
|
|
|
|
@app.get("/health")
|
|
def health() -> dict:
|
|
return {"status": "ok", "layer": "astroklient"}
|