diff --git a/docs/astrololo_wymagania.xlsx b/docs/astrololo_wymagania.xlsx index 2f226b1..a847a59 100644 Binary files a/docs/astrololo_wymagania.xlsx and b/docs/astrololo_wymagania.xlsx differ diff --git a/services/data/app/files.py b/services/data/app/files.py new file mode 100644 index 0000000..5500c8c --- /dev/null +++ b/services/data/app/files.py @@ -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) 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 %} + + +{% if not files %} +Nie ma jeszcze żadnego pliku bazy.
+{% endif %} + +| W użyciu | Plik | Stan | Rozmiar | Zmieniony | + {% if can(request, 'files_input') or is_admin %}Operacje | {% endif %} +
|---|---|---|---|---|---|
| + {# 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') or is_admin %} ++ {% if can(request, 'files_input') and f.status in ('active', 'ready') %} + + {% endif %} + {% if is_admin %} + {% if f.status in ('archived', 'quarantine') %} + + {% endif %} + + {% endif %} + | + {% endif %} +
+ 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