Compare commits

..

2 Commits

Author SHA1 Message Date
gitea 003deb9404 feat(dane): sterownik Postgresa pod lustro w SQL (DAN-28)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m40s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 16s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 9s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 10m43s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 16m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 11m40s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 8m15s
Testy / Kontrola składni wszystkich warstw (push) Successful in 1m45s
build / build (push) Successful in 11m21s
Warstwa danych ma SQLAlchemy, ale nie miała czym rozmawiać z Postgresem. Bez
psycopg adres `postgresql+psycopg://…` wywala się dopiero przy PIERWSZYM
połączeniu — już na klastrze, komunikatem o braku modułu. [binary] bierze gotowe
koło, więc obraz nie kompiluje libpq.

Sam SQL_URL niczego jeszcze nie przełącza: o dostawcy decyduje DATA_PROVIDER,
które zostaje na `excel`, dopóki lustro nie jest zaimplementowane i sprawdzone.
Dzięki temu Postgresa da się wdrożyć i obejrzeć bez ryzyka dla działającego
wyszukiwania.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:16:43 +02:00
gitea 6466ab89a9 feat(dane): interfejs zarządzania plikami baz — trzy poziomy dostępu (DAN-27)
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m12s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 19s
Testy / Kontrola składni wszystkich warstw (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 12m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 16s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
Ekran „Pliki" z trzema poziomami, wpiętymi w kontrolę dostępu z PRE-27:

  „files"        widzi listę i KLIKANIEM decyduje, z których baz program korzysta,
  „files_input"  dokłada wgrywanie i ARCHIWIZACJĘ,
  administrator  kasowanie, przywracanie z archiwum i REGUŁY WALIDACJI.

STAN JEST TERAZ TRWAŁY. DAN-15 trzymał go w zmiennej DISABLED_BASES, bo warstwa
danych nie miała gdzie zapisywać — udział był montowany read-only. Skoro stan ma
być klikany, musi przetrwać restart, więc udział jest zapisywalny, a stan leży
w pliku obok baz (zapis atomowy: plik opisuje CAŁY zbiór, więc obcięcie w połowie
skasowałoby wiedzę o wszystkich naraz). DISABLED_BASES zostaje jako awaryjne
wyłączenie z konfiguracji i odsiewa DODATKOWO — nie odwrotnie, bo inaczej ktoś
z dostępem do ekranu włączyłby bazę wyłączoną świadomie na poziomie wdrożenia.

ARCHIWIZACJA NIE KASUJE. Plik zostaje na dysku, zamrożony, ze znacznikiem czasu;
znika wyłącznie z użytku. To najdalej idąca operacja osoby wgrywającej dane —
kasować może tylko administrator. Test sprawdza, że plik po archiwizacji nadal
istnieje, bo to jest cała istota tej operacji.

WALIDACJA JEST BRAMKĄ DO UŻYTKU, NIE FILTREM NA WEJŚCIU. Plik wgrany zostaje
NIEZALEŻNIE od wyniku — nie tracimy niczego, co ktoś wgrał. Zmienia się tylko to,
czy da się go włączyć. Sprawdzenie biegnie też w chwili włączania, nie tylko przy
wgrywaniu: reguły mogą się zmienić po fakcie.

O WALIDACJI WIE TYLKO ADMINISTRATOR. Pliki wstrzymane są odsiewane W WARSTWIE
DANYCH przy for_admin=False, a nie ukrywane w szablonie — gdyby dochodziły do
przeglądarki, wystarczyłby podgląd źródła, żeby poznać reguły. Odmowa włączenia
wraca do konta bez uprawnień BEZ POWODU, bo powód zdradza regułę. Sekcja reguł
nie trafia nawet do źródła strony. Test parametryzowany po obu niższych poziomach
szuka w odpowiedzi śladów mechanizmu i wymaga, żeby żadnego nie było.

Każdy plik ma policzony sha256 — tożsamość niezależna od nazwy. Wykorzystuje ją
już odrzucanie duplikatów, a w kroku drugim posłuży do pilnowania zgodności
lustra w SQL.

Przy okazji naprawiony błąd, który dopiero co bym wprowadził: Path("") to
Path("."), czyli wartość PRAWDZIWA, więc `Path(os.getenv(...)) or domyślna`
zawsze wybierało pustą zmienną i zapisywało stan do katalogu bieżącego.

Wymaga zapisywalnego udziału — osobny PR w repo deploy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:39:42 +02:00
18 changed files with 1248 additions and 129 deletions
Binary file not shown.
+3
View File
@@ -37,6 +37,9 @@ class Settings:
query_cache_ttl: int = field(default_factory=lambda: int(os.getenv("QUERY_CACHE_TTL", "300")))
# Warstwa SQL (po migracji)
# Domyślnie SQLite w cache — do testów i pracy lokalnej, bez stawiania bazy.
# Na klastrze SQL_URL wskazuje Postgresa i przychodzi z SEKRETU, bo niesie
# hasło (patrz deploy: astrololo/README-postgres.md).
sql_url: str = field(
default_factory=lambda: os.getenv("SQL_URL", "sqlite:///./.cache/astrololo.db")
)
+321
View File
@@ -0,0 +1,321 @@
"""Rejestr plików baz: stan użycia, wgrywanie, archiwizacja, walidacja (DAN-27).
CO SIĘ ZMIENIA WZGLĘDEM DAN-15. Dotąd włączanie i wyłączanie baz szło przez
zmienną `DISABLED_BASES` — deklaratywnie, bo warstwa danych nie miała gdzie
zapisywać stanu (udział read-only, cache jako emptyDir). Teraz stan jest KLIKANY,
więc musi być trwały: udział jest zapisywalny, a stan leży w pliku obok baz.
STANY PLIKU
active — bierze udział w wyszukiwaniu,
ready — sprawny, ale świadomie odstawiony; można włączyć jednym kliknięciem,
archived — ZAMROŻONY: nie bierze udziału, ma znacznik czasu archiwizacji,
sam plik zostaje nietknięty. To jedyna forma „usuwania" dostępna
osobie wgrywającej dane,
quarantine — wgrany, ale nie przeszedł walidacji. NIE JEST TRACONY; decyzję,
czy go skasować, podejmuje wyłącznie administrator.
DLACZEGO KWARANTANNA JEST NIEWIDOCZNA POZA ADMINISTRATOREM. Zasada z PRE-27 mówi,
że konto ograniczone nie ma skąd wiedzieć o mechanizmach, których nie obsługuje.
Gdyby plik w kwarantannie był widoczny z powodem odrzucenia, każdy wgrywający
poznałby reguły walidacji — a te są narzędziem administratora. Osoba wgrywająca
widzi więc plik jako „oczekuje na zatwierdzenie", bez powodu i bez reguł.
REGUŁY WALIDACJI są danymi, nie kodem: administrator ustawia je z ekranu. Trzymamy
je w tym samym pliku stanu, bo stan i reguły zmieniają się razem i muszą przetrwać
restart tak samo.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import tempfile
import threading
from datetime import datetime, timezone
from pathlib import Path
ACTIVE, READY, ARCHIVED, QUARANTINE = "active", "ready", "archived", "quarantine"
USABLE = frozenset({ACTIVE})
# Stany, o których wolno wiedzieć osobie bez uprawnień administracyjnych.
# Kwarantanna świadomie poza listą — patrz nagłówek modułu.
VISIBLE_TO_EVERYONE = frozenset({ACTIVE, READY, ARCHIVED})
_lock = threading.Lock()
DEFAULT_RULES: dict = {
"extensions": [".xlsx"],
"max_size_mb": 50,
"min_rows": 1,
"required_columns": [], # puste = bez wymagań co do nagłówków
"reject_duplicate_content": True,
}
def state_path(root: Path | str) -> Path:
"""Plik stanu — obok baz, chyba że wskazano inaczej.
Sprawdzamy NAPIS ze środowiska, nie Path(napis): Path("") to Path("."),
czyli wartość PRAWDZIWA, więc `Path(os.getenv(...)) or domyślna` zawsze
wybierało pustą zmienną i zapisywało stan do katalogu bieżącego."""
override = os.getenv("FILES_STATE", "").strip()
return Path(override) if override else Path(root) / ".files-state.json"
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def sha256_of(path: Path | str) -> str:
"""Skrót treści pliku — tożsamość pliku niezależna od nazwy.
Przyda się też krokowi drugiemu (lustro w SQL): to po nim poznamy, że plik
na dysku rozjechał się z tym, co wczytano do bazy."""
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
# ── stan ─────────────────────────────────────────────────────────────────
def _read_state(root: Path) -> dict:
try:
with open(state_path(root), encoding="utf-8") as fh:
data = json.load(fh)
except (FileNotFoundError, json.JSONDecodeError):
data = {}
files = data.get("files")
rules = data.get("rules")
return {
"files": files if isinstance(files, dict) else {},
"rules": {**DEFAULT_RULES, **(rules if isinstance(rules, dict) else {})},
}
def _write_state(root: Path, data: dict) -> None:
path = state_path(root)
path.parent.mkdir(parents=True, exist_ok=True)
# Atomowo: plik stanu opisuje CAŁY zbiór baz, więc obcięcie go w połowie
# zapisu skasowałoby wiedzę o wszystkich naraz.
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=1, sort_keys=True)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, path)
except BaseException:
Path(tmp).unlink(missing_ok=True)
raise
def rules(root: Path | str) -> dict:
return _read_state(Path(root))["rules"]
def set_rules(root: Path | str, new: dict) -> dict:
root = Path(root)
with _lock:
data = _read_state(root)
merged = {**data["rules"]}
for key, value in (new or {}).items():
if key in DEFAULT_RULES:
merged[key] = value
data["rules"] = merged
_write_state(root, data)
return merged
# ── walidacja ────────────────────────────────────────────────────────────
def validate(path: Path | str, root: Path | str, *, digest: str = "",
known_digests: dict[str, str] | None = None) -> list[str]:
"""Lista POWODÓW odrzucenia. Pusta lista = plik nadaje się do użytku.
Zwracamy powody, a nie samo „tak/nie", bo administrator ma zobaczyć, CZEGO
plikowi brakuje — inaczej poprawianie bazy byłoby zgadywanką. Poza konto
administracyjne ta lista nie wychodzi."""
p, rs = Path(path), rules(root)
why: list[str] = []
exts = [str(e).lower() for e in rs.get("extensions") or []]
if exts and p.suffix.lower() not in exts:
why.append(f"rozszerzenie {p.suffix or '(brak)'} spoza dozwolonych: {', '.join(exts)}")
try:
size_mb = p.stat().st_size / (1024 * 1024)
except OSError:
return why + ["pliku nie da się odczytać"]
cap = float(rs.get("max_size_mb") or 0)
if cap and size_mb > cap:
why.append(f"rozmiar {size_mb:.1f} MB przekracza limit {cap:g} MB")
if rs.get("reject_duplicate_content") and known_digests:
digest = digest or sha256_of(p)
twin = next((name for name, d in known_digests.items()
if d == digest and name != p.name), None)
if twin:
why.append(f"treść identyczna z plikiem „{twin}")
required = [str(c).strip() for c in (rs.get("required_columns") or []) if str(c).strip()]
min_rows = int(rs.get("min_rows") or 0)
if required or min_rows:
why += _inspect_workbook(p, required, min_rows)
return why
def _inspect_workbook(path: Path, required: list[str], min_rows: int) -> list[str]:
"""Zagląda do arkusza: nagłówki i liczba wierszy.
read_only + tylko pierwszy arkusz — plik bazy potrafi mieć kilkadziesiąt MB,
a wczytanie go w całości przy każdym wgraniu zatkałoby usługę."""
try:
import openpyxl
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
except Exception as e: # noqa: BLE001 — każdy błąd = powód
return [f"nie udało się otworzyć arkusza ({type(e).__name__})"]
why: list[str] = []
try:
ws = wb[wb.sheetnames[0]]
rows = ws.iter_rows(values_only=True)
header = [str(c).strip().lower() for c in (next(rows, ()) or ()) if c is not None]
missing = [c for c in required if c.strip().lower() not in header]
if missing:
why.append(f"brak wymaganych kolumn: {', '.join(missing)}")
if min_rows:
seen = sum(1 for i, _ in enumerate(rows) if i < min_rows)
if seen < min_rows:
why.append(f"za mało wierszy danych ({seen} < {min_rows})")
finally:
wb.close()
return why
# ── rejestr ──────────────────────────────────────────────────────────────
def _scan(root: Path) -> list[Path]:
return [p for p in sorted(root.glob("**/*"))
if p.is_file() and not p.name.startswith((".", "~$"))]
def registry(root: Path | str, *, for_admin: bool = False) -> list[dict]:
"""Pliki na udziale wraz ze stanem. `for_admin` odsłania kwarantannę i powody.
Filtrowanie siedzi TUTAJ, a nie w szablonie: gdyby pliki w kwarantannie
dochodziły do przeglądarki i były tylko ukrywane stylem, wystarczyłby podgląd
źródła strony, żeby poznać reguły walidacji."""
root = Path(root)
data = _read_state(root)
out: list[dict] = []
for p in _scan(root):
rel = str(p.relative_to(root))
row = data["files"].get(rel, {})
status = row.get("status") or READY
if status == QUARANTINE and not for_admin:
continue
try:
st = p.stat()
size_mb = round(st.st_size / (1024 * 1024), 2)
modified = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).strftime("%Y-%m-%d")
except OSError:
size_mb, modified = None, None
entry = {
"name": p.name, "path": rel, "size_mb": size_mb, "modified": modified,
"status": status, "in_use": status in USABLE,
"archived_at": row.get("archived_at") or "",
"uploaded_at": row.get("uploaded_at") or "",
"uploaded_by": row.get("uploaded_by") or "",
"sha256": row.get("sha256") or "",
}
if for_admin:
entry["rejected_for"] = list(row.get("rejected_for") or [])
out.append(entry)
return out
def usable_paths(root: Path | str) -> list[str]:
"""Ścieżki baz, które FAKTYCZNIE biorą udział w wyszukiwaniu."""
root = Path(root)
return [str(root / e["path"]) for e in registry(root, for_admin=True) if e["in_use"]]
def _touch(root: Path, rel: str, **fields) -> dict:
with _lock:
data = _read_state(root)
row = {**data["files"].get(rel, {}), **fields}
data["files"][rel] = row
_write_state(root, data)
return row
def set_status(root: Path | str, rel: str, status: str, *, by: str = "") -> dict:
"""Zmienia stan pliku. Włączyć do użytku można TYLKO plik, który przeszedł
walidację — to jest właśnie ta bramka, o której mowa w wymaganiu."""
root = Path(root)
target = root / rel
if not target.is_file():
raise ValueError(f"Nie ma pliku „{rel}”.")
if status not in {ACTIVE, READY, ARCHIVED, QUARANTINE}:
raise ValueError(f"Nieznany stan: {status}")
data = _read_state(root)
current = (data["files"].get(rel) or {}).get("status") or READY
if status == ACTIVE:
if current == QUARANTINE:
raise ValueError("Plik nie może trafić do użytku.")
known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True) if e["sha256"]}
why = validate(target, root, known_digests=known)
if why:
_touch(root, rel, status=QUARANTINE, rejected_for=why, checked_at=_now())
raise ValueError("Plik nie może trafić do użytku.")
fields = {"status": status, "changed_at": _now(), "changed_by": by}
if status == ARCHIVED:
# Znacznik czasu archiwizacji to wymóg: „zamrożona forma z timestampem".
fields["archived_at"] = _now()
elif status == ACTIVE:
fields["archived_at"] = ""
fields["rejected_for"] = []
return _touch(root, rel, **fields)
def store_upload(root: Path | str, filename: str, content: bytes, *, by: str = "") -> dict:
"""Zapisuje wgrany plik i od razu go sprawdza.
Plik zostaje NIEZALEŻNIE od wyniku walidacji — nie tracimy niczego, co ktoś
wgrał. Zmienia się tylko to, czy da się go włączyć do użytku."""
root = Path(root)
safe = re.sub(r"[^A-Za-z0-9._ -]", "_", Path(filename or "").name).strip() or "plik"
target = root / safe
stem, suffix, n = Path(safe).stem, Path(safe).suffix, 1
while target.exists(): # nie nadpisujemy cudzej bazy
target = root / f"{stem}-{n}{suffix}"
n += 1
root.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
rel = str(target.relative_to(root))
digest = sha256_of(target)
known = {e["path"]: e["sha256"] for e in registry(root, for_admin=True)
if e["sha256"] and e["path"] != rel}
why = validate(target, root, digest=digest, known_digests=known)
_touch(root, rel, status=QUARANTINE if why else READY, rejected_for=why,
sha256=digest, uploaded_at=_now(), uploaded_by=by, checked_at=_now())
return {"path": rel, "name": target.name, "accepted": not why}
def delete(root: Path | str, rel: str) -> None:
"""Nieodwracalne skasowanie pliku — wyłącznie dla administratora."""
root = Path(root)
target = root / rel
if not target.is_file():
raise ValueError(f"Nie ma pliku „{rel}”.")
target.unlink()
with _lock:
data = _read_state(root)
data["files"].pop(rel, None)
_write_state(root, data)
+77 -2
View File
@@ -7,13 +7,16 @@ ani prezentacji.
# build-marker: 2026-07-25 wymuszenie nowego obrazu po incydencie z tagiem :latest
from __future__ import annotations
import base64
import binascii
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, HTTPException
from app import canary, link_crypto, security
from app import canary, files, link_crypto, security
from app.config import settings
from app.models import HealthInfo, SearchQuery, SearchResult
from pydantic import BaseModel
from app.providers.factory import build_provider
provider = build_provider(settings)
@@ -54,6 +57,78 @@ def bases() -> dict:
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
# ── zarządzanie plikami baz (DAN-27) ─────────────────────────────────────
# Warstwa danych jest właścicielem plików, więc to ona nimi zarządza. Uprawnienia
# rozstrzyga PREZENTACJA (PRE-27) i przekazuje tu wynik jako `for_admin` / `by` —
# ta warstwa nie zna kont i nie ma jak ich znać. Nie jest to dziura: warstwa
# danych stoi za tokenem międzywarstwowym i szyfrowanym łączem, więc rozmawia
# z nią wyłącznie warstwa logiczna.
class FilesQuery(BaseModel):
for_admin: bool = False
class FileAction(BaseModel):
path: str
status: str = ""
by: str = ""
class FileUpload(BaseModel):
filename: str
content_b64: str
by: str = ""
class RulesUpdate(BaseModel):
rules: dict
@app.post("/files")
def files_list(q: FilesQuery) -> dict:
"""Rejestr plików. Kwarantanna WYCHODZI stąd tylko przy for_admin — filtrujemy
u źródła, żeby nie dało się jej odczytać z podglądu źródła strony."""
root = settings.excel_dir
return {"files": files.registry(root, for_admin=q.for_admin),
"rules": files.rules(root) if q.for_admin else {},
"root": str(root)}
@app.post("/files/status")
def files_status(a: FileAction) -> dict:
try:
row = files.set_status(settings.excel_dir, a.path, a.status, by=a.by)
except ValueError as e:
raise HTTPException(422, str(e)) from e
return {"path": a.path, "status": row.get("status")}
@app.post("/files/upload")
def files_upload(u: FileUpload) -> dict:
"""Plik wędruje w base64 wewnątrz zaszyfrowanego łącza — tym samym kanałem,
co reszta ruchu międzywarstwowego. Osobny, nieszyfrowany kanał na pliki
byłby obejściem PRE-16."""
try:
raw = base64.b64decode(u.content_b64, validate=True)
except (binascii.Error, ValueError) as e:
raise HTTPException(422, "Nieczytelna zawartość pliku.") from e
return files.store_upload(settings.excel_dir, u.filename, raw, by=u.by)
@app.post("/files/delete")
def files_delete(a: FileAction) -> dict:
try:
files.delete(settings.excel_dir, a.path)
except ValueError as e:
raise HTTPException(422, str(e)) from e
return {"deleted": a.path}
@app.post("/files/rules")
def files_rules(u: RulesUpdate) -> dict:
return {"rules": files.set_rules(settings.excel_dir, u.rules)}
@app.get("/health", response_model=HealthInfo)
def health() -> HealthInfo:
return provider.health()
+17 -7
View File
@@ -92,20 +92,30 @@ class ExcelDataProvider(DataProvider):
return [str(p) for p in sorted(base.glob("**/*.xlsx")) if not p.name.startswith("~$")]
def _enabled_files(self, paths: list[str]) -> list[str]:
"""Odsiewa bazy WYŁĄCZONE globalnie (DAN-15) — nie biorą udziału
w interpretacji, choć fizycznie leżą na udziale."""
from app import bases
"""Bazy biorące udział w wyszukiwaniu.
Źródłem prawdy jest REJESTR PLIKÓW (DAN-27) — stan klikany z ekranu,
trwały na udziale. Zmienna DISABLED_BASES z DAN-15 zostaje jako awaryjne
wyłączenie z konfiguracji: gdy jest ustawiona, odsiewa DODATKOWO. Nie
odwrotnie — inaczej ktoś z dostępem do ekranu mógłby włączyć bazę
wyłączoną świadomie na poziomie wdrożenia.
"""
from app import files
usable = set(files.usable_paths(self.s.excel_dir))
out = [p for p in paths if p in usable]
entries = bases.disabled_entries()
if not entries:
return paths
return [p for p in paths if bases.is_enabled(p, self.s.excel_dir, entries)]
if entries:
out = [p for p in out if bases.is_enabled(p, self.s.excel_dir, entries)]
return out
def list_bases(self) -> list[dict]:
"""Bazy dostępne na udziale + metaopis + stan włączenia (DAN-15/PRE-09)."""
from app import bases
return bases.list_bases(self.s.excel_dir, self._excel_files())
from app import files
return files.registry(self.s.excel_dir, for_admin=True)
# ---- publiczne API ----
def search(self, query: SearchQuery) -> SearchResult:
+5
View File
@@ -6,6 +6,11 @@ pandas>=2.2
openpyxl>=3.1
pyarrow>=18.0
SQLAlchemy>=2.0
# Sterownik Postgresa (DAN-28). Sam SQLAlchemy nie rozmawia z bazą — bez tego
# `postgresql+psycopg://…` wywala się dopiero przy PIERWSZYM połączeniu, już na
# klastrze, komunikatem o braku modułu. [binary] = gotowe koło, bez kompilacji
# libpq w obrazie.
psycopg[binary]>=3.2
pydantic>=2.10
# Szyfrowanie łącza między warstwami (PRE-16): AES-256-GCM + HKDF
cryptography>=44.0
+162
View File
@@ -0,0 +1,162 @@
"""Rejestr plików baz: stany, walidacja, archiwizacja (DAN-27).
Testujemy tu RDZEŃ — bez HTTP i bez uprawnień, bo uprawnienia rozstrzyga
prezentacja (patrz services/presentation/tests/test_pliki.py). Tutaj chodzi
o to, żeby żadna operacja nie gubiła pliku i żeby bramka „do użytku tylko po
walidacji" faktycznie trzymała.
"""
import pathlib
import pytest
from app import files
@pytest.fixture()
def root(tmp_path, monkeypatch):
monkeypatch.delenv("FILES_STATE", raising=False)
return tmp_path
def _xlsx(path, rows=3, header=("id", "opis")):
import openpyxl
wb = openpyxl.Workbook()
ws = wb.active
ws.append(list(header))
for i in range(rows):
ws.append([i, f"treść {i}"])
wb.save(path)
return path
# ── rejestr i stany ──────────────────────────────────────────────────────
def test_new_file_is_visible_but_not_in_use(root):
_xlsx(root / "baza.xlsx")
entry = files.registry(root)[0]
assert entry["status"] == files.READY
assert entry["in_use"] is False, "nowy plik nie może sam wejść do wyszukiwania"
def test_only_active_files_reach_the_search(root):
_xlsx(root / "a.xlsx")
_xlsx(root / "b.xlsx")
assert files.usable_paths(root) == []
files.set_status(root, "a.xlsx", files.ACTIVE)
assert [pathlib.Path(p).name for p in files.usable_paths(root)] == ["a.xlsx"]
def test_state_survives_a_restart(root):
"""Stan jest KLIKANY, więc musi być trwały — inaczej restart poda po cichu
przywracałby bazy wyłączone świadomie."""
_xlsx(root / "a.xlsx")
files.set_status(root, "a.xlsx", files.ACTIVE)
assert files.state_path(root).exists()
assert files.registry(root)[0]["in_use"] is True
# ── archiwizacja ─────────────────────────────────────────────────────────
def test_archiving_freezes_the_file_but_never_removes_it(root):
"""To jest najdalej idąca operacja osoby wgrywającej dane: plik ZOSTAJE."""
p = _xlsx(root / "stara.xlsx")
files.set_status(root, "stara.xlsx", files.ACTIVE)
files.set_status(root, "stara.xlsx", files.ARCHIVED, by="dane")
entry = files.registry(root)[0]
assert p.exists(), "plik zniknął z dysku — archiwizacja ma go zachować"
assert entry["status"] == files.ARCHIVED
assert entry["in_use"] is False
assert entry["archived_at"], "brak znacznika czasu archiwizacji"
def test_archived_file_cannot_slip_back_into_use_by_itself(root):
_xlsx(root / "stara.xlsx")
files.set_status(root, "stara.xlsx", files.ARCHIVED)
assert files.usable_paths(root) == []
# ── walidacja: bramka do użytku ──────────────────────────────────────────
def test_upload_keeps_a_file_that_fails_validation(root):
"""Rzecz najważniejsza: wgranego pliku NIE TRACIMY, choćby nie przeszedł."""
files.set_rules(root, {"extensions": [".xlsx"]})
out = files.store_upload(root, "notatka.txt", "to nie jest baza".encode("utf-8"), by="dane")
assert out["accepted"] is False
assert (root / out["path"]).exists(), "plik odrzucony zniknął z dysku"
admin_view = files.registry(root, for_admin=True)[0]
assert admin_view["status"] == files.QUARANTINE
assert admin_view["rejected_for"], "administrator ma widzieć powód"
def test_a_held_file_is_invisible_without_admin_rights(root):
"""Gdyby plik wstrzymany był widoczny z powodem odrzucenia, każdy wgrywający
poznałby reguły walidacji — a te są narzędziem administratora."""
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
assert files.registry(root, for_admin=False) == []
assert len(files.registry(root, for_admin=True)) == 1
def test_a_held_file_cannot_be_switched_into_use(root):
files.store_upload(root, "notatka.txt", "nie baza".encode("utf-8"))
rel = files.registry(root, for_admin=True)[0]["path"]
with pytest.raises(ValueError):
files.set_status(root, rel, files.ACTIVE)
def test_activation_revalidates_and_holds_a_file_that_stopped_qualifying(root):
"""Reguły mogą się zmienić PO wgraniu — bramka sprawdza w chwili włączania,
a nie tylko przy wgrywaniu."""
_xlsx(root / "mala.xlsx", rows=2)
files.set_status(root, "mala.xlsx", files.ACTIVE)
files.set_rules(root, {"min_rows": 500})
files.set_status(root, "mala.xlsx", files.READY)
with pytest.raises(ValueError):
files.set_status(root, "mala.xlsx", files.ACTIVE)
assert (root / "mala.xlsx").exists()
@pytest.mark.parametrize("rule,value,bad", [
("extensions", [".xlsx"], "plik.csv"),
("max_size_mb", 0.000001, "plik.xlsx"),
])
def test_rules_reject_what_they_are_meant_to(root, rule, value, bad):
files.set_rules(root, {rule: value})
out = files.store_upload(root, bad, b"x" * 2048)
assert out["accepted"] is False
def test_required_columns_are_checked_inside_the_workbook(root):
files.set_rules(root, {"required_columns": ["id", "znaczenie"]})
_xlsx(root / "tmp.xlsx", header=("id", "opis"))
why = files.validate(root / "tmp.xlsx", root)
assert why and "znaczenie" in why[0]
def test_duplicate_content_is_rejected_by_hash_not_by_name(root):
files.set_rules(root, {"reject_duplicate_content": True})
data = _xlsx(root / "wzor.xlsx").read_bytes()
first = files.store_upload(root, "pierwsza.xlsx", data)
assert first["accepted"] is True
second = files.store_upload(root, "inna-nazwa.xlsx", data)
assert second["accepted"] is False
def test_upload_never_overwrites_someone_elses_base(root):
files.store_upload(root, "baza.xlsx", _xlsx(root / "w.xlsx").read_bytes())
(root / "w.xlsx").unlink()
files.set_rules(root, {"reject_duplicate_content": False})
out = files.store_upload(root, "baza.xlsx", "inna treść".encode("utf-8"))
assert out["name"] != "baza.xlsx"
assert (root / "baza.xlsx").exists() and (root / out["path"]).exists()
# ── kasowanie ────────────────────────────────────────────────────────────
def test_delete_removes_the_file_and_its_entry(root):
_xlsx(root / "a.xlsx")
files.set_status(root, "a.xlsx", files.ACTIVE)
files.delete(root, "a.xlsx")
assert not (root / "a.xlsx").exists()
assert files.registry(root, for_admin=True) == []
assert files.usable_paths(root) == []
+27
View File
@@ -51,6 +51,33 @@ class DataClient:
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
headers=_auth_headers(), link=_link())
# ── zarządzanie plikami baz (DAN-27) ────────────────────────────────
# Jedna metoda na trasę, bez sprytnego generyka: te wywołania różnią się
# skutkiem (odczyt / zapis / skasowanie), a ujednolicenie ich w jedno
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
def files_list(self, for_admin: bool = False) -> dict[str, Any]:
return self._files_post("/files", {"for_admin": for_admin})
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/status", {"path": path, "status": status, "by": by})
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/upload",
{"filename": filename, "content_b64": content_b64, "by": by})
def files_delete(self, path: str) -> dict[str, Any]:
return self._files_post("/files/delete", {"path": path})
def files_rules(self, rules: dict) -> dict[str, Any]:
return self._files_post("/files/rules", {"rules": rules})
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
payload=payload, headers=_auth_headers(), link=_link())
def health(self) -> dict[str, Any]:
# /health celowo poza szyfrowaniem — pukają tu sondy k8s, które klucza
# nie mają, a nie przechodzi tędy nic z baz.
+72
View File
@@ -121,6 +121,78 @@ def bases() -> dict:
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
# ── zarządzanie plikami baz (DAN-27) — czysty przelot ───────────────────
# Warstwa logiczna niczego tu nie rozstrzyga: uprawnienia zna PREZENTACJA
# (PRE-27), właścicielem plików jest warstwa DANYCH. Ta warstwa tylko przenosi,
# bo prezentacja nie ma prawa rozmawiać z danymi wprost.
class FilesQuery(BaseModel):
for_admin: bool = False
class FileAction(BaseModel):
path: str
status: str = ""
by: str = ""
class FileUpload(BaseModel):
filename: str
content_b64: str
by: str = ""
class RulesUpdate(BaseModel):
rules: dict
def _files_call(fn, *args, **kw) -> dict:
from app.clients.data_client import DataClient
try:
return fn(DataClient(), *args, **kw)
except httpx.HTTPStatusError as e:
# 422 z warstwy danych to ODMOWA MERYTORYCZNA (np. plik nie przeszedł
# walidacji), nie awaria — ma dojść do prezentacji jako 422, żeby dało
# się pokazać powód zamiast „usługa niedostępna".
raise HTTPException(status_code=e.response.status_code,
detail=_detail(e)) from e
except httpx.HTTPError as e:
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
def _detail(e: httpx.HTTPStatusError) -> str:
try:
return str(e.response.json().get("detail") or e)
except Exception: # noqa: BLE001
return str(e)
@app.post("/files")
def files_list(q: FilesQuery) -> dict:
return _files_call(lambda c: c.files_list(q.for_admin))
@app.post("/files/status")
def files_status(a: FileAction) -> dict:
return _files_call(lambda c: c.files_status(a.path, a.status, a.by))
@app.post("/files/upload")
def files_upload(u: FileUpload) -> dict:
return _files_call(lambda c: c.files_upload(u.filename, u.content_b64, u.by))
@app.post("/files/delete")
def files_delete(a: FileAction) -> dict:
return _files_call(lambda c: c.files_delete(a.path))
@app.post("/files/rules")
def files_rules(u: RulesUpdate) -> dict:
return _files_call(lambda c: c.files_rules(u.rules))
@app.post("/chart/synastry")
def chart_synastry(req: SynastryRequest) -> dict:
"""Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta
+4 -49
View File
@@ -28,16 +28,6 @@ from app import features
_lock = threading.Lock()
class AccountsUnavailable(RuntimeError):
"""Pliku kont nie da się odczytać albo zapisać — problem z magazynem.
Osobny wyjątek, bo to NIE JEST błąd danych: konta mogą być w porządku, tylko
katalog jest niedostępny (złe prawa na udziale, nieprzemontowany wolumen,
brak miejsca). Ekran ma wtedy POWIEDZIEĆ, co i gdzie jest nie tak — gołe 500
zostawia administratora z niczym, a to jedyny ekran, z którego może to
naprawić."""
def store_path() -> str:
"""Ścieżka pliku kont.
@@ -48,62 +38,27 @@ def store_path() -> str:
def _read() -> dict:
path = store_path()
try:
with open(path, encoding="utf-8") as fh:
with open(store_path(), encoding="utf-8") as fh:
data = json.load(fh)
except FileNotFoundError:
# Pierwsze uruchomienie: pliku jeszcze nie ma i to jest normalne.
except (FileNotFoundError, json.JSONDecodeError):
return {"users": {}}
except json.JSONDecodeError:
# Plik jest, ale nieczytelny. NIE nadpisujemy go pustym zbiorem —
# to skasowałoby wszystkie konta przy pierwszym zapisie.
raise AccountsUnavailable(
f"Plik kont {path} jest uszkodzony (niepoprawny JSON). "
f"Nic nie zostało zmienione — napraw albo usuń plik ręcznie.") from None
except OSError as e:
raise AccountsUnavailable(
f"Nie mogę odczytać pliku kont {path}: {e.strerror or e}. "
f"Najczęstsza przyczyna: prawa do katalogu na udziale albo "
f"niezamontowany wolumen.") from e
users = data.get("users")
return {"users": users if isinstance(users, dict) else {}}
def _write(data: dict) -> None:
path = store_path()
try:
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
except OSError as e:
raise AccountsUnavailable(
f"Nie mogę utworzyć katalogu na plik kont ({os.path.dirname(path)}): "
f"{e.strerror or e}.") from e
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
# Plik tymczasowy MUSI powstać w tym samym katalogu — os.replace jest
# niepodzielne tylko w obrębie jednego systemu plików.
# mkstemp też musi być w klamrze: przy katalogu tylko do odczytu wywala się
# ONO pierwsze, jeszcze zanim dojdzie do zapisu i podmiany.
try:
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".", suffix=".tmp")
except OSError as e:
raise AccountsUnavailable(
f"Nie mogę zapisać pliku kont {path}: {e.strerror or e}. "
f"Najczęstsza przyczyna: udział zamontowany tylko do odczytu albo "
f"prawa katalogu. Nic nie zostało zmienione.") from e
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path) or ".", 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 OSError as e:
try:
os.unlink(tmp)
except OSError:
pass
raise AccountsUnavailable(
f"Nie mogę zapisać pliku kont {path}: {e.strerror or e}. "
f"Najczęstsza przyczyna: udział zamontowany tylko do odczytu albo "
f"prawa katalogu. Nic nie zostało zmienione.") from e
except BaseException:
try:
os.unlink(tmp)
@@ -158,6 +158,33 @@ class LogicClient:
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
headers=_auth_headers(), link=_link())
# ── zarządzanie plikami baz (DAN-27) ────────────────────────────────
# Jedna metoda na trasę, bez sprytnego generyka: te wywołania różnią się
# skutkiem (odczyt / zapis / skasowanie), a ujednolicenie ich w jedno
# `call(path, payload)` zaciera tę różnicę dokładnie tam, gdzie jest ważna.
def files_list(self, for_admin: bool = False) -> dict[str, Any]:
return self._files_post("/files", {"for_admin": for_admin})
def files_status(self, path: str, status: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/status", {"path": path, "status": status, "by": by})
def files_upload(self, filename: str, content_b64: str, by: str = "") -> dict[str, Any]:
return self._files_post("/files/upload",
{"filename": filename, "content_b64": content_b64, "by": by})
def files_delete(self, path: str) -> dict[str, Any]:
return self._files_post("/files/delete", {"path": path})
def files_rules(self, rules: dict) -> dict[str, Any]:
return self._files_post("/files/rules", {"rules": rules})
def _files_post(self, path: str, payload: dict) -> dict[str, Any]:
with httpx.Client(timeout=settings.http_timeout) as client:
return link_crypto.call_json(client, "POST", f"{self.base_url}{path}",
payload=payload, headers=_auth_headers(), link=_link())
def llm_models(self) -> dict[str, Any]:
"""Katalog modeli per dostawca (podpowiedzi do pola wyboru w UI)."""
with httpx.Client(timeout=settings.http_timeout) as client:
+16
View File
@@ -41,6 +41,7 @@ SCREENS: tuple[Feature, ...] = (
Feature("synastry", "Synastria", "Porównanie dwóch horoskopów.", "Ekrany", "/synastry"),
Feature("significators", "Sygnifikatory", "Wyszukiwarka po bazach interpretacyjnych.", "Ekrany", "/significators"),
Feature("compile", "Skompiluj", "Złożenie raportu z policzonych części.", "Ekrany", "/compile"),
Feature("files", "Pliki", "Wybór baz, z których korzysta program.", "Ekrany", "/files"),
Feature("settings", "Ustawienia", "Podgląd baz i konfiguracji modelu.", "Ekrany", "/settings"),
)
@@ -57,6 +58,10 @@ EXTRAS: tuple[Feature, ...] = (
Feature("ai", "Generowanie tekstu przez model",
"Horoskopy pisane przez model językowy. UWAGA: każde użycie kosztuje.",
"Rozszerzenia"),
Feature("files_input", "Wgrywanie i archiwizacja baz",
"Dodawanie nowych plików baz i wycofywanie ich z użytku (plik zostaje, "
"zamrożony, ze znacznikiem czasu). Kasować może wyłącznie administrator.",
"Rozszerzenia"),
Feature("export", "Eksport plików",
"Pobieranie raportu jako PDF i wyników jako Excel.", "Rozszerzenia"),
)
@@ -84,6 +89,17 @@ ROUTES: dict[tuple[str, str], str | None] = {
("POST", "/compile"): "compile",
("POST", "/compile/pdf"): "export",
("GET", "/settings"): "settings",
# Zarządzanie plikami baz (DAN-27). Trzy poziomy: „files" wybiera, z czego
# program korzysta; „files_input" dokłada wgrywanie i archiwizację;
# kasowanie, przywracanie i REGUŁY WALIDACJI to wyłącznie administrator —
# o istnieniu walidacji nikt poza nim nie ma skąd wiedzieć.
("GET", "/files"): "files",
("POST", "/files/use"): "files",
("POST", "/files/upload"): "files_input",
("POST", "/files/archive"): "files_input",
("POST", "/files/restore"): ADMIN,
("POST", "/files/delete"): ADMIN,
("POST", "/files/rules"): ADMIN,
("POST", "/horoscope/stream"): "ai",
("GET", "/accounts"): ADMIN,
("POST", "/accounts/create"): ADMIN,
+17
View File
@@ -0,0 +1,17 @@
"""Nazwy stanów pliku bazy — wspólne dla prezentacji i warstwy danych (DAN-27).
Osobny, króciutki moduł, bo prezentacja NIE MOŻE importować warstwy danych (to
inna usługa i inny obraz), a wpisanie tych napisów wprost w handlerach skończyłoby
się literówką, która przejdzie testy i wyjdzie dopiero na produkcji.
"""
ACTIVE = "active"
READY = "ready"
ARCHIVED = "archived"
QUARANTINE = "quarantine"
LABELS = {
ACTIVE: "w użyciu",
READY: "gotowa, odstawiona",
ARCHIVED: "zarchiwizowana",
QUARANTINE: "wstrzymana",
}
+144 -12
View File
@@ -17,14 +17,17 @@ from functools import lru_cache
from pathlib import Path
import httpx
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
import base64
from app import accounts as accounts_store
from app import chartwheel as chartwheel_mod
from app import features
from app import files_state
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
from app import geocode, security
@@ -69,6 +72,7 @@ templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
templates.env.globals["house_label"] = house_label
templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
templates.env.globals["STATUS_LABELS"] = files_state.LABELS
def _perms(request: Request) -> frozenset[str]:
@@ -672,20 +676,148 @@ 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.
def _accounts_context(request: Request, error: str = "", done: str = "") -> dict:
# Problem z magazynem NIE MOŻE dawać gołego 500: to jedyny ekran, z którego
# administrator może go naprawić, więc musi na nim przeczytać, co i gdzie
# jest nie tak.
try:
users = accounts_store.all_users()
except accounts_store.AccountsUnavailable as e:
users, error = {}, str(e)
return {
"users": users,
"users": accounts_store.all_users(),
"catalog": features.ALL,
"screens": features.SCREENS,
"extras": features.EXTRAS,
@@ -715,7 +847,7 @@ def accounts_create(request: Request, login: str = Form(""), password: str = For
note: str = Form(""), granted: list[str] = Form([])):
try:
accounts_store.create(login, password, granted, note)
except (ValueError, accounts_store.AccountsUnavailable) as e:
except ValueError as e:
return _accounts_redirect(error=str(e))
return _accounts_redirect(done=f"Założono konto „{login.strip()}”.")
@@ -725,7 +857,7 @@ def accounts_update(request: Request, login: str = Form(...), password: str = Fo
note: str = Form(""), granted: list[str] = Form([])):
try:
accounts_store.update(login, granted=granted, password=password, note=note)
except (ValueError, accounts_store.AccountsUnavailable) as e:
except ValueError as e:
return _accounts_redirect(error=str(e))
changed = "uprawnienia i hasło" if password.strip() else "uprawnienia"
return _accounts_redirect(done=f"Zapisano {changed} konta „{login}”.")
@@ -735,7 +867,7 @@ def accounts_update(request: Request, login: str = Form(...), password: str = Fo
def accounts_delete(request: Request, login: str = Form(...)):
try:
accounts_store.delete(login)
except (ValueError, accounts_store.AccountsUnavailable) as e:
except ValueError as e:
return _accounts_redirect(error=str(e))
return _accounts_redirect(done=f"Skasowano konto „{login}”.")
@@ -247,3 +247,18 @@ button.danger { background: #8b2f2f; }
border: 1px solid var(--line); border-left: 4px solid var(--accent);
border-radius: 4px; background: rgba(255, 255, 255, .03);
}
/* ── ekran plików (DAN-27) ──────────────────────────────────────────── */
form.inline { display: inline; }
td.ops form.inline + form.inline { margin-left: .4rem; }
button.toggle {
background: none; border: 1px solid var(--line); border-radius: 50%;
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
}
button.toggle.on { color: var(--accent); border-color: var(--accent); }
button.toggle.off { color: var(--muted); }
tr.row-archived td { opacity: .55; }
/* Wstrzymane widzi tylko administrator dla reszty tych wierszy nie ma
w ogóle w odpowiedzi, więc ten styl nigdy nie dotyczy ich strony. */
tr.row-held td { background: rgba(184, 134, 11, .07); }
@@ -0,0 +1,126 @@
{% extends "base.html" %}
{% set nav_active = "files" %}
{% block title %}Pliki{% endblock %}
{% block content %}
<p class="muted">
Bazy interpretacyjne, z których korzysta program. Zaznaczona baza bierze udział
w wyszukiwaniu; odznaczona zostaje na dysku, ale program jej nie używa.
</p>
{% if error %}<p class="house-warning">{{ error }}</p>{% endif %}
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
<div class="meta">Bazy ({{ files | length }})</div>
{% if not files %}
<p class="muted">Nie ma jeszcze żadnego pliku bazy.</p>
{% endif %}
<div class="table-scroll">
<table>
<thead>
<tr>
<th>W użyciu</th><th>Plik</th><th>Stan</th><th>Rozmiar</th><th>Zmieniony</th>
{% if can(request, 'files_input') or is_admin %}<th>Operacje</th>{% endif %}
</tr>
</thead>
<tbody>
{% for f in files %}
<tr class="{{ 'row-archived' if f.status == 'archived' else '' }}{{ ' row-held' if f.status == 'quarantine' else '' }}">
<td>
{# Archiwum jest ZAMROŻONE — nie da się go włączyć bez przywrócenia,
a przywrócić może wyłącznie administrator. #}
{% if f.status in ('active', 'ready') %}
<form method="post" action="/files/use" class="inline">
<input type="hidden" name="path" value="{{ f.path }}">
<input type="hidden" name="use" value="{{ '0' if f.in_use else '1' }}">
<button type="submit" class="toggle {{ 'on' if f.in_use else 'off' }}"
title="{{ 'Odstaw tę bazę' if f.in_use else 'Włącz tę bazę do użytku' }}">
{{ '●' if f.in_use else '○' }}
</button>
</form>
{% else %}<span class="muted"></span>{% endif %}
</td>
<td class="mono">{{ f.name }}</td>
<td>
{{ STATUS_LABELS.get(f.status, f.status) }}
{% if f.status == 'archived' and f.archived_at %}
<span class="muted small">({{ f.archived_at[:16] | replace('T', ' ') }})</span>
{% endif %}
{% if is_admin and f.rejected_for %}
<div class="muted small">nie przeszedł: {{ f.rejected_for | join('; ') }}</div>
{% endif %}
</td>
<td class="mono">{{ '%.2f'|format(f.size_mb) if f.size_mb is not none else '—' }} MB</td>
<td class="mono">{{ f.modified or '—' }}</td>
{% if can(request, 'files_input') or is_admin %}
<td class="ops">
{% if can(request, 'files_input') and f.status in ('active', 'ready') %}
<form method="post" action="/files/archive" class="inline">
<input type="hidden" name="path" value="{{ f.path }}">
<button type="submit" class="ghost"
title="Plik zostaje na dysku, zamrożony ze znacznikiem czasu — znika tylko z użytku.">Archiwizuj</button>
</form>
{% endif %}
{% if is_admin %}
{% if f.status in ('archived', 'quarantine') %}
<form method="post" action="/files/restore" class="inline">
<input type="hidden" name="path" value="{{ f.path }}">
<button type="submit" class="ghost">Przywróć</button>
</form>
{% endif %}
<form method="post" action="/files/delete" class="inline">
<input type="hidden" name="path" value="{{ f.path }}">
<button type="submit" class="danger"
onclick="return confirm('Skasować {{ f.name }} bezpowrotnie? Pliku nie da się odzyskać.')">Skasuj</button>
</form>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if can(request, 'files_input') %}
<div class="meta">Wgraj nową bazę</div>
<form method="post" action="/files/upload" enctype="multipart/form-data" class="account-card">
<div class="grid">
<label>Plik <input type="file" name="upload" required></label>
</div>
<div class="actions"><button type="submit">Wgraj</button></div>
</form>
{% endif %}
{% if is_admin %}
{# Reguły walidacji widzi WYŁĄCZNIE administrator — cała ta sekcja nie trafia
nawet do źródła strony dla pozostałych kont. #}
<div class="meta">Reguły przyjmowania baz</div>
<p class="muted small">
Plik musi je spełnić, żeby dało się go włączyć do użytku. Plik, który ich nie
spełnia, <strong>nie jest kasowany</strong> — czeka na Twoją decyzję.
</p>
<form method="post" action="/files/rules" class="account-card">
<div class="grid">
<label>Dozwolone rozszerzenia (po przecinku)
<input type="text" name="extensions" value="{{ (rules.extensions or []) | join(', ') }}"></label>
<label>Maksymalny rozmiar (MB)
<input type="number" name="max_size_mb" step="1" min="0" value="{{ rules.max_size_mb }}"></label>
<label>Minimalna liczba wierszy
<input type="number" name="min_rows" step="1" min="0" value="{{ rules.min_rows }}"></label>
</div>
<div class="grid">
<label>Wymagane kolumny (po przecinku, puste = bez wymagań)
<input type="text" name="required_columns"
value="{{ (rules.required_columns or []) | join(', ') }}"></label>
</div>
<div class="opts">
<label><input type="checkbox" name="reject_duplicate_content" value="1"
{{ 'checked' if rules.reject_duplicate_content else '' }}>
odrzucaj pliki o treści identycznej z już wgraną</label>
</div>
<div class="actions"><button type="submit">Zapisz reguły</button></div>
</form>
{% endif %}
{% endblock %}
@@ -309,62 +309,3 @@ def test_deleting_an_account_cannot_touch_the_administrator(env, monkeypatch):
assert r.status_code == 303
assert store.exists("ala"), "kasowanie nieistniejącego konta ruszyło inne"
assert c.get("/", headers=admin).status_code == 200
# ── awaria magazynu kont ─────────────────────────────────────────────────
# Ekran kont to JEDYNE miejsce, z którego administrator może naprawić problem
# z magazynem — więc musi na nim przeczytać, co i gdzie jest nie tak. Gołe 500
# (tak było na pierwszym wdrożeniu) zostawia go z niczym.
def _unreadable(tmp_path, monkeypatch, mode):
d = tmp_path / "stan"
d.mkdir()
monkeypatch.setenv("ACCOUNTS_FILE", str(d / "accounts.json"))
d.chmod(mode)
return d
def test_unreadable_store_explains_itself_instead_of_500(env, tmp_path, monkeypatch):
d = _unreadable(tmp_path, monkeypatch, 0o000)
try:
c = _client(monkeypatch)
r = c.get("/accounts", headers=_auth("szef", "tajne-szefa"))
assert r.status_code == 200, "problem z magazynem nie może wywalać strony"
assert "Nie mogę odczytać pliku kont" in r.text
assert str(d / "accounts.json") in r.text, "komunikat ma podać ŚCIEŻKĘ"
finally:
d.chmod(0o755)
def test_read_only_store_refuses_to_save_with_a_reason(env, tmp_path, monkeypatch):
d = _unreadable(tmp_path, monkeypatch, 0o555)
try:
c = _client(monkeypatch)
r = c.post("/accounts/create", headers=_auth("szef", "tajne-szefa"),
follow_redirects=False,
data={"login": "ala", "password": "x", "granted": ["chart"]})
assert r.status_code == 303
# Komunikat jedzie w parametrze zapytania, więc jest zakodowany —
# porównanie na surowym nagłówku sprawdzałoby procenty, nie treść.
from urllib.parse import unquote_plus
assert "Nie mogę zapisać pliku kont" in unquote_plus(r.headers["location"])
finally:
d.chmod(0o755)
def test_a_corrupt_file_is_never_silently_overwritten(env, tmp_path, monkeypatch):
"""Nadpisanie uszkodzonego pliku pustym zbiorem skasowałoby WSZYSTKIE konta.
Lepiej odmówić i powiedzieć, co jest nie tak."""
path = tmp_path / "accounts.json"
path.write_text("{to nie jest json", encoding="utf-8")
monkeypatch.setenv("ACCOUNTS_FILE", str(path))
c = _client(monkeypatch)
r = c.get("/accounts", headers=_auth("szef", "tajne-szefa"))
assert r.status_code == 200 and "uszkodzony" in r.text
c.post("/accounts/create", headers=_auth("szef", "tajne-szefa"),
follow_redirects=False, data={"login": "ala", "password": "x"})
assert path.read_text(encoding="utf-8") == "{to nie jest json", \
"uszkodzony plik został nadpisany — konta by zniknęły"
+215
View File
@@ -0,0 +1,215 @@
"""Trzy poziomy dostępu do plików baz (DAN-27 × PRE-27).
user (files") — widzi listę i decyduje, z czego program korzysta,
data_input (+files_input")— dokłada wgrywanie i ARCHIWIZACJĘ,
administrator kasowanie, przywracanie i REGUŁY WALIDACJI.
Osobno pilnujemy własności negatywnej: poza administratorem NIKT nie ma skąd
wiedzieć, że walidacja w ogóle istnieje. To łatwo zepsuć dobrą intencją
pokażmy człowiekowi, czemu plik nie przeszedł" — więc testy są wprost o tym.
"""
import pytest
from app import accounts as store
from app import features
@pytest.fixture()
def env(tmp_path, monkeypatch):
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
monkeypatch.setenv("APP_USER", "szef")
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
monkeypatch.delenv("APP_USERS", raising=False)
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
return tmp_path
def _auth(user, password):
import base64
return {"Authorization": "Basic " + base64.b64encode(
f"{user}:{password}".encode()).decode()}
REGISTRY = [
{"name": "glowna.xlsx", "path": "glowna.xlsx", "size_mb": 1.0, "modified": "2026-01-01",
"status": "active", "in_use": True, "archived_at": "", "uploaded_at": "",
"uploaded_by": "", "sha256": "abc"},
{"name": "stara.xlsx", "path": "stara.xlsx", "size_mb": 2.0, "modified": "2025-01-01",
"status": "archived", "in_use": False, "archived_at": "2026-02-02T10:00:00+00:00",
"uploaded_at": "", "uploaded_by": "", "sha256": "def"},
]
HELD = {"name": "podejrzana.xlsx", "path": "podejrzana.xlsx", "size_mb": 0.1,
"modified": "2026-03-03", "status": "quarantine", "in_use": False,
"archived_at": "", "uploaded_at": "2026-03-03T09:00:00+00:00",
"uploaded_by": "dane", "sha256": "ghi",
"rejected_for": ["brak wymaganych kolumn: znaczenie"]}
RULES = {"extensions": [".xlsx"], "max_size_mb": 50, "min_rows": 1,
"required_columns": ["id", "znaczenie"], "reject_duplicate_content": True}
def _client(monkeypatch, calls=None):
"""Klient z zastubowaną warstwą logiczną. `calls` zbiera to, co poszło w dół."""
from starlette.testclient import TestClient
from app.main import app, logic
calls = calls if calls is not None else []
def files_list(for_admin=False):
rows = list(REGISTRY) + ([HELD] if for_admin else [])
return {"files": rows, "rules": RULES if for_admin else {}, "root": "/x"}
monkeypatch.setattr(logic, "files_list", files_list)
for name in ("files_status", "files_upload", "files_delete", "files_rules"):
monkeypatch.setattr(logic, name,
lambda *a, _n=name, **kw: (calls.append((_n, a, kw)),
{"accepted": True, "name": "x.xlsx"})[1])
return TestClient(app), calls
# ── poziom „user" ────────────────────────────────────────────────────────
def test_user_sees_the_list_and_can_switch_bases_on_and_off(env, monkeypatch):
store.create("ula", "x", ["files"])
c, calls = _client(monkeypatch)
html = c.get("/files", headers=_auth("ula", "x")).text
assert "glowna.xlsx" in html and "stara.xlsx" in html
r = c.post("/files/use", headers=_auth("ula", "x"), follow_redirects=False,
data={"path": "glowna.xlsx", "use": "0"})
assert r.status_code == 303
assert calls and calls[0][0] == "files_status"
def test_user_gets_no_upload_no_archive_no_delete(env, monkeypatch):
store.create("ula", "x", ["files"])
c, _ = _client(monkeypatch)
html = c.get("/files", headers=_auth("ula", "x")).text
for absent in ("/files/upload", "/files/archive", "/files/delete",
"/files/restore", "/files/rules"):
assert absent not in html, f"strona zdradza {absent}"
for path in ("/files/upload", "/files/archive", "/files/delete",
"/files/restore", "/files/rules"):
r = c.post(path, headers=_auth("ula", "x"), data={"path": "glowna.xlsx"})
assert r.status_code == 404, f"{path}{r.status_code}"
# ── poziom „data_input" ──────────────────────────────────────────────────
def test_data_input_can_upload_and_archive(env, monkeypatch):
store.create("dane", "x", ["files", "files_input"])
c, calls = _client(monkeypatch)
html = c.get("/files", headers=_auth("dane", "x")).text
assert "/files/upload" in html and "/files/archive" in html
r = c.post("/files/upload", headers=_auth("dane", "x"), follow_redirects=False,
files={"upload": ("nowa.xlsx", b"zawartosc", "application/vnd.ms-excel")})
assert r.status_code == 303 and calls[-1][0] == "files_upload"
r = c.post("/files/archive", headers=_auth("dane", "x"), follow_redirects=False,
data={"path": "glowna.xlsx"})
assert r.status_code == 303
assert calls[-1][1][1] == "archived", "archiwizacja ma ustawiać stan `archived`"
def test_data_input_cannot_delete_restore_or_set_rules(env, monkeypatch):
"""Wymóg wprost: osoba wgrywająca dane może CO NAJWYŻEJ zarchiwizować."""
store.create("dane", "x", ["files", "files_input"])
c, _ = _client(monkeypatch)
html = c.get("/files", headers=_auth("dane", "x")).text
for absent in ("/files/delete", "/files/restore", "/files/rules"):
assert absent not in html, f"strona zdradza {absent}"
for path in ("/files/delete", "/files/restore", "/files/rules"):
assert c.post(path, headers=_auth("dane", "x"),
data={"path": "glowna.xlsx"}).status_code == 404
# ── własność negatywna: walidacja jest tajemnicą administratora ─────────
@pytest.mark.parametrize("perms", [["files"], ["files", "files_input"]])
def test_nobody_below_admin_learns_that_validation_exists(env, monkeypatch, perms):
store.create("ktos", "x", perms)
c, _ = _client(monkeypatch)
html = c.get("/files", headers=_auth("ktos", "x")).text
assert "podejrzana.xlsx" not in html, "plik wstrzymany nie ma prawa się pokazać"
for leak in ("walidacj", "Walidacj", "reguł", "Reguł", "znaczenie",
"brak wymaganych kolumn", "rozszerzeni", "Maksymalny rozmiar"):
assert leak not in html, f"strona zdradza mechanizm: „{leak}"
def test_refusal_to_activate_gives_no_reason_below_admin(env, monkeypatch):
"""Powód odmowy zdradzałby regułę. Komunikat ma być bez treści."""
import httpx
from app.main import app, logic
from starlette.testclient import TestClient
store.create("ula", "x", ["files"])
def boom(*a, **kw):
raise httpx.HTTPStatusError(
"422", request=httpx.Request("POST", "http://x"),
response=httpx.Response(422, json={"detail": "brak wymaganych kolumn: znaczenie"}))
monkeypatch.setattr(logic, "files_list", lambda for_admin=False: {"files": [], "rules": {}})
monkeypatch.setattr(logic, "files_status", boom)
r = TestClient(app).post("/files/use", headers=_auth("ula", "x"),
follow_redirects=False, data={"path": "x.xlsx", "use": "1"})
assert r.status_code == 303
assert "znaczenie" not in r.headers["location"]
assert "kolumn" not in r.headers["location"]
# ── administrator ────────────────────────────────────────────────────────
def test_admin_sees_held_files_with_the_reason_and_the_rules(env, monkeypatch):
c, _ = _client(monkeypatch)
html = c.get("/files", headers=_auth("szef", "tajne-szefa")).text
assert "podejrzana.xlsx" in html
assert "brak wymaganych kolumn: znaczenie" in html
assert "Reguły przyjmowania baz" in html
for op in ("/files/delete", "/files/restore", "/files/rules",
"/files/upload", "/files/archive"):
assert op in html, f"administratorowi brakuje {op}"
def test_admin_can_delete_restore_and_change_the_rules(env, monkeypatch):
c, calls = _client(monkeypatch)
admin = _auth("szef", "tajne-szefa")
c.post("/files/delete", headers=admin, follow_redirects=False, data={"path": "a.xlsx"})
c.post("/files/restore", headers=admin, follow_redirects=False, data={"path": "a.xlsx"})
c.post("/files/rules", headers=admin, follow_redirects=False,
data={"extensions": ".xlsx, .xlsm", "max_size_mb": "10", "min_rows": "5",
"required_columns": "id, znaczenie", "reject_duplicate_content": "1"})
names = [c[0] for c in calls]
assert names == ["files_delete", "files_status", "files_rules"]
sent = calls[-1][1][0]
assert sent["extensions"] == [".xlsx", ".xlsm"]
assert sent["required_columns"] == ["id", "znaczenie"]
assert sent["reject_duplicate_content"] is True
def test_archived_file_cannot_be_switched_on_from_the_page(env, monkeypatch):
"""Archiwum jest ZAMROŻONE: przełącznik przy nim w ogóle się nie pojawia,
a przywrócić może wyłącznie administrator."""
store.create("dane", "x", ["files", "files_input"])
c, _ = _client(monkeypatch)
html = c.get("/files", headers=_auth("dane", "x")).text
row = html[html.index("stara.xlsx") - 700:html.index("stara.xlsx")]
assert "/files/use" not in row, "zarchiwizowana baza ma przełącznik użycia"
def test_files_tab_is_hidden_without_the_permission(env, monkeypatch):
store.create("bez", "x", ["chart"])
c, _ = _client(monkeypatch)
html = c.get("/", headers=_auth("bez", "x")).text
assert 'href="/files"' not in html and "Pliki" not in html
assert c.get("/files", headers=_auth("bez", "x")).status_code == 404
def test_route_map_covers_every_file_route():
for route in ("/files", "/files/use", "/files/upload", "/files/archive",
"/files/restore", "/files/delete", "/files/rules"):
method = "GET" if route == "/files" else "POST"
assert (method, route) in features.ROUTES, route