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
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m12s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 19s
Testy / Kontrola składni wszystkich warstw (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 12m25s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m33s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 16s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
Ekran „Pliki" z trzema poziomami, wpiętymi w kontrolę dostępu z PRE-27:
„files" widzi listę i KLIKANIEM decyduje, z których baz program korzysta,
„files_input" dokłada wgrywanie i ARCHIWIZACJĘ,
administrator kasowanie, przywracanie z archiwum i REGUŁY WALIDACJI.
STAN JEST TERAZ TRWAŁY. DAN-15 trzymał go w zmiennej DISABLED_BASES, bo warstwa
danych nie miała gdzie zapisywać — udział był montowany read-only. Skoro stan ma
być klikany, musi przetrwać restart, więc udział jest zapisywalny, a stan leży
w pliku obok baz (zapis atomowy: plik opisuje CAŁY zbiór, więc obcięcie w połowie
skasowałoby wiedzę o wszystkich naraz). DISABLED_BASES zostaje jako awaryjne
wyłączenie z konfiguracji i odsiewa DODATKOWO — nie odwrotnie, bo inaczej ktoś
z dostępem do ekranu włączyłby bazę wyłączoną świadomie na poziomie wdrożenia.
ARCHIWIZACJA NIE KASUJE. Plik zostaje na dysku, zamrożony, ze znacznikiem czasu;
znika wyłącznie z użytku. To najdalej idąca operacja osoby wgrywającej dane —
kasować może tylko administrator. Test sprawdza, że plik po archiwizacji nadal
istnieje, bo to jest cała istota tej operacji.
WALIDACJA JEST BRAMKĄ DO UŻYTKU, NIE FILTREM NA WEJŚCIU. Plik wgrany zostaje
NIEZALEŻNIE od wyniku — nie tracimy niczego, co ktoś wgrał. Zmienia się tylko to,
czy da się go włączyć. Sprawdzenie biegnie też w chwili włączania, nie tylko przy
wgrywaniu: reguły mogą się zmienić po fakcie.
O WALIDACJI WIE TYLKO ADMINISTRATOR. Pliki wstrzymane są odsiewane W WARSTWIE
DANYCH przy for_admin=False, a nie ukrywane w szablonie — gdyby dochodziły do
przeglądarki, wystarczyłby podgląd źródła, żeby poznać reguły. Odmowa włączenia
wraca do konta bez uprawnień BEZ POWODU, bo powód zdradza regułę. Sekcja reguł
nie trafia nawet do źródła strony. Test parametryzowany po obu niższych poziomach
szuka w odpowiedzi śladów mechanizmu i wymaga, żeby żadnego nie było.
Każdy plik ma policzony sha256 — tożsamość niezależna od nazwy. Wykorzystuje ją
już odrzucanie duplikatów, a w kroku drugim posłuży do pilnowania zgodności
lustra w SQL.
Przy okazji naprawiony błąd, który dopiero co bym wprowadził: Path("") to
Path("."), czyli wartość PRAWDZIWA, więc `Path(os.getenv(...)) or domyślna`
zawsze wybierało pustą zmienną i zapisywało stan do katalogu bieżącego.
Wymaga zapisywalnego udziału — osobny PR w repo deploy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -17,14 +17,17 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Form, HTTPException, Query, Request
|
||||
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
import base64
|
||||
|
||||
from app import accounts as accounts_store
|
||||
from app import chartwheel as chartwheel_mod
|
||||
from app import features
|
||||
from app import files_state
|
||||
from app.house_systems import HOUSE_SYSTEMS, LIMITED as HOUSE_LIMITED, label as house_label
|
||||
|
||||
from app import geocode, security
|
||||
@@ -69,6 +72,7 @@ templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
|
||||
templates.env.globals["house_label"] = house_label
|
||||
templates.env.globals["HOUSE_LIMITED"] = HOUSE_LIMITED
|
||||
templates.env.globals["WHEEL_ORIENTATIONS"] = chartwheel_mod.ORIENTATIONS
|
||||
templates.env.globals["STATUS_LABELS"] = files_state.LABELS
|
||||
|
||||
|
||||
def _perms(request: Request) -> frozenset[str]:
|
||||
@@ -672,6 +676,141 @@ def timezone_lookup(lat: float, lon: float, date: str = "", time: str = "12:00")
|
||||
return res
|
||||
|
||||
|
||||
# ---------------- Pliki baz (DAN-27) ----------------
|
||||
# Trzy poziomy dostępu, opisane w features.ROUTES:
|
||||
# „files" — widzi listę i decyduje, z czego program korzysta,
|
||||
# „files_input" — dokłada wgrywanie i ARCHIWIZACJĘ (plik zostaje zamrożony
|
||||
# ze znacznikiem czasu, znika tylko z użytku),
|
||||
# administrator — kasowanie, przywracanie z archiwum i REGUŁY WALIDACJI.
|
||||
#
|
||||
# Kwarantanna (plik wgrany, ale odrzucony przez walidację) jest odsiewana W WARSTWIE
|
||||
# DANYCH przy `for_admin=False`. Nie filtrujemy jej tutaj ani w szablonie: gdyby
|
||||
# takie pliki dochodziły do przeglądarki, wystarczyłby podgląd źródła, żeby poznać
|
||||
# reguły — a te ma znać wyłącznie administrator.
|
||||
|
||||
def _files_context(request: Request, error: str = "", done: str = "") -> dict:
|
||||
is_admin = features.ADMIN in _perms(request)
|
||||
try:
|
||||
data = logic.files_list(for_admin=is_admin)
|
||||
except httpx.HTTPError as e:
|
||||
return {"files": [], "rules": {}, "is_admin": is_admin,
|
||||
"error": _logic_error(e), "done": ""}
|
||||
return {"files": data.get("files") or [], "rules": data.get("rules") or {},
|
||||
"is_admin": is_admin, "error": error, "done": done}
|
||||
|
||||
|
||||
@app.get("/files", response_class=HTMLResponse)
|
||||
def files_view(request: Request, error: str = "", done: str = ""):
|
||||
return templates.TemplateResponse(request, "files.html",
|
||||
_files_context(request, error, done))
|
||||
|
||||
|
||||
def _files_redirect(error: str = "", done: str = "") -> RedirectResponse:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
q = urlencode({k: v for k, v in (("error", error), ("done", done)) if v})
|
||||
return RedirectResponse(f"/files{'?' + q if q else ''}", status_code=303)
|
||||
|
||||
|
||||
def _who(request: Request) -> str:
|
||||
return getattr(request.state, "user", "") or "-"
|
||||
|
||||
|
||||
@app.post("/files/use")
|
||||
def files_use(request: Request, path: str = Form(...), use: str = Form("")):
|
||||
"""Włącza albo odstawia bazę. Włączenie przechodzi przez bramkę walidacji
|
||||
w warstwie danych — odmowa wraca BEZ POWODU, bo powód zdradzałby reguły."""
|
||||
want = files_state.ACTIVE if use.strip().lower() in {"1", "true", "on", "tak"} \
|
||||
else files_state.READY
|
||||
try:
|
||||
logic.files_status(path, want, by=_who(request))
|
||||
except httpx.HTTPStatusError as e:
|
||||
detail = _http_detail(e)
|
||||
if features.ADMIN not in _perms(request):
|
||||
detail = "Tego pliku nie da się teraz włączyć do użytku."
|
||||
return _files_redirect(error=detail)
|
||||
except httpx.HTTPError as e:
|
||||
return _files_redirect(error=_logic_error(e))
|
||||
return _files_redirect(done="Zapisano." if want == files_state.READY
|
||||
else "Baza włączona do użytku.")
|
||||
|
||||
|
||||
@app.post("/files/upload")
|
||||
async def files_upload(request: Request, upload: UploadFile = File(...)):
|
||||
"""Wgranie nowej bazy. Plik zostaje NIEZALEŻNIE od wyniku walidacji —
|
||||
nie tracimy niczego, co ktoś wgrał."""
|
||||
raw = await upload.read()
|
||||
if not raw:
|
||||
return _files_redirect(error="Pusty plik.")
|
||||
try:
|
||||
out = logic.files_upload(upload.filename or "plik.xlsx",
|
||||
base64.b64encode(raw).decode("ascii"), by=_who(request))
|
||||
except httpx.HTTPError as e:
|
||||
return _files_redirect(error=_logic_error(e))
|
||||
if out.get("accepted"):
|
||||
return _files_redirect(done=f"Wgrano „{out.get('name')}”. Można ją włączyć do użytku.")
|
||||
# Bez powodu i bez słowa „walidacja" — poza administratorem nikt nie ma
|
||||
# skąd wiedzieć, że taki mechanizm istnieje.
|
||||
return _files_redirect(done=f"Wgrano „{out.get('name')}”. "
|
||||
f"Zanim trafi do użytku, musi ją zatwierdzić administrator.")
|
||||
|
||||
|
||||
@app.post("/files/archive")
|
||||
def files_archive(request: Request, path: str = Form(...)):
|
||||
"""Archiwizacja: plik ZOSTAJE, zamrożony, ze znacznikiem czasu — znika tylko
|
||||
z użytku. To najdalej idąca operacja dostępna osobie wgrywającej dane."""
|
||||
try:
|
||||
logic.files_status(path, files_state.ARCHIVED, by=_who(request))
|
||||
except httpx.HTTPError as e:
|
||||
return _files_redirect(error=_logic_error(e))
|
||||
return _files_redirect(done=f"Zarchiwizowano „{path}”. Plik został zachowany.")
|
||||
|
||||
|
||||
@app.post("/files/restore")
|
||||
def files_restore(request: Request, path: str = Form(...)):
|
||||
try:
|
||||
logic.files_status(path, files_state.READY, by=_who(request))
|
||||
except httpx.HTTPError as e:
|
||||
return _files_redirect(error=_logic_error(e))
|
||||
return _files_redirect(done=f"Przywrócono „{path}” z archiwum.")
|
||||
|
||||
|
||||
@app.post("/files/delete")
|
||||
def files_delete(request: Request, path: str = Form(...)):
|
||||
try:
|
||||
logic.files_delete(path)
|
||||
except httpx.HTTPError as e:
|
||||
return _files_redirect(error=_logic_error(e))
|
||||
return _files_redirect(done=f"Skasowano „{path}” bezpowrotnie.")
|
||||
|
||||
|
||||
@app.post("/files/rules")
|
||||
def files_rules(request: Request, extensions: str = Form(".xlsx"),
|
||||
max_size_mb: float = Form(50), min_rows: int = Form(1),
|
||||
required_columns: str = Form(""),
|
||||
reject_duplicate_content: str = Form("")):
|
||||
rules = {
|
||||
"extensions": [e.strip() for e in extensions.split(",") if e.strip()],
|
||||
"max_size_mb": max_size_mb,
|
||||
"min_rows": min_rows,
|
||||
"required_columns": [c.strip() for c in required_columns.split(",") if c.strip()],
|
||||
"reject_duplicate_content": reject_duplicate_content.strip().lower()
|
||||
in {"1", "true", "on", "tak"},
|
||||
}
|
||||
try:
|
||||
logic.files_rules(rules)
|
||||
except httpx.HTTPError as e:
|
||||
return _files_redirect(error=_logic_error(e))
|
||||
return _files_redirect(done="Zapisano reguły walidacji.")
|
||||
|
||||
|
||||
def _http_detail(e: httpx.HTTPStatusError) -> str:
|
||||
try:
|
||||
return str(e.response.json().get("detail") or e)
|
||||
except Exception: # noqa: BLE001
|
||||
return str(e)
|
||||
|
||||
|
||||
# ---------------- Konta i uprawnienia (PRE-27) ----------------
|
||||
# Ochrona tych tras siedzi w features.ROUTES, nie w dekoratorze — jedna mapa
|
||||
# dla całej aplikacji, sprawdzana testem, który przechodzi po WSZYSTKICH trasach.
|
||||
|
||||
@@ -247,3 +247,18 @@ button.danger { background: #8b2f2f; }
|
||||
border: 1px solid var(--line); border-left: 4px solid var(--accent);
|
||||
border-radius: 4px; background: rgba(255, 255, 255, .03);
|
||||
}
|
||||
|
||||
|
||||
/* ── ekran plików (DAN-27) ──────────────────────────────────────────── */
|
||||
form.inline { display: inline; }
|
||||
td.ops form.inline + form.inline { margin-left: .4rem; }
|
||||
button.toggle {
|
||||
background: none; border: 1px solid var(--line); border-radius: 50%;
|
||||
width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
|
||||
}
|
||||
button.toggle.on { color: var(--accent); border-color: var(--accent); }
|
||||
button.toggle.off { color: var(--muted); }
|
||||
tr.row-archived td { opacity: .55; }
|
||||
/* Wstrzymane widzi tylko administrator — dla reszty tych wierszy nie ma
|
||||
w ogóle w odpowiedzi, więc ten styl nigdy nie dotyczy ich strony. */
|
||||
tr.row-held td { background: rgba(184, 134, 11, .07); }
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
{% extends "base.html" %}
|
||||
{% set nav_active = "files" %}
|
||||
{% block title %}Pliki{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="muted">
|
||||
Bazy interpretacyjne, z których korzysta program. Zaznaczona baza bierze udział
|
||||
w wyszukiwaniu; odznaczona zostaje na dysku, ale program jej nie używa.
|
||||
</p>
|
||||
|
||||
{% if error %}<p class="house-warning">{{ error }}</p>{% endif %}
|
||||
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
|
||||
|
||||
<div class="meta">Bazy ({{ files | length }})</div>
|
||||
{% if not files %}
|
||||
<p class="muted">Nie ma jeszcze żadnego pliku bazy.</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>W użyciu</th><th>Plik</th><th>Stan</th><th>Rozmiar</th><th>Zmieniony</th>
|
||||
{% if can(request, 'files_input') or is_admin %}<th>Operacje</th>{% endif %}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in files %}
|
||||
<tr class="{{ 'row-archived' if f.status == 'archived' else '' }}{{ ' row-held' if f.status == 'quarantine' else '' }}">
|
||||
<td>
|
||||
{# Archiwum jest ZAMROŻONE — nie da się go włączyć bez przywrócenia,
|
||||
a przywrócić może wyłącznie administrator. #}
|
||||
{% if f.status in ('active', 'ready') %}
|
||||
<form method="post" action="/files/use" class="inline">
|
||||
<input type="hidden" name="path" value="{{ f.path }}">
|
||||
<input type="hidden" name="use" value="{{ '0' if f.in_use else '1' }}">
|
||||
<button type="submit" class="toggle {{ 'on' if f.in_use else 'off' }}"
|
||||
title="{{ 'Odstaw tę bazę' if f.in_use else 'Włącz tę bazę do użytku' }}">
|
||||
{{ '●' if f.in_use else '○' }}
|
||||
</button>
|
||||
</form>
|
||||
{% else %}<span class="muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="mono">{{ f.name }}</td>
|
||||
<td>
|
||||
{{ STATUS_LABELS.get(f.status, f.status) }}
|
||||
{% if f.status == 'archived' and f.archived_at %}
|
||||
<span class="muted small">({{ f.archived_at[:16] | replace('T', ' ') }})</span>
|
||||
{% endif %}
|
||||
{% if is_admin and f.rejected_for %}
|
||||
<div class="muted small">nie przeszedł: {{ f.rejected_for | join('; ') }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="mono">{{ '%.2f'|format(f.size_mb) if f.size_mb is not none else '—' }} MB</td>
|
||||
<td class="mono">{{ f.modified or '—' }}</td>
|
||||
{% if can(request, 'files_input') or is_admin %}
|
||||
<td class="ops">
|
||||
{% if can(request, 'files_input') and f.status in ('active', 'ready') %}
|
||||
<form method="post" action="/files/archive" class="inline">
|
||||
<input type="hidden" name="path" value="{{ f.path }}">
|
||||
<button type="submit" class="ghost"
|
||||
title="Plik zostaje na dysku, zamrożony ze znacznikiem czasu — znika tylko z użytku.">Archiwizuj</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if is_admin %}
|
||||
{% if f.status in ('archived', 'quarantine') %}
|
||||
<form method="post" action="/files/restore" class="inline">
|
||||
<input type="hidden" name="path" value="{{ f.path }}">
|
||||
<button type="submit" class="ghost">Przywróć</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="post" action="/files/delete" class="inline">
|
||||
<input type="hidden" name="path" value="{{ f.path }}">
|
||||
<button type="submit" class="danger"
|
||||
onclick="return confirm('Skasować {{ f.name }} bezpowrotnie? Pliku nie da się odzyskać.')">Skasuj</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endif %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if can(request, 'files_input') %}
|
||||
<div class="meta">Wgraj nową bazę</div>
|
||||
<form method="post" action="/files/upload" enctype="multipart/form-data" class="account-card">
|
||||
<div class="grid">
|
||||
<label>Plik <input type="file" name="upload" required></label>
|
||||
</div>
|
||||
<div class="actions"><button type="submit">Wgraj</button></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if is_admin %}
|
||||
{# Reguły walidacji widzi WYŁĄCZNIE administrator — cała ta sekcja nie trafia
|
||||
nawet do źródła strony dla pozostałych kont. #}
|
||||
<div class="meta">Reguły przyjmowania baz</div>
|
||||
<p class="muted small">
|
||||
Plik musi je spełnić, żeby dało się go włączyć do użytku. Plik, który ich nie
|
||||
spełnia, <strong>nie jest kasowany</strong> — czeka na Twoją decyzję.
|
||||
</p>
|
||||
<form method="post" action="/files/rules" class="account-card">
|
||||
<div class="grid">
|
||||
<label>Dozwolone rozszerzenia (po przecinku)
|
||||
<input type="text" name="extensions" value="{{ (rules.extensions or []) | join(', ') }}"></label>
|
||||
<label>Maksymalny rozmiar (MB)
|
||||
<input type="number" name="max_size_mb" step="1" min="0" value="{{ rules.max_size_mb }}"></label>
|
||||
<label>Minimalna liczba wierszy
|
||||
<input type="number" name="min_rows" step="1" min="0" value="{{ rules.min_rows }}"></label>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<label>Wymagane kolumny (po przecinku, puste = bez wymagań)
|
||||
<input type="text" name="required_columns"
|
||||
value="{{ (rules.required_columns or []) | join(', ') }}"></label>
|
||||
</div>
|
||||
<div class="opts">
|
||||
<label><input type="checkbox" name="reject_duplicate_content" value="1"
|
||||
{{ 'checked' if rules.reject_duplicate_content else '' }}>
|
||||
odrzucaj pliki o treści identycznej z już wgraną</label>
|
||||
</div>
|
||||
<div class="actions"><button type="submit">Zapisz reguły</button></div>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user