From 6466ab89a9c20475cdfc44e2ab56a7187d55e720 Mon Sep 17 00:00:00 2001 From: migatu Date: Fri, 7 Aug 2026 14:39:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(dane):=20interfejs=20zarz=C4=85dzania=20pl?= =?UTF-8?q?ikami=20baz=20=E2=80=94=20trzy=20poziomy=20dost=C4=99pu=20(DAN-?= =?UTF-8?q?27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/astrololo_wymagania.xlsx | Bin 39659 -> 40264 bytes services/data/app/files.py | 321 ++++++++++++++++++ services/data/app/main.py | 79 ++++- services/data/app/providers/excel_provider.py | 24 +- services/data/tests/test_files.py | 162 +++++++++ services/logic/app/clients/data_client.py | 27 ++ services/logic/app/main.py | 72 ++++ .../presentation/app/clients/logic_client.py | 27 ++ services/presentation/app/features.py | 16 + services/presentation/app/files_state.py | 17 + services/presentation/app/main.py | 141 +++++++- services/presentation/app/static/styles.css | 15 + .../presentation/app/templates/files.html | 126 +++++++ services/presentation/tests/test_pliki.py | 215 ++++++++++++ 14 files changed, 1232 insertions(+), 10 deletions(-) create mode 100644 services/data/app/files.py create mode 100644 services/data/tests/test_files.py create mode 100644 services/presentation/app/files_state.py create mode 100644 services/presentation/app/templates/files.html create mode 100644 services/presentation/tests/test_pliki.py diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index 2f226b1c59eb64537bd4371ca2e7339987451e8d..a847a599fcdc1de67faabd78da709c9a6e4710ba 100644 GIT binary patch delta 8488 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 DTuf4s delta 7856 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 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) diff --git a/services/data/app/main.py b/services/data/app/main.py index 0ecdbda..3d3c799 100644 --- a/services/data/app/main.py +++ b/services/data/app/main.py @@ -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() diff --git a/services/data/app/providers/excel_provider.py b/services/data/app/providers/excel_provider.py index d9c156f..c44e96c 100644 --- a/services/data/app/providers/excel_provider.py +++ b/services/data/app/providers/excel_provider.py @@ -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: diff --git a/services/data/tests/test_files.py b/services/data/tests/test_files.py new file mode 100644 index 0000000..e36f421 --- /dev/null +++ b/services/data/tests/test_files.py @@ -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) == [] diff --git a/services/logic/app/clients/data_client.py b/services/logic/app/clients/data_client.py index 1bf84e8..88a0a29 100644 --- a/services/logic/app/clients/data_client.py +++ b/services/logic/app/clients/data_client.py @@ -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. diff --git a/services/logic/app/main.py b/services/logic/app/main.py index b4fd46b..3f9d61d 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -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 diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py index 7984c5b..755c106 100644 --- a/services/presentation/app/clients/logic_client.py +++ b/services/presentation/app/clients/logic_client.py @@ -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: diff --git a/services/presentation/app/features.py b/services/presentation/app/features.py index 8ac8250..103aa8f 100644 --- a/services/presentation/app/features.py +++ b/services/presentation/app/features.py @@ -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, diff --git a/services/presentation/app/files_state.py b/services/presentation/app/files_state.py new file mode 100644 index 0000000..ff93e8d --- /dev/null +++ b/services/presentation/app/files_state.py @@ -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", +} diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index 3da0e14..fece586 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -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. diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css index ddfeb9d..8c10737 100644 --- a/services/presentation/app/static/styles.css +++ b/services/presentation/app/static/styles.css @@ -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); } diff --git a/services/presentation/app/templates/files.html b/services/presentation/app/templates/files.html new file mode 100644 index 0000000..e155f51 --- /dev/null +++ b/services/presentation/app/templates/files.html @@ -0,0 +1,126 @@ +{% extends "base.html" %} +{% set nav_active = "files" %} +{% block title %}Pliki{% endblock %} + +{% block content %} +

+ Bazy interpretacyjne, z których korzysta program. Zaznaczona baza bierze udział + w wyszukiwaniu; odznaczona zostaje na dysku, ale program jej nie używa. +

+ +{% if error %}

{{ error }}

{% endif %} +{% if done %}

{{ done }}

{% endif %} + +
Bazy ({{ files | length }})
+{% if not files %} +

Nie ma jeszcze żadnego pliku bazy.

+{% endif %} + +
+ + + + + {% if can(request, 'files_input') or is_admin %}{% endif %} + + + + {% for f in files %} + + + + + + + {% if can(request, 'files_input') or is_admin %} + + {% endif %} + + {% endfor %} + +
W użyciuPlikStanRozmiarZmienionyOperacje
+ {# 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') %} +
+ + + +
+ {% else %}{% endif %} +
{{ f.name }} + {{ STATUS_LABELS.get(f.status, f.status) }} + {% if f.status == 'archived' and f.archived_at %} + ({{ f.archived_at[:16] | replace('T', ' ') }}) + {% endif %} + {% if is_admin and f.rejected_for %} +
nie przeszedł: {{ f.rejected_for | join('; ') }}
+ {% endif %} +
{{ '%.2f'|format(f.size_mb) if f.size_mb is not none else '—' }} MB{{ f.modified or '—' }} + {% if can(request, 'files_input') and f.status in ('active', 'ready') %} +
+ + +
+ {% endif %} + {% if is_admin %} + {% if f.status in ('archived', 'quarantine') %} +
+ + +
+ {% endif %} +
+ + +
+ {% endif %} +
+
+ +{% if can(request, 'files_input') %} +
Wgraj nową bazę
+ +{% 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. #} +
Reguły przyjmowania baz
+

+ Plik musi je spełnić, żeby dało się go włączyć do użytku. Plik, który ich nie + spełnia, nie jest kasowany — czeka na Twoją decyzję. +

+ +{% endif %} +{% endblock %} diff --git a/services/presentation/tests/test_pliki.py b/services/presentation/tests/test_pliki.py new file mode 100644 index 0000000..0bd3923 --- /dev/null +++ b/services/presentation/tests/test_pliki.py @@ -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