feat(dane): interfejs zarządzania plikami baz — trzy poziomy dostępu (DAN-27)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m12s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 19s
Testy / Kontrola składni wszystkich warstw (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 12m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 16s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m12s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 19s
Testy / Kontrola składni wszystkich warstw (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 12m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 16s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
Ekran „Pliki" z trzema poziomami, wpiętymi w kontrolę dostępu z PRE-27:
„files" widzi listę i KLIKANIEM decyduje, z których baz program korzysta,
„files_input" dokłada wgrywanie i ARCHIWIZACJĘ,
administrator kasowanie, przywracanie z archiwum i REGUŁY WALIDACJI.
STAN JEST TERAZ TRWAŁY. DAN-15 trzymał go w zmiennej DISABLED_BASES, bo warstwa
danych nie miała gdzie zapisywać — udział był montowany read-only. Skoro stan ma
być klikany, musi przetrwać restart, więc udział jest zapisywalny, a stan leży
w pliku obok baz (zapis atomowy: plik opisuje CAŁY zbiór, więc obcięcie w połowie
skasowałoby wiedzę o wszystkich naraz). DISABLED_BASES zostaje jako awaryjne
wyłączenie z konfiguracji i odsiewa DODATKOWO — nie odwrotnie, bo inaczej ktoś
z dostępem do ekranu włączyłby bazę wyłączoną świadomie na poziomie wdrożenia.
ARCHIWIZACJA NIE KASUJE. Plik zostaje na dysku, zamrożony, ze znacznikiem czasu;
znika wyłącznie z użytku. To najdalej idąca operacja osoby wgrywającej dane —
kasować może tylko administrator. Test sprawdza, że plik po archiwizacji nadal
istnieje, bo to jest cała istota tej operacji.
WALIDACJA JEST BRAMKĄ DO UŻYTKU, NIE FILTREM NA WEJŚCIU. Plik wgrany zostaje
NIEZALEŻNIE od wyniku — nie tracimy niczego, co ktoś wgrał. Zmienia się tylko to,
czy da się go włączyć. Sprawdzenie biegnie też w chwili włączania, nie tylko przy
wgrywaniu: reguły mogą się zmienić po fakcie.
O WALIDACJI WIE TYLKO ADMINISTRATOR. Pliki wstrzymane są odsiewane W WARSTWIE
DANYCH przy for_admin=False, a nie ukrywane w szablonie — gdyby dochodziły do
przeglądarki, wystarczyłby podgląd źródła, żeby poznać reguły. Odmowa włączenia
wraca do konta bez uprawnień BEZ POWODU, bo powód zdradza regułę. Sekcja reguł
nie trafia nawet do źródła strony. Test parametryzowany po obu niższych poziomach
szuka w odpowiedzi śladów mechanizmu i wymaga, żeby żadnego nie było.
Każdy plik ma policzony sha256 — tożsamość niezależna od nazwy. Wykorzystuje ją
już odrzucanie duplikatów, a w kroku drugim posłuży do pilnowania zgodności
lustra w SQL.
Przy okazji naprawiony błąd, który dopiero co bym wprowadził: Path("") to
Path("."), czyli wartość PRAWDZIWA, więc `Path(os.getenv(...)) or domyślna`
zawsze wybierało pustą zmienną i zapisywało stan do katalogu bieżącego.
Wymaga zapisywalnego udziału — osobny PR w repo deploy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,14 +17,17 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||
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 files_state
|
||||
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
|
||||
|
||||
from app import geocode, security
|
||||
@@ -69,6 +72,7 @@ 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]:
|
||||
@@ -672,6 +676,141 @@ def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00")
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user