"""Pliki baz: wybór, wgrywanie, archiwizacja i reguły przyjmowania (DAN-27).""" from __future__ import annotations from fastapi import File, Form, Request, UploadFile from fastapi.responses import HTMLResponse, RedirectResponse import base64 import httpx from app import files_state from app.features import Feature from app.podstawa import (app, templates, logic, features, _perms, _logic_error) # Etykiety stanów pliku dla szablonu. Rejestrowane TUTAJ, a nie w podstawie: # należą do tego ekranu, a produkt, który go nie ma, nie ma po co ich znać. templates.env.globals["STATUS_LABELS"] = files_state.LABELS # ---------------- Pliki baz (DAN-27) ---------------- # Trzy poziomy dostępu, opisane w features.ROUTES: # „files" — widzi listę i decyduje, z czego program korzysta, # „files_input" — dokłada wgrywanie i ARCHIWIZACJĘ (plik zostaje zamrożony # ze znacznikiem czasu, znika tylko z użytku), # administrator — kasowanie, przywracanie z archiwum i REGUŁY WALIDACJI. # # Kwarantanna (plik wgrany, ale odrzucony przez walidację) jest odsiewana W WARSTWIE # DANYCH przy `for_admin=False`. Nie filtrujemy jej tutaj ani w szablonie: gdyby # takie pliki dochodziły do przeglądarki, wystarczyłby podgląd źródła, żeby poznać # reguły — a te ma znać wyłącznie administrator. def _files_context(request: Request, error: str = "", done: str = "") -> dict: is_admin = features.ADMIN in _perms(request) try: data = logic.files_list(for_admin=is_admin) except httpx.HTTPError as e: return {"files": [], "rules": {}, "is_admin": is_admin, "error": _logic_error(e), "done": ""} return {"files": data.get("files") or [], "rules": data.get("rules") or {}, "is_admin": is_admin, "error": error, "done": done} @app.get("/files", response_class=HTMLResponse) def files_view(request: Request, error: str = "", done: str = ""): return templates.TemplateResponse(request, "files.html", _files_context(request, error, done)) def _files_redirect(error: str = "", done: str = "") -> RedirectResponse: from urllib.parse import urlencode q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v}) return RedirectResponse(f"/files{'?' + q if q else ''}", status_code=303) def _who(request: Request) -> str: return getattr(request.state, "user", "") or "-" @app.post("/files/use") def files_use(request: Request, path: str = Form(...), use: str = Form("")): """Włącza albo odstawia bazę. Włączenie przechodzi przez bramkę walidacji w warstwie danych — odmowa wraca BEZ POWODU, bo powód zdradzałby reguły.""" want = files_state.ACTIVE if use.strip().lower() in {"1", "true", "on", "tak"} \ else files_state.READY try: logic.files_status(path, want, by=_who(request)) except httpx.HTTPStatusError as e: detail = _http_detail(e) if features.ADMIN not in _perms(request): detail = "Tego pliku nie da się teraz włączyć do użytku." return _files_redirect(error=detail) except httpx.HTTPError as e: return _files_redirect(error=_logic_error(e)) return _files_redirect(done="Zapisano." if want == files_state.READY else "Plik włączony do użytku.") @app.post("/files/upload") async def files_upload(request: Request, upload: UploadFile = File(...)): """Wgranie nowej bazy. Plik zostaje NIEZALEŻNIE od wyniku walidacji — nie tracimy niczego, co ktoś wgrał.""" raw = await upload.read() if not raw: return _files_redirect(error="Pusty plik.") try: out = logic.files_upload(upload.filename or "plik.xlsx", base64.b64encode(raw).decode("ascii"), by=_who(request)) except httpx.HTTPError as e: return _files_redirect(error=_logic_error(e)) # DOKŁADNIE ten sam komunikat niezależnie od wyniku sprawdzenia. Dwa różne # komunikaty były wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać # odpowiedź, żeby odgadnąć reguły, które ma znać wyłącznie administrator. # Poprzednia treść mówiła przy tym wprost, że istnieje „administrator", który # musi plik „zatwierdzić" — czyli że konto wgrywającego jest ograniczone # i że plik przechodzi przez jakąś bramkę. return _files_redirect(done=f"Wgrano „{out.get('name')}”.") @app.post("/files/archive") def files_archive(request: Request, path: str = Form(...)): """Archiwizacja: plik ZOSTAJE, zamrożony, ze znacznikiem czasu — znika tylko z użytku. To najdalej idąca operacja dostępna osobie wgrywającej dane.""" try: logic.files_status(path, files_state.ARCHIVED, by=_who(request)) except httpx.HTTPError as e: return _files_redirect(error=_logic_error(e)) return _files_redirect(done=f"Zarchiwizowano „{path}”. Plik został zachowany.") @app.post("/files/restore") def files_restore(request: Request, path: str = Form(...)): try: logic.files_status(path, files_state.READY, by=_who(request)) except httpx.HTTPError as e: return _files_redirect(error=_logic_error(e)) return _files_redirect(done=f"Przywrócono „{path}” z archiwum.") @app.post("/files/delete") def files_delete(request: Request, path: str = Form(...)): try: logic.files_delete(path) except httpx.HTTPError as e: return _files_redirect(error=_logic_error(e)) return _files_redirect(done=f"Skasowano „{path}” bezpowrotnie.") @app.post("/files/rules") def files_rules(request: Request, extensions: str = Form(".xlsx"), max_size_mb: float = Form(50), min_rows: int = Form(1), required_columns: str = Form(""), reject_duplicate_content: str = Form("")): rules = { "extensions": [e.strip() for e in extensions.split(",") if e.strip()], "max_size_mb": max_size_mb, "min_rows": min_rows, "required_columns": [c.strip() for c in required_columns.split(",") if c.strip()], "reject_duplicate_content": reject_duplicate_content.strip().lower() in {"1", "true", "on", "tak"}, } try: logic.files_rules(rules) except httpx.HTTPError as e: return _files_redirect(error=_logic_error(e)) return _files_redirect(done="Zapisano reguły walidacji.") def _http_detail(e: httpx.HTTPStatusError) -> str: try: return str(e.response.json().get("detail") or e) except Exception: # noqa: BLE001 return str(e) features.zarejestruj( ekran=Feature("files", "Pliki", "Wybór plików, z których korzysta program.", "Ekrany", "/files", kolejnosc=70), # Kasowanie, przywracanie i REGUŁY PRZYJMOWANIA to wyłącznie administrator — # o istnieniu reguł nikt poza nim nie ma skąd wiedzieć (DAN-27). trasy={("GET", "/files"): "files", ("POST", "/files/use"): "files", ("POST", "/files/upload"): "files_input", ("POST", "/files/archive"): "files_input", ("POST", "/files/restore"): features.ADMIN, ("POST", "/files/delete"): features.ADMIN, ("POST", "/files/rules"): features.ADMIN}, zasoby={"s-files.css": "files"}, ) features.zarejestruj(rozszerzenie=Feature( "files_input", "Wgrywanie i archiwizacja plików", "Dodawanie nowych plików i wycofywanie ich z użytku (plik zostaje, zamrożony, " "ze znacznikiem czasu). Kasować może wyłącznie administrator.", "Rozszerzenia", kolejnosc=40))