"""Rejestr plików baz: stan użycia, wgrywanie, archiwizacja, walidacja (DAN-27). CO SIĘ ZMIENIA WZGLĘDEM DAN-15. Dotąd włączanie i wyłączanie baz szło przez zmienną `DISABLED_BASES` — deklaratywnie, bo warstwa danych nie miała gdzie zapisywać stanu (udział read-only, cache jako emptyDir). Teraz stan jest KLIKANY, więc musi być trwały: udział jest zapisywalny, a stan leży w pliku obok baz. STANY PLIKU active — bierze udział w wyszukiwaniu, ready — sprawny, ale świadomie odstawiony; można włączyć jednym kliknięciem, archived — ZAMROŻONY: nie bierze udziału, ma znacznik czasu archiwizacji, sam plik zostaje nietknięty. To jedyna forma „usuwania" dostępna osobie wgrywającej dane, quarantine — wgrany, ale nie przeszedł walidacji. NIE JEST TRACONY; decyzję, czy go skasować, podejmuje wyłącznie administrator. DLACZEGO KWARANTANNA JEST NIEWIDOCZNA POZA ADMINISTRATOREM. Zasada z PRE-27 mówi, że konto ograniczone nie ma skąd wiedzieć o mechanizmach, których nie obsługuje. Gdyby plik w kwarantannie był widoczny z powodem odrzucenia, każdy wgrywający poznałby reguły walidacji — a te są narzędziem administratora. Osoba wgrywająca widzi więc plik jako „oczekuje na zatwierdzenie", bez powodu i bez reguł. REGUŁY WALIDACJI są danymi, nie kodem: administrator ustawia je z ekranu. Trzymamy je w tym samym pliku stanu, bo stan i reguły zmieniają się razem i muszą przetrwać restart tak samo. """ from __future__ import annotations import hashlib import json import os import re import tempfile import threading from datetime import datetime, timezone from pathlib import Path ACTIVE, READY, ARCHIVED, QUARANTINE = "active", "ready", "archived", "quarantine" USABLE = frozenset({ACTIVE}) # Stany, o których wolno wiedzieć osobie bez uprawnień administracyjnych. # Kwarantanna świadomie poza listą — patrz nagłówek modułu. VISIBLE_TO_EVERYONE = frozenset({ACTIVE, READY, ARCHIVED}) _lock = threading.Lock() DEFAULT_RULES: dict = { "extensions": [".xlsx"], "max_size_mb": 50, "min_rows": 1, "required_columns": [], # puste = bez wymagań co do nagłówków "reject_duplicate_content": True, } def state_path(root: Path | str) -> Path: """Plik stanu — obok baz, chyba że wskazano inaczej. Sprawdzamy NAPIS ze środowiska, nie Path(napis): 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.""" override = os.getenv("FILES_STATE", "").strip() return Path(override) if override else Path(root) / ".files-state.json" def _now() -> str: return datetime.now(timezone.utc).isoformat(timespec="seconds") def sha256_of(path: Path | str) -> str: """Skrót treści pliku — tożsamość pliku niezależna od nazwy. Przyda się też krokowi drugiemu (lustro w SQL): to po nim poznamy, że plik na dysku rozjechał się z tym, co wczytano do bazy.""" h = hashlib.sha256() with open(path, "rb") as fh: for chunk in iter(lambda: fh.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() # ── stan ───────────────────────────────────────────────────────────────── def _read_state(root: Path) -> dict: try: with open(state_path(root), encoding="utf-8") as fh: data = json.load(fh) except (FileNotFoundError, json.JSONDecodeError): data = {} files = data.get("files") rules = data.get("rules") return { "files": files if isinstance(files, dict) else {}, "rules": {**DEFAULT_RULES, **(rules if isinstance(rules, dict) else {})}, } def _write_state(root: Path, data: dict) -> None: path = state_path(root) path.parent.mkdir(parents=True, exist_ok=True) # Atomowo: plik stanu opisuje CAŁY zbiór baz, więc obcięcie go w połowie # zapisu skasowałoby wiedzę o wszystkich naraz. fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as fh: json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True) fh.flush() os.fsync(fh.fileno()) os.replace(tmp, path) except BaseException: Path(tmp).unlink(missing_ok=True) raise def rules(root: Path | str) -> dict: return _read_state(Path(root))["rules"] def set_rules(root: Path | str, new: dict) -> dict: root = Path(root) with _lock: data = _read_state(root) merged = {**data["rules"]} for key, value in (new or {}).items(): if key in DEFAULT_RULES: merged[key] = value data["rules"] = merged _write_state(root, data) return merged # ── walidacja ──────────────────────────────────────────────────────────── def validate(path: Path | str, root: Path | str, *, digest: str = "", known_digests: dict[str, str] | None = None) -> list[str]: """Lista POWODÓW odrzucenia. Pusta lista = plik nadaje się do użytku. Zwracamy powody, a nie samo „tak/nie", bo administrator ma zobaczyć, CZEGO plikowi brakuje — inaczej poprawianie bazy byłoby zgadywanką. Poza konto administracyjne ta lista nie wychodzi.""" p, rs = Path(path), rules(root) why: list[str] = [] exts = [str(e).lower() for e in rs.get("extensions") or []] if exts and p.suffix.lower() not in exts: why.append(f"rozszerzenie {p.suffix or '(brak)'} spoza dozwolonych: {', '.join(exts)}") try: size_mb = p.stat().st_size / (1024 * 1024) except OSError: return why + ["pliku nie da się odczytać"] cap = float(rs.get("max_size_mb") or 0) if cap and size_mb > cap: why.append(f"rozmiar {size_mb:.1f} MB przekracza limit {cap:g} MB") if rs.get("reject_duplicate_content") and known_digests: digest = digest or sha256_of(p) twin = next((name for name, d in known_digests.items() if d == digest and name != p.name), None) if twin: why.append(f"treść identyczna z plikiem „{twin}”") required = [str(c).strip() for c in (rs.get("required_columns") or []) if str(c).strip()] min_rows = int(rs.get("min_rows") or 0) if required or min_rows: why += _inspect_workbook(p, required, min_rows) return why def _inspect_workbook(path: Path, required: list[str], min_rows: int) -> list[str]: """Zagląda do arkusza: nagłówki i liczba wierszy. read_only + tylko pierwszy arkusz — plik bazy potrafi mieć kilkadziesiąt MB, a wczytanie go w całości przy każdym wgraniu zatkałoby usługę.""" try: import openpyxl wb = openpyxl.load_workbook(path, read_only=True, data_only=True) except Exception as e: # noqa: BLE001 — każdy błąd = powód return [f"nie udało się otworzyć arkusza ({type(e).__name__})"] why: list[str] = [] try: ws = wb[wb.sheetnames[0]] rows = ws.iter_rows(values_only=True) header = [str(c).strip().lower() for c in (next(rows, ()) or ()) if c is not None] missing = [c for c in required if c.strip().lower() not in header] if missing: why.append(f"brak wymaganych kolumn: {', '.join(missing)}") if min_rows: seen = sum(1 for i, _ in enumerate(rows) if i < min_rows) if seen < min_rows: why.append(f"za mało wierszy danych ({seen} < {min_rows})") finally: wb.close() return why # ── rejestr ────────────────────────────────────────────────────────────── def _scan(root: Path) -> list[Path]: """Pliki na udziale, bez śmieci technicznych. Pomijamy nie tylko ukryte PLIKI, ale i wszystko, co leży w ukrytym KATALOGU: filtr po samej nazwie pliku wciągał do rejestru zawartość `.cache`, bo pliki w środku nie zaczynają się od kropki. Efekt: cache podawany jako baza, a przy pierwszym uruchomieniu jeszcze przyjmowany jako aktywny.""" def ukryta_sciezka(p: Path) -> bool: return any(part.startswith(".") for part in p.relative_to(root).parts[:-1]) return [p for p in sorted(root.glob("**/*")) if p.is_file() and not p.name.startswith((".", "~$")) and not ukryta_sciezka(p)] def _adopt_existing(root: Path) -> dict: """Pierwsze uruchomienie: bazy zastane na udziale są OD RAZU w użyciu. Bez tego wdrożenie DAN-27 wyłączyłoby wyszukiwanie. Dotąd bazy działały domyślnie (wyłączało się je jawnie przez DISABLED_BASES); po przejściu na rejestr plik bez wpisu dostaje `ready`, czyli NIE w użyciu — więc pusty stan po wdrożeniu oznaczałby, że program nagle niczego nie znajduje. Ta cicha zmiana zachowania byłaby gorsza od awarii, bo wygląda jak pusta baza. Rozróżnienie jest celowe: `ready` dotyczy plików WGRANYCH przez ekran (te ktoś musi świadomie włączyć), a nie zastanych przy przejściu na rejestr. Zapis stanu może się nie udać (udział read-only) — wtedy trudno, przy każdym uruchomieniu przyjmiemy je na nowo. Zachowanie jest to samo, koszt żaden.""" files = {str(p.relative_to(root)): {"status": ACTIVE, "adopted_at": _now()} for p in _scan(root)} data = {"files": files, "rules": {**DEFAULT_RULES}} try: _write_state(root, data) except OSError: pass return data def registry(root: Path | str, *, for_admin: bool = False) -> list[dict]: """Pliki na udziale wraz ze stanem. `for_admin` odsłania kwarantannę i powody. Filtrowanie siedzi TUTAJ, a nie w szablonie: gdyby pliki w kwarantannie dochodziły do przeglądarki i były tylko ukrywane stylem, wystarczyłby podgląd źródła strony, żeby poznać reguły walidacji.""" root = Path(root) # Brak PLIKU stanu = pierwsze uruchomienie. Pusty słownik przy istniejącym # pliku to co innego: ktoś świadomie wszystko odstawił, więc nie wskrzeszamy. data = _read_state(root) if state_path(root).exists() else _adopt_existing(root) out: list[dict] = [] for p in _scan(root): rel = str(p.relative_to(root)) row = data["files"].get(rel, {}) status = row.get("status") or READY if status == QUARANTINE and not for_admin: continue try: st = p.stat() size_mb = round(st.st_size / (1024 * 1024), 2) modified = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).strftime("%Y-%m-%d") except OSError: size_mb, modified = None, None entry = { "name": p.name, "path": rel, "size_mb": size_mb, "modified": modified, "status": status, "in_use": status in USABLE, # `enabled` to TA SAMA informacja pod nazwą, której używa reszta # świata: endpoint /bases, warstwa logiczna i ekran „Ustawienia" # (DAN-15/PRE-09). Rejestr wszedł w miejsce starej listy baz, więc # musi mówić jej językiem — inaczej każdy odbiorca dostaje KeyError, # a to była właśnie awaria /bases po wdrożeniu DAN-27. "enabled": status in USABLE, "archived_at": row.get("archived_at") or "", "uploaded_at": row.get("uploaded_at") or "", "uploaded_by": row.get("uploaded_by") or "", "sha256": row.get("sha256") or "", } if for_admin: entry["rejected_for"] = list(row.get("rejected_for") or []) out.append(entry) return out def usable_paths(root: Path | str) -> list[str]: """Ścieżki baz, które FAKTYCZNIE biorą udział w wyszukiwaniu.""" root = Path(root) return [str(root / e["path"]) for e in registry(root, for_admin=True) if e["in_use"]] def _touch(root: Path, rel: str, **fields) -> dict: with _lock: data = _read_state(root) row = {**data["files"].get(rel, {}), **fields} data["files"][rel] = row _write_state(root, data) return row def set_status(root: Path | str, rel: str, status: str, *, by: str = "") -> dict: """Zmienia stan pliku. Włączyć do użytku można TYLKO plik, który przeszedł walidację — to jest właśnie ta bramka, o której mowa w wymaganiu.""" root = Path(root) target = root / rel if not target.is_file(): raise ValueError(f"Nie ma pliku „{rel}”.") if status not in {ACTIVE, READY, ARCHIVED, QUARANTINE}: raise ValueError(f"Nieznany stan: {status}") data = _read_state(root) current = (data["files"].get(rel) or {}).get("status") or READY if status == ACTIVE: if current == QUARANTINE: raise ValueError("Plik nie może trafić do użytku.") known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True) if e["sha256"]} why = validate(target, root, known_digests=known) if why: _touch(root, rel, status=QUARANTINE, rejected_for=why, checked_at=_now()) raise ValueError("Plik nie może trafić do użytku.") fields = {"status": status, "changed_at": _now(), "changed_by": by} if status == ARCHIVED: # Znacznik czasu archiwizacji to wymóg: „zamrożona forma z timestampem". fields["archived_at"] = _now() elif status == ACTIVE: fields["archived_at"] = "" fields["rejected_for"] = [] return _touch(root, rel, **fields) def store_upload(root: Path | str, filename: str, content: bytes, *, by: str = "") -> dict: """Zapisuje wgrany plik i od razu go sprawdza. Plik zostaje NIEZALEŻNIE od wyniku walidacji — nie tracimy niczego, co ktoś wgrał. Zmienia się tylko to, czy da się go włączyć do użytku.""" root = Path(root) safe = re.sub(r"[^A-Za-z0-9._ -]", "_", Path(filename or "").name).strip() or "plik" target = root / safe stem, suffix, n = Path(safe).stem, Path(safe).suffix, 1 while target.exists(): # nie nadpisujemy cudzej bazy target = root / f"{stem}-{n}{suffix}" n += 1 root.mkdir(parents=True, exist_ok=True) target.write_bytes(content) rel = str(target.relative_to(root)) digest = sha256_of(target) known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True) if e["sha256"] and e["path"] != rel} why = validate(target, root, digest=digest, known_digests=known) _touch(root, rel, status=QUARANTINE if why else READY, rejected_for=why, sha256=digest, uploaded_at=_now(), uploaded_by=by, checked_at=_now()) return {"path": rel, "name": target.name, "accepted": not why} def delete(root: Path | str, rel: str) -> None: """Nieodwracalne skasowanie pliku — wyłącznie dla administratora.""" root = Path(root) target = root / rel if not target.is_file(): raise ValueError(f"Nie ma pliku „{rel}”.") target.unlink() with _lock: data = _read_state(root) data["files"].pop(rel, None) _write_state(root, data)