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

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:
2026-08-07 14:39:42 +02:00
parent a833965909
commit 6466ab89a9
14 changed files with 1232 additions and 10 deletions
Binary file not shown.
+321
View File
@@ -0,0 +1,321 @@
"""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 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)
data = _read_state(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)
+77 -2
View File
@@ -7,13 +7,16 @@ ani prezentacji.
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
from __future__ import annotations
import base64
import binascii
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from app import canary, link_crypto, security
from app import canary, files, link_crypto, security
from app.config import settings
from app.models import HealthInfo, SearchQuery, SearchResult
from pydantic import BaseModel
from app.providers.factory import build_provider
provider = build_provider(settings)
@@ -54,6 +57,78 @@ def bases() -> dict:
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
# ── zarządzanie plikami baz (DAN-27) ─────────────────────────────────────
# Warstwa danych jest właścicielem plików, więc to ona nimi zarządza. Uprawnienia
# rozstrzyga PREZENTACJA (PRE-27) i przekazuje tu wynik jako `for_admin` / `by` —
# ta warstwa nie zna kont i nie ma jak ich znać. Nie jest to dziura: warstwa
# danych stoi za tokenem międzywarstwowym i szyfrowanym łączem, więc rozmawia
# z nią wyłącznie warstwa logiczna.
class FilesQuery(BaseModel):
for_admin: bool = False
class FileAction(BaseModel):
path: str
status: str = ""
by: str = ""
class FileUpload(BaseModel):
filename: str
content_b64: str
by: str = ""
class RulesUpdate(BaseModel):
rules: dict
@app.post("/files")
def files_list(q: FilesQuery) -> dict:
"""Rejestr plików. Kwarantanna WYCHODZI stąd tylko przy for_admin — filtrujemy
u źródła, żeby nie dało się jej odczytać z podglądu źródła strony."""
root = settings.excel_dir
return {"files": files.registry(root, for_admin=q.for_admin),
"rules": files.rules(root) if q.for_admin else {},
"root": str(root)}
@app.post("/files/status")
def files_status(a: FileAction) -> dict:
try:
row = files.set_status(settings.excel_dir, a.path, a.status, by=a.by)
except ValueError as e:
raise HTTPException(422, str(e)) from e
return {"path": a.path, "status": row.get("status")}
@app.post("/files/upload")
def files_upload(u: FileUpload) -> dict:
"""Plik wędruje w base64 wewnątrz zaszyfrowanego łącza — tym samym kanałem,
co reszta ruchu międzywarstwowego. Osobny, nieszyfrowany kanał na pliki
byłby obejściem PRE-16."""
try:
raw = base64.b64decode(u.content_b64, validate=True)
except (binascii.Error, ValueError) as e:
raise HTTPException(422, "Nieczytelna zawartość pliku.") from e
return files.store_upload(settings.excel_dir, u.filename, raw, by=u.by)
@app.post("/files/delete")
def files_delete(a: FileAction) -> dict:
try:
files.delete(settings.excel_dir, a.path)
except ValueError as e:
raise HTTPException(422, str(e)) from e
return {"deleted": a.path}
@app.post("/files/rules")
def files_rules(u: RulesUpdate) -> dict:
return {"rules": files.set_rules(settings.excel_dir, u.rules)}
@app.get("/health", response_model=HealthInfo)
def health() -> HealthInfo:
return provider.health()
+17 -7
View File
@@ -92,20 +92,30 @@ class ExcelDataProvider(DataProvider):
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
def _enabled_files(self, paths: list[str]) -> list[str]:
"""Odsiewa bazy WYŁĄCZONE globalnie (DAN-15) — nie biorą udziału
w interpretacji, choć fizycznie leżą na udziale."""
from app import bases
"""Bazy biorące udział w wyszukiwaniu.
Źródłem prawdy jest REJESTR PLIKÓW (DAN-27) — stan klikany z ekranu,
trwały na udziale. Zmienna DISABLED_BASES z DAN-15 zostaje jako awaryjne
wyłączenie z konfiguracji: gdy jest ustawiona, odsiewa DODATKOWO. Nie
odwrotnie — inaczej ktoś z dostępem do ekranu mógłby włączyć bazę
wyłączoną świadomie na poziomie wdrożenia.
"""
from app import files
usable = set(files.usable_paths(self.s.excel_dir))
out = [p for p in paths if p in usable]
entries = bases.disabled_entries()
if not entries:
return paths
return [p for p in paths if bases.is_enabled(p, self.s.excel_dir, entries)]
if entries:
out = [p for p in out if bases.is_enabled(p, self.s.excel_dir, entries)]
return out
def list_bases(self) -> list[dict]:
"""Bazy dostępne na udziale + metaopis + stan włączenia (DAN-15/PRE-09)."""
from app import bases
return bases.list_bases(self.s.excel_dir, self._excel_files())
from app import files
return files.registry(self.s.excel_dir, for_admin=True)
# ---- publiczne API ----
def search(self, query: SearchQuery) -> SearchResult:
+162
View File
@@ -0,0 +1,162 @@
"""Rejestr plików baz: stany, walidacja, archiwizacja (DAN-27).
Testujemy tu RDZEŃ — bez HTTP i bez uprawnień, bo uprawnienia rozstrzyga
prezentacja (patrz services/presentation/tests/test_pliki.py). Tutaj chodzi
o to, żeby żadna operacja nie gubiła pliku i żeby bramka „do użytku tylko po
walidacji" faktycznie trzymała.
"""
import pathlib
import pytest
from app import files
@pytest.fixture()
def root(tmp_path, monkeypatch):
monkeypatch.delenv("FILES_STATE", raising=False)
return tmp_path
def _xlsx(path, rows=3, header=("id", "opis")):
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.append(list(header))
for i in range(rows):
ws.append([i, f"treść {i}"])
wb.save(path)
return path
# ── rejestr i stany ──────────────────────────────────────────────────────
def test_new_file_is_visible_but_not_in_use(root):
_xlsx(root / "baza.xlsx")
entry = files.registry(root)[0]
assert entry["status"] == files.READY
assert entry["in_use"] is False, "nowy plik nie może sam wejść do wyszukiwania"
def test_only_active_files_reach_the_search(root):
_xlsx(root / "a.xlsx")
_xlsx(root / "b.xlsx")
assert files.usable_paths(root) == []
files.set_status(root, "a.xlsx", files.ACTIVE)
assert [pathlib.Path(p).name for p in files.usable_paths(root)] == ["a.xlsx"]
def test_state_survives_a_restart(root):
"""Stan jest KLIKANY, więc musi być trwały — inaczej restart poda po cichu
przywracałby bazy wyłączone świadomie."""
_xlsx(root / "a.xlsx")
files.set_status(root, "a.xlsx", files.ACTIVE)
assert files.state_path(root).exists()
assert files.registry(root)[0]["in_use"] is True
# ── archiwizacja ─────────────────────────────────────────────────────────
def test_archiving_freezes_the_file_but_never_removes_it(root):
"""To jest najdalej idąca operacja osoby wgrywającej dane: plik ZOSTAJE."""
p = _xlsx(root / "stara.xlsx")
files.set_status(root, "stara.xlsx", files.ACTIVE)
files.set_status(root, "stara.xlsx", files.ARCHIVED, by="dane")
entry = files.registry(root)[0]
assert p.exists(), "plik zniknął z dysku — archiwizacja ma go zachować"
assert entry["status"] == files.ARCHIVED
assert entry["in_use"] is False
assert entry["archived_at"], "brak znacznika czasu archiwizacji"
def test_archived_file_cannot_slip_back_into_use_by_itself(root):
_xlsx(root / "stara.xlsx")
files.set_status(root, "stara.xlsx", files.ARCHIVED)
assert files.usable_paths(root) == []
# ── walidacja: bramka do użytku ──────────────────────────────────────────
def test_upload_keeps_a_file_that_fails_validation(root):
"""Rzecz najważniejsza: wgranego pliku NIE TRACIMY, choćby nie przeszedł."""
files.set_rules(root, {"extensions": [".xlsx"]})
out = files.store_upload(root, "notatka.txt", "to nie jest baza".encode("utf-8"), by="dane")
assert out["accepted"] is False
assert (root / out["path"]).exists(), "plik odrzucony zniknął z dysku"
admin_view = files.registry(root, for_admin=True)[0]
assert admin_view["status"] == files.QUARANTINE
assert admin_view["rejected_for"], "administrator ma widzieć powód"
def test_a_held_file_is_invisible_without_admin_rights(root):
"""Gdyby plik wstrzymany był widoczny z powodem odrzucenia, każdy wgrywający
poznałby reguły walidacji — a te są narzędziem administratora."""
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
assert files.registry(root, for_admin=False) == []
assert len(files.registry(root, for_admin=True)) == 1
def test_a_held_file_cannot_be_switched_into_use(root):
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
rel = files.registry(root, for_admin=True)[0]["path"]
with pytest.raises(ValueError):
files.set_status(root, rel, files.ACTIVE)
def test_activation_revalidates_and_holds_a_file_that_stopped_qualifying(root):
"""Reguły mogą się zmienić PO wgraniu — bramka sprawdza w chwili włączania,
a nie tylko przy wgrywaniu."""
_xlsx(root / "mala.xlsx", rows=2)
files.set_status(root, "mala.xlsx", files.ACTIVE)
files.set_rules(root, {"min_rows": 500})
files.set_status(root, "mala.xlsx", files.READY)
with pytest.raises(ValueError):
files.set_status(root, "mala.xlsx", files.ACTIVE)
assert (root / "mala.xlsx").exists()
@pytest.mark.parametrize("rule,value,bad", [
("extensions", [".xlsx"], "plik.csv"),
("max_size_mb", 0.000001, "plik.xlsx"),
])
def test_rules_reject_what_they_are_meant_to(root, rule, value, bad):
files.set_rules(root, {rule: value})
out = files.store_upload(root, bad, b"x" * 2048)
assert out["accepted"] is False
def test_required_columns_are_checked_inside_the_workbook(root):
files.set_rules(root, {"required_columns": ["id", "znaczenie"]})
_xlsx(root / "tmp.xlsx", header=("id", "opis"))
why = files.validate(root / "tmp.xlsx", root)
assert why and "znaczenie" in why[0]
def test_duplicate_content_is_rejected_by_hash_not_by_name(root):
files.set_rules(root, {"reject_duplicate_content": True})
data = _xlsx(root / "wzor.xlsx").read_bytes()
first = files.store_upload(root, "pierwsza.xlsx", data)
assert first["accepted"] is True
second = files.store_upload(root, "inna-nazwa.xlsx", data)
assert second["accepted"] is False
def test_upload_never_overwrites_someone_elses_base(root):
files.store_upload(root, "baza.xlsx", _xlsx(root / "w.xlsx").read_bytes())
(root / "w.xlsx").unlink()
files.set_rules(root, {"reject_duplicate_content": False})
out = files.store_upload(root, "baza.xlsx", "inna treść".encode("utf-8"))
assert out["name"] != "baza.xlsx"
assert (root / "baza.xlsx").exists() and (root / out["path"]).exists()
# ── kasowanie ────────────────────────────────────────────────────────────
def test_delete_removes_the_file_and_its_entry(root):
_xlsx(root / "a.xlsx")
files.set_status(root, "a.xlsx", files.ACTIVE)
files.delete(root, "a.xlsx")
assert not (root / "a.xlsx").exists()
assert files.registry(root, for_admin=True) == []
assert files.usable_paths(root) == []
+27
View File
@@ -51,6 +51,33 @@ class DataClient:
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
headers=_auth_headers(), link=_link())
# ── zarządzanie plikami baz (DAN-27) ────────────────────────────────
# Jedna metoda na trasę, bez sprytnego generyka: te wywołania różnią się
# skutkiem (odczyt / zapis / skasowanie), a ujednolicenie ich w jedno
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
def files_list(self, for_admin: bool = False) -> dict[str, Any]:
return self._files_post("/files", {"for_admin": for_admin})
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/status", {"path": path, "status": status, "by": by})
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/upload",
{"filename": filename, "content_b64": content_b64, "by": by})
def files_delete(self, path: str) -> dict[str, Any]:
return self._files_post("/files/delete", {"path": path})
def files_rules(self, rules: dict) -> dict[str, Any]:
return self._files_post("/files/rules", {"rules": rules})
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
payload=payload, headers=_auth_headers(), link=_link())
def health(self) -> dict[str, Any]:
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
# nie mają, a nie przechodzi tędy nic z baz.
+72
View File
@@ -121,6 +121,78 @@ def bases() -> dict:
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
# ── zarządzanie plikami baz (DAN-27) — czysty przelot ───────────────────
# Warstwa logiczna niczego tu nie rozstrzyga: uprawnienia zna PREZENTACJA
# (PRE-27), właścicielem plików jest warstwa DANYCH. Ta warstwa tylko przenosi,
# bo prezentacja nie ma prawa rozmawiać z danymi wprost.
class FilesQuery(BaseModel):
for_admin: bool = False
class FileAction(BaseModel):
path: str
status: str = ""
by: str = ""
class FileUpload(BaseModel):
filename: str
content_b64: str
by: str = ""
class RulesUpdate(BaseModel):
rules: dict
def _files_call(fn, *args, **kw) -> dict:
from app.clients.data_client import DataClient
try:
return fn(DataClient(), *args, **kw)
except httpx.HTTPStatusError as e:
# 422 z warstwy danych to ODMOWA MERYTORYCZNA (np. plik nie przeszedł
# walidacji), nie awaria — ma dojść do prezentacji jako 422, żeby dało
# się pokazać powód zamiast „usługa niedostępna".
raise HTTPException(status_code=e.response.status_code,
detail=_detail(e)) from e
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
def _detail(e: httpx.HTTPStatusError) -> str:
try:
return str(e.response.json().get("detail") or e)
except Exception: # noqa: BLE001
return str(e)
@app.post("/files")
def files_list(q: FilesQuery) -> dict:
return _files_call(lambda c: c.files_list(q.for_admin))
@app.post("/files/status")
def files_status(a: FileAction) -> dict:
return _files_call(lambda c: c.files_status(a.path, a.status, a.by))
@app.post("/files/upload")
def files_upload(u: FileUpload) -> dict:
return _files_call(lambda c: c.files_upload(u.filename, u.content_b64, u.by))
@app.post("/files/delete")
def files_delete(a: FileAction) -> dict:
return _files_call(lambda c: c.files_delete(a.path))
@app.post("/files/rules")
def files_rules(u: RulesUpdate) -> dict:
return _files_call(lambda c: c.files_rules(u.rules))
@app.post("/chart/synastry")
def chart_synastry(req: SynastryRequest) -> dict:
"""Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta
@@ -158,6 +158,33 @@ class LogicClient:
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
headers=_auth_headers(), link=_link())
# ── zarządzanie plikami baz (DAN-27) ────────────────────────────────
# Jedna metoda na trasę, bez sprytnego generyka: te wywołania różnią się
# skutkiem (odczyt / zapis / skasowanie), a ujednolicenie ich w jedno
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
def files_list(self, for_admin: bool = False) -> dict[str, Any]:
return self._files_post("/files", {"for_admin": for_admin})
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/status", {"path": path, "status": status, "by": by})
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/upload",
{"filename": filename, "content_b64": content_b64, "by": by})
def files_delete(self, path: str) -> dict[str, Any]:
return self._files_post("/files/delete", {"path": path})
def files_rules(self, rules: dict) -> dict[str, Any]:
return self._files_post("/files/rules", {"rules": rules})
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
payload=payload, headers=_auth_headers(), link=_link())
def llm_models(self) -> dict[str, Any]:
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
with httpx.Client(timeout=settings.http_timeout) as client:
+16
View File
@@ -41,6 +41,7 @@ SCREENS: tuple[Feature, ...] = (
Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.", "Ekrany", "/synastry"),
Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach interpretacyjnych.", "Ekrany", "/significators"),
Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.", "Ekrany", "/compile"),
Feature("files", "Pliki", "Wybór baz, z których korzysta program.", "Ekrany", "/files"),
Feature("settings", "Ustawienia", "Podgląd baz i konfiguracji modelu.", "Ekrany", "/settings"),
)
@@ -57,6 +58,10 @@ EXTRAS: tuple[Feature, ...] = (
Feature("ai", "Generowanie tekstu przez model",
"Horoskopy pisane przez model językowy. UWAGA: każde użycie kosztuje.",
"Rozszerzenia"),
Feature("files_input", "Wgrywanie i archiwizacja baz",
"Dodawanie nowych plików baz i wycofywanie ich z użytku (plik zostaje, "
"zamrożony, ze znacznikiem czasu). Kasować może wyłącznie administrator.",
"Rozszerzenia"),
Feature("export", "Eksport plików",
"Pobieranie raportu jako PDF i wyników jako Excel.", "Rozszerzenia"),
)
@@ -84,6 +89,17 @@ ROUTES: dict[tuple[str, str], str | None] = {
("POST", "/compile"): "compile",
("POST", "/compile/pdf"): "export",
("GET", "/settings"): "settings",
# Zarządzanie plikami baz (DAN-27). Trzy poziomy: „files" wybiera, z czego
# program korzysta; „files_input" dokłada wgrywanie i archiwizację;
# kasowanie, przywracanie i REGUŁY WALIDACJI to wyłącznie administrator —
# o istnieniu walidacji nikt poza nim nie ma skąd wiedzieć.
("GET", "/files"): "files",
("POST", "/files/use"): "files",
("POST", "/files/upload"): "files_input",
("POST", "/files/archive"): "files_input",
("POST", "/files/restore"): ADMIN,
("POST", "/files/delete"): ADMIN,
("POST", "/files/rules"): ADMIN,
("POST", "/horoscope/stream"): "ai",
("GET", "/accounts"): ADMIN,
("POST", "/accounts/create"): ADMIN,
+17
View File
@@ -0,0 +1,17 @@
"""Nazwy stanów pliku bazy — wspólne dla prezentacji i warstwy danych (DAN-27).
Osobny, króciutki moduł, bo prezentacja NIE MOŻE importować warstwy danych (to
inna usługa i inny obraz), a wpisanie tych napisów wprost w handlerach skończyłoby
się literówką, która przejdzie testy i wyjdzie dopiero na produkcji.
"""
ACTIVE = "active"
READY = "ready"
ARCHIVED = "archived"
QUARANTINE = "quarantine"
LABELS = {
ACTIVE: "w użyciu",
READY: "gotowa, odstawiona",
ARCHIVED: "zarchiwizowana",
QUARANTINE: "wstrzymana",
}
+140 -1
View File
@@ -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.
@@ -247,3 +247,18 @@ button.danger { background: #8b2f2f; }
border: 1px solid var(--line); border-left: 4px solid var(--accent);
border-radius: 4px; background: rgba(255, 255, 255, .03);
}
/* ── ekran plików (DAN-27) ──────────────────────────────────────────── */
form.inline { display: inline; }
td.ops form.inline + form.inline { margin-left: .4rem; }
button.toggle {
background: none; border: 1px solid var(--line); border-radius: 50%;
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
}
button.toggle.on { color: var(--accent); border-color: var(--accent); }
button.toggle.off { color: var(--muted); }
tr.row-archived td { opacity: .55; }
/* Wstrzymane widzi tylko administrator — dla reszty tych wierszy nie ma
w ogóle w odpowiedzi, więc ten styl nigdy nie dotyczy ich strony. */
tr.row-held td { background: rgba(184, 134, 11, .07); }
@@ -0,0 +1,126 @@
{% extends "base.html" %}
{% set nav_active = "files" %}
{% block title %}Pliki{% endblock %}
{% block content %}
<p class="muted">
Bazy interpretacyjne, z których korzysta program. Zaznaczona baza bierze udział
w wyszukiwaniu; odznaczona zostaje na dysku, ale program jej nie używa.
</p>
{% if error %}<p class="house-warning">{{ error }}</p>{% endif %}
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
<div class="meta">Bazy ({{ files | length }})</div>
{% if not files %}
<p class="muted">Nie ma jeszcze żadnego pliku bazy.</p>
{% endif %}
<div class="table-scroll">
<table>
<thead>
<tr>
<th>W użyciu</th><th>Plik</th><th>Stan</th><th>Rozmiar</th><th>Zmieniony</th>
{% if can(request, 'files_input') or is_admin %}<th>Operacje</th>{% endif %}
</tr>
</thead>
<tbody>
{% for f in files %}
<tr class="{{ 'row-archived' if f.status == 'archived' else '' }}{{ ' row-held' if f.status == 'quarantine' else '' }}">
<td>
{# Archiwum jest ZAMROŻONE — nie da się go włączyć bez przywrócenia,
a przywrócić może wyłącznie administrator. #}
{% if f.status in ('active', 'ready') %}
<form method="post" action="/files/use" class="inline">
<input type="hidden" name="path" value="{{ f.path }}">
<input type="hidden" name="use" value="{{ '0' if f.in_use else '1' }}">
<button type="submit" class="toggle {{ 'on' if f.in_use else 'off' }}"
title="{{ 'Odstaw tę bazę' if f.in_use else 'Włącz tę bazę do użytku' }}">
{{ '●' if f.in_use else '○' }}
</button>
</form>
{% else %}<span class="muted"></span>{% endif %}
</td>
<td class="mono">{{ f.name }}</td>
<td>
{{ STATUS_LABELS.get(f.status, f.status) }}
{% if f.status == 'archived' and f.archived_at %}
<span class="muted small">({{ f.archived_at[:16] | replace('T', ' ') }})</span>
{% endif %}
{% if is_admin and f.rejected_for %}
<div class="muted small">nie przeszedł: {{ f.rejected_for | join('; ') }}</div>
{% endif %}
</td>
<td class="mono">{{ '%.2f'|format(f.size_mb) if f.size_mb is not none else '—' }} MB</td>
<td class="mono">{{ f.modified or '—' }}</td>
{% if can(request, 'files_input') or is_admin %}
<td class="ops">
{% if can(request, 'files_input') and f.status in ('active', 'ready') %}
<form method="post" action="/files/archive" class="inline">
<input type="hidden" name="path" value="{{ f.path }}">
<button type="submit" class="ghost"
title="Plik zostaje na dysku, zamrożony ze znacznikiem czasu — znika tylko z użytku.">Archiwizuj</button>
</form>
{% endif %}
{% if is_admin %}
{% if f.status in ('archived', 'quarantine') %}
<form method="post" action="/files/restore" class="inline">
<input type="hidden" name="path" value="{{ f.path }}">
<button type="submit" class="ghost">Przywróć</button>
</form>
{% endif %}
<form method="post" action="/files/delete" class="inline">
<input type="hidden" name="path" value="{{ f.path }}">
<button type="submit" class="danger"
onclick="return confirm('Skasować {{ f.name }} bezpowrotnie? Pliku nie da się odzyskać.')">Skasuj</button>
</form>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if can(request, 'files_input') %}
<div class="meta">Wgraj nową bazę</div>
<form method="post" action="/files/upload" enctype="multipart/form-data" class="account-card">
<div class="grid">
<label>Plik <input type="file" name="upload" required></label>
</div>
<div class="actions"><button type="submit">Wgraj</button></div>
</form>
{% endif %}
{% if is_admin %}
{# Reguły walidacji widzi WYŁĄCZNIE administrator — cała ta sekcja nie trafia
nawet do źródła strony dla pozostałych kont. #}
<div class="meta">Reguły przyjmowania baz</div>
<p class="muted small">
Plik musi je spełnić, żeby dało się go włączyć do użytku. Plik, który ich nie
spełnia, <strong>nie jest kasowany</strong> — czeka na Twoją decyzję.
</p>
<form method="post" action="/files/rules" class="account-card">
<div class="grid">
<label>Dozwolone rozszerzenia (po przecinku)
<input type="text" name="extensions" value="{{ (rules.extensions or []) | join(', ') }}"></label>
<label>Maksymalny rozmiar (MB)
<input type="number" name="max_size_mb" step="1" min="0" value="{{ rules.max_size_mb }}"></label>
<label>Minimalna liczba wierszy
<input type="number" name="min_rows" step="1" min="0" value="{{ rules.min_rows }}"></label>
</div>
<div class="grid">
<label>Wymagane kolumny (po przecinku, puste = bez wymagań)
<input type="text" name="required_columns"
value="{{ (rules.required_columns or []) | join(', ') }}"></label>
</div>
<div class="opts">
<label><input type="checkbox" name="reject_duplicate_content" value="1"
{{ 'checked' if rules.reject_duplicate_content else '' }}>
odrzucaj pliki o treści identycznej z już wgraną</label>
</div>
<div class="actions"><button type="submit">Zapisz reguły</button></div>
</form>
{% endif %}
{% endblock %}
+215
View File
@@ -0,0 +1,215 @@
"""Trzy poziomy dostępu do plików baz (DAN-27 × PRE-27).
user („files") — widzi listę i decyduje, z czego program korzysta,
data_input (+„files_input")— dokłada wgrywanie i ARCHIWIZACJĘ,
administrator — kasowanie, przywracanie i REGUŁY WALIDACJI.
Osobno pilnujemy własności negatywnej: poza administratorem NIKT nie ma skąd
wiedzieć, że walidacja w ogóle istnieje. To łatwo zepsuć dobrą intencją —
„pokażmy człowiekowi, czemu plik nie przeszedł" — więc testy są wprost o tym.
"""
import pytest
from app import accounts as store
from app import features
@pytest.fixture()
def env(tmp_path, monkeypatch):
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
monkeypatch.setenv("APP_USER", "szef")
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
monkeypatch.delenv("APP_USERS", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
return tmp_path
def _auth(user, password):
import base64
return {"Authorization": "Basic " + base64.b64encode(
f"{user}:{password}".encode()).decode()}
REGISTRY = [
{"name": "glowna.xlsx", "path": "glowna.xlsx", "size_mb": 1.0, "modified": "2026-01-01",
"status": "active", "in_use": True, "archived_at": "", "uploaded_at": "",
"uploaded_by": "", "sha256": "abc"},
{"name": "stara.xlsx", "path": "stara.xlsx", "size_mb": 2.0, "modified": "2025-01-01",
"status": "archived", "in_use": False, "archived_at": "2026-02-02T10:00:00+00:00",
"uploaded_at": "", "uploaded_by": "", "sha256": "def"},
]
HELD = {"name": "podejrzana.xlsx", "path": "podejrzana.xlsx", "size_mb": 0.1,
"modified": "2026-03-03", "status": "quarantine", "in_use": False,
"archived_at": "", "uploaded_at": "2026-03-03T09:00:00+00:00",
"uploaded_by": "dane", "sha256": "ghi",
"rejected_for": ["brak wymaganych kolumn: znaczenie"]}
RULES = {"extensions": [".xlsx"], "max_size_mb": 50, "min_rows": 1,
"required_columns": ["id", "znaczenie"], "reject_duplicate_content": True}
def _client(monkeypatch, calls=None):
"""Klient z zastubowaną warstwą logiczną. `calls` zbiera to, co poszło w dół."""
from starlette.testclient import TestClient
from app.main import app, logic
calls = calls if calls is not None else []
def files_list(for_admin=False):
rows = list(REGISTRY) + ([HELD] if for_admin else [])
return {"files": rows, "rules": RULES if for_admin else {}, "root": "/x"}
monkeypatch.setattr(logic, "files_list", files_list)
for name in ("files_status", "files_upload", "files_delete", "files_rules"):
monkeypatch.setattr(logic, name,
lambda *a, _n=name, **kw: (calls.append((_n, a, kw)),
{"accepted": True, "name": "x.xlsx"})[1])
return TestClient(app), calls
# ── poziom „user" ────────────────────────────────────────────────────────
def test_user_sees_the_list_and_can_switch_bases_on_and_off(env, monkeypatch):
store.create("ula", "x", ["files"])
c, calls = _client(monkeypatch)
html = c.get("/files", headers=_auth("ula", "x")).text
assert "glowna.xlsx" in html and "stara.xlsx" in html
r = c.post("/files/use", headers=_auth("ula", "x"), follow_redirects=False,
data={"path": "glowna.xlsx", "use": "0"})
assert r.status_code == 303
assert calls and calls[0][0] == "files_status"
def test_user_gets_no_upload_no_archive_no_delete(env, monkeypatch):
store.create("ula", "x", ["files"])
c, _ = _client(monkeypatch)
html = c.get("/files", headers=_auth("ula", "x")).text
for absent in ("/files/upload", "/files/archive", "/files/delete",
"/files/restore", "/files/rules"):
assert absent not in html, f"strona zdradza {absent}"
for path in ("/files/upload", "/files/archive", "/files/delete",
"/files/restore", "/files/rules"):
r = c.post(path, headers=_auth("ula", "x"), data={"path": "glowna.xlsx"})
assert r.status_code == 404, f"{path}{r.status_code}"
# ── poziom „data_input" ──────────────────────────────────────────────────
def test_data_input_can_upload_and_archive(env, monkeypatch):
store.create("dane", "x", ["files", "files_input"])
c, calls = _client(monkeypatch)
html = c.get("/files", headers=_auth("dane", "x")).text
assert "/files/upload" in html and "/files/archive" in html
r = c.post("/files/upload", headers=_auth("dane", "x"), follow_redirects=False,
files={"upload": ("nowa.xlsx", b"zawartosc", "application/vnd.ms-excel")})
assert r.status_code == 303 and calls[-1][0] == "files_upload"
r = c.post("/files/archive", headers=_auth("dane", "x"), follow_redirects=False,
data={"path": "glowna.xlsx"})
assert r.status_code == 303
assert calls[-1][1][1] == "archived", "archiwizacja ma ustawiać stan `archived`"
def test_data_input_cannot_delete_restore_or_set_rules(env, monkeypatch):
"""Wymóg wprost: osoba wgrywająca dane może CO NAJWYŻEJ zarchiwizować."""
store.create("dane", "x", ["files", "files_input"])
c, _ = _client(monkeypatch)
html = c.get("/files", headers=_auth("dane", "x")).text
for absent in ("/files/delete", "/files/restore", "/files/rules"):
assert absent not in html, f"strona zdradza {absent}"
for path in ("/files/delete", "/files/restore", "/files/rules"):
assert c.post(path, headers=_auth("dane", "x"),
data={"path": "glowna.xlsx"}).status_code == 404
# ── własność negatywna: walidacja jest tajemnicą administratora ─────────
@pytest.mark.parametrize("perms", [["files"], ["files", "files_input"]])
def test_nobody_below_admin_learns_that_validation_exists(env, monkeypatch, perms):
store.create("ktos", "x", perms)
c, _ = _client(monkeypatch)
html = c.get("/files", headers=_auth("ktos", "x")).text
assert "podejrzana.xlsx" not in html, "plik wstrzymany nie ma prawa się pokazać"
for leak in ("walidacj", "Walidacj", "reguł", "Reguł", "znaczenie",
"brak wymaganych kolumn", "rozszerzeni", "Maksymalny rozmiar"):
assert leak not in html, f"strona zdradza mechanizm: „{leak}"
def test_refusal_to_activate_gives_no_reason_below_admin(env, monkeypatch):
"""Powód odmowy zdradzałby regułę. Komunikat ma być bez treści."""
import httpx
from app.main import app, logic
from starlette.testclient import TestClient
store.create("ula", "x", ["files"])
def boom(*a, **kw):
raise httpx.HTTPStatusError(
"422", request=httpx.Request("POST", "http://x"),
response=httpx.Response(422, json={"detail": "brak wymaganych kolumn: znaczenie"}))
monkeypatch.setattr(logic, "files_list", lambda for_admin=False: {"files": [], "rules": {}})
monkeypatch.setattr(logic, "files_status", boom)
r = TestClient(app).post("/files/use", headers=_auth("ula", "x"),
follow_redirects=False, data={"path": "x.xlsx", "use": "1"})
assert r.status_code == 303
assert "znaczenie" not in r.headers["location"]
assert "kolumn" not in r.headers["location"]
# ── administrator ────────────────────────────────────────────────────────
def test_admin_sees_held_files_with_the_reason_and_the_rules(env, monkeypatch):
c, _ = _client(monkeypatch)
html = c.get("/files", headers=_auth("szef", "tajne-szefa")).text
assert "podejrzana.xlsx" in html
assert "brak wymaganych kolumn: znaczenie" in html
assert "Reguły przyjmowania baz" in html
for op in ("/files/delete", "/files/restore", "/files/rules",
"/files/upload", "/files/archive"):
assert op in html, f"administratorowi brakuje {op}"
def test_admin_can_delete_restore_and_change_the_rules(env, monkeypatch):
c, calls = _client(monkeypatch)
admin = _auth("szef", "tajne-szefa")
c.post("/files/delete", headers=admin, follow_redirects=False, data={"path": "a.xlsx"})
c.post("/files/restore", headers=admin, follow_redirects=False, data={"path": "a.xlsx"})
c.post("/files/rules", headers=admin, follow_redirects=False,
data={"extensions": ".xlsx, .xlsm", "max_size_mb": "10", "min_rows": "5",
"required_columns": "id, znaczenie", "reject_duplicate_content": "1"})
names = [c[0] for c in calls]
assert names == ["files_delete", "files_status", "files_rules"]
sent = calls[-1][1][0]
assert sent["extensions"] == [".xlsx", ".xlsm"]
assert sent["required_columns"] == ["id", "znaczenie"]
assert sent["reject_duplicate_content"] is True
def test_archived_file_cannot_be_switched_on_from_the_page(env, monkeypatch):
"""Archiwum jest ZAMROŻONE: przełącznik przy nim w ogóle się nie pojawia,
a przywrócić może wyłącznie administrator."""
store.create("dane", "x", ["files", "files_input"])
c, _ = _client(monkeypatch)
html = c.get("/files", headers=_auth("dane", "x")).text
row = html[html.index("stara.xlsx") - 700:html.index("stara.xlsx")]
assert "/files/use" not in row, "zarchiwizowana baza ma przełącznik użycia"
def test_files_tab_is_hidden_without_the_permission(env, monkeypatch):
store.create("bez", "x", ["chart"])
c, _ = _client(monkeypatch)
html = c.get("/", headers=_auth("bez", "x")).text
assert 'href="/files"' not in html and "Pliki" not in html
assert c.get("/files", headers=_auth("bez", "x")).status_code == 404
def test_route_map_covers_every_file_route():
for route in ("/files", "/files/use", "/files/upload", "/files/archive",
"/files/restore", "/files/delete", "/files/rules"):
method = "GET" if route == "/files" else "POST"
assert (method, route) in features.ROUTES, route