b838cf4723
build / build (push) Successful in 7s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m35s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 4m52s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 7s
Testy / Kontrola składni wszystkich warstw (push) Successful in 5s
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 w stanie dostaje `ready`, czyli NIE w użyciu — a stan po wdrożeniu jest pusty. Efekt: żadna baza nie jest aktywna i program nagle niczego nie znajduje. Ta cicha zmiana zachowania byłaby gorsza od awarii, bo wygląda jak pusta baza, a nie jak zepsuty deploy — i szukałoby się jej w warstwie danych albo w indeksie. Teraz brak PLIKU stanu oznacza pierwsze uruchomienie i bazy zastane są przyjmowane jako aktywne. Pusty słownik przy ISTNIEJĄCYM pliku to co innego: ktoś świadomie wszystko odstawił, więc nie wskrzeszamy — osobny test tego pilnuje. Rozróżnienie jest celowe i też pod testem: `ready` dotyczy plików WGRANYCH przez ekran (te ktoś musi świadomie włączyć), a nie zastanych przy przejściu na rejestr. Inaczej nowa baza wchodziłaby do wyników sama, bez niczyjej decyzji. Przyjęcie działa też na udziale tylko do odczytu: zapis stanu wtedy nie przechodzi, więc powtórzy się przy każdym uruchomieniu — zachowanie to samo, koszt żaden. Dwa testy opisujące STARE zachowanie zostały poprawione, bo to one były błędne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
348 lines
14 KiB
Python
348 lines
14 KiB
Python
"""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]:
|
|
return [p for p in sorted(root.glob("**/*"))
|
|
if p.is_file() and not p.name.startswith((".", "~$"))]
|
|
|
|
|
|
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,
|
|
"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)
|