feat: przegląd baz na udziale + globalne włączanie/wyłączanie (DAN-15/PRE-09)
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m30s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 12s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
build / build (push) Successful in 40s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m3s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m35s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 14s
Testy / Kontrola składni wszystkich warstw (push) Successful in 10s
Testy / Testy warstwy logicznej (silnik) (pull_request) Successful in 10m30s
Testy / Testy warstwy prezentacji (dostęp do baz) (pull_request) Successful in 9m29s
Testy / Testy warstwy bazodanowej (ochrona baz) (pull_request) Successful in 9m28s
Testy / Build obrazu silnika B (swisseph) (pull_request) Successful in 12s
Testy / Kontrola składni wszystkich warstw (pull_request) Successful in 8s
build / build (push) Successful in 40s
Testy / Testy warstwy logicznej (silnik) (push) Successful in 11m3s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Successful in 9m35s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Successful in 9m29s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 14s
Testy / Kontrola składni wszystkich warstw (push) Successful in 10s
Wymaganie przedefiniowane pod model serwerowy (#47): nie wybiera się folderu — pliki leżą na stałym NFS. Potrzeba za to WIDZIEĆ, jakie bazy są dostępne i móc zdecydować, które biorą udział w interpretacji. Warstwa danych: `bases.py` (lista plików + metaopis: nazwa, ścieżka, rozmiar, data, stan) i endpoint `/bases`. Wyłączone bazy są ODSIEWANE z kandydatów przy wyszukiwaniu, więc naprawdę nie biorą udziału w interpretacji — nie tylko znikają z listy. Lista wyłączonych wchodzi do klucza cache zapytań: bez tego zmiana ustawień oddawałaby wynik sprzed zmiany, czyli treść bazy uznanej za wyłączoną. `list_bases()` doszło do interfejsu dostawcy jako OPCJONALNE (SQL nie operuje na plikach → pusto, zamiast wywrotki). Przelot logika → prezentacja i ekran „Ustawienia" z tabelą baz. Przez łącze idą SAME METADANE — podgląd listy nie jest kolejną drogą do wyniesienia treści. Stan przełączników jest DEKLARATYWNY (`DISABLED_BASES`), nie klikalny — i to jest świadome: udział z bazami montujemy read-only, a katalog cache to `emptyDir`, więc zapisany przełącznik ginąłby przy restarcie poda i po cichu włączał z powrotem wyłączoną bazę. Ekran mówi wprost, jak wyłączyć bazę i dlaczego nie klikaniem. Tryb klikalny wymagałby dołożenia trwałego wolumenu. Weryfikacja na żywym łańcuchu: `/bases` przechodzi przez SZYFROWANE łącze (logic→data), pokazuje 3 bazy z metaopisem i stanem; wyszukiwanie daje 3 → 2 → 0 wierszy w miarę wyłączania baz. Testy: dane +6, prezentacja +6. Dane 13, logika 277, prezentacja 249. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #55.
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
"""Przegląd baz na udziale i ich globalne włączanie/wyłączanie (DAN-15 / PRE-09).
|
||||
|
||||
Wymaganie przedefiniowane pod model serwerowy: pliki baz leżą na stałym NFS, więc
|
||||
nie wybiera się folderu — potrzeba za to WIDZIEĆ, jakie bazy są dostępne (nazwa +
|
||||
metaopis) i móc globalnie zdecydować, które biorą udział w interpretacji.
|
||||
|
||||
Stan przełączników trzymamy DEKLARATYWNIE w zmiennej `DISABLED_BASES`, a nie w
|
||||
pliku, bo warstwa danych nie ma gdzie trwale zapisywać: udział z bazami jest
|
||||
montowany read-only, a katalog cache to `emptyDir` (ginie przy restarcie poda).
|
||||
Zapis do pliku po cichu wracałby więc do stanu sprzed restartu — a ciche
|
||||
przywrócenie wyłączonej bazy jest gorsze niż konieczność edycji konfiguracji.
|
||||
|
||||
Dopasowanie jest tolerancyjne: wpis pasuje po nazwie pliku ALBO po ścieżce
|
||||
względnej — żeby dało się wyłączyć zarówno „stara_baza.xlsx", jak i
|
||||
„archiwum/stara_baza.xlsx".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def disabled_entries() -> list[str]:
|
||||
return [e.strip() for e in os.getenv("DISABLED_BASES", "").split(",") if e.strip()]
|
||||
|
||||
|
||||
def _relative(path: Path, root: Path) -> str:
|
||||
try:
|
||||
return str(path.relative_to(root))
|
||||
except ValueError:
|
||||
return path.name
|
||||
|
||||
|
||||
def is_enabled(path: str | Path, root: str | Path, entries: list[str] | None = None) -> bool:
|
||||
"""Czy baza bierze udział w wyszukiwaniu interpretacji."""
|
||||
entries = disabled_entries() if entries is None else entries
|
||||
if not entries:
|
||||
return True
|
||||
p = Path(path)
|
||||
rel = _relative(p, Path(root))
|
||||
return not any(e == p.name or e == rel for e in entries)
|
||||
|
||||
|
||||
def list_bases(root: str | Path, paths: list[str]) -> list[dict]:
|
||||
"""Bazy dostępne na udziale + metaopis. Celowo TANI opis (dane z systemu
|
||||
plików): przy setkach plików liczenie rekordów oznaczałoby wczytanie każdego."""
|
||||
root = Path(root)
|
||||
entries = disabled_entries()
|
||||
out: list[dict] = []
|
||||
for raw in paths:
|
||||
p = Path(raw)
|
||||
try:
|
||||
st = p.stat()
|
||||
size_mb, modified = round(st.st_size / (1024 * 1024), 2), st.st_mtime
|
||||
except OSError:
|
||||
size_mb, modified = None, None
|
||||
out.append({
|
||||
"name": p.name,
|
||||
"path": _relative(p, root),
|
||||
"size_mb": size_mb,
|
||||
"modified": (datetime.fromtimestamp(modified, tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
if modified else None),
|
||||
"enabled": is_enabled(p, root, entries),
|
||||
})
|
||||
return out
|
||||
@@ -44,6 +44,16 @@ def search(query: SearchQuery) -> SearchResult:
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/bases")
|
||||
def bases() -> dict:
|
||||
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
|
||||
|
||||
Same METADANE — nazwy plików, rozmiar, data. Żadnej treści baz, więc podgląd
|
||||
listy nie jest kolejną drogą do ich wyniesienia."""
|
||||
items = provider.list_bases()
|
||||
return {"bases": items, "enabled": sum(1 for b in items if b["enabled"]), "total": len(items)}
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthInfo)
|
||||
def health() -> HealthInfo:
|
||||
return provider.health()
|
||||
|
||||
@@ -25,3 +25,10 @@ class DataProvider(ABC):
|
||||
def warmup(self) -> None:
|
||||
"""Opcjonalne wstępne zbudowanie cache/indeksu przy starcie."""
|
||||
return None
|
||||
|
||||
def list_bases(self) -> list[dict]:
|
||||
"""Bazy widoczne dla dostawcy + metaopis i stan włączenia (DAN-15/PRE-09).
|
||||
|
||||
Opcjonalne: dostawca SQL nie operuje na plikach, więc domyślnie pusto —
|
||||
UI pokaże wtedy, że nie ma czego przełączać, zamiast się wywrócić."""
|
||||
return []
|
||||
|
||||
@@ -91,10 +91,31 @@ class ExcelDataProvider(DataProvider):
|
||||
base = Path(self.s.excel_dir)
|
||||
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
|
||||
|
||||
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)]
|
||||
|
||||
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())
|
||||
|
||||
# ---- publiczne API ----
|
||||
def search(self, query: SearchQuery) -> SearchResult:
|
||||
t0 = time.perf_counter()
|
||||
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}"
|
||||
# Lista wyłączonych baz wchodzi do klucza cache: bez tego zmiana ustawień
|
||||
# oddawałaby wynik sprzed zmiany, czyli treść bazy uznanej za wyłączoną.
|
||||
from app import bases
|
||||
|
||||
disabled = ",".join(bases.disabled_entries())
|
||||
cache_key = f"{query.key}|{query.value}|{query.exact}|{query.limit}|{query.fields}|{disabled}"
|
||||
|
||||
hit = self.queries.get(cache_key) # poziom 3: wynik zapytania
|
||||
if hit is not None:
|
||||
@@ -105,6 +126,9 @@ class ExcelDataProvider(DataProvider):
|
||||
if not candidates:
|
||||
# brak w indeksie (np. klucz nieindeksowany) -> przeszukaj wszystkie pliki
|
||||
candidates = [(p, "0") for p in self._excel_files()]
|
||||
# bazy wyłączone globalnie (DAN-15) pomijamy niezależnie od źródła kandydatów
|
||||
allowed = set(self._enabled_files([p for p, _ in candidates]))
|
||||
candidates = [(p, s) for p, s in candidates if p in allowed]
|
||||
|
||||
rows: list[dict] = []
|
||||
for path, _sheet in candidates:
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Przegląd baz na udziale i ich globalne wyłączanie (DAN-15 / PRE-09)."""
|
||||
import pytest
|
||||
|
||||
from app import bases
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
monkeypatch.delenv("DISABLED_BASES", raising=False)
|
||||
|
||||
|
||||
ROOT = "/dane"
|
||||
PATHS = [f"{ROOT}/main_base.xlsx", f"{ROOT}/archiwum/stara.xlsx"]
|
||||
|
||||
|
||||
def test_everything_enabled_by_default():
|
||||
for b in bases.list_bases(ROOT, PATHS):
|
||||
assert b["enabled"] is True
|
||||
|
||||
|
||||
def test_disable_by_file_name(monkeypatch):
|
||||
monkeypatch.setenv("DISABLED_BASES", "stara.xlsx")
|
||||
state = {b["name"]: b["enabled"] for b in bases.list_bases(ROOT, PATHS)}
|
||||
assert state == {"main_base.xlsx": True, "stara.xlsx": False}
|
||||
|
||||
|
||||
def test_disable_by_relative_path(monkeypatch):
|
||||
"""Wpis może wskazywać ścieżkę względną, nie tylko samą nazwę."""
|
||||
monkeypatch.setenv("DISABLED_BASES", "archiwum/stara.xlsx")
|
||||
assert bases.is_enabled(f"{ROOT}/archiwum/stara.xlsx", ROOT) is False
|
||||
assert bases.is_enabled(f"{ROOT}/main_base.xlsx", ROOT) is True
|
||||
|
||||
|
||||
def test_entries_are_trimmed_and_multiple(monkeypatch):
|
||||
monkeypatch.setenv("DISABLED_BASES", " stara.xlsx , main_base.xlsx ")
|
||||
assert [b["enabled"] for b in bases.list_bases(ROOT, PATHS)] == [False, False]
|
||||
|
||||
|
||||
def test_listing_carries_metadata_not_content(tmp_path):
|
||||
"""Metaopis: nazwa, ścieżka, rozmiar, data — ŻADNEJ treści bazy."""
|
||||
f = tmp_path / "baza.xlsx"
|
||||
f.write_bytes(b"x" * 2048)
|
||||
item = bases.list_bases(tmp_path, [str(f)])[0]
|
||||
assert set(item) == {"name", "path", "size_mb", "modified", "enabled"}
|
||||
assert item["name"] == "baza.xlsx" and item["size_mb"] is not None and item["modified"]
|
||||
|
||||
|
||||
def test_missing_file_does_not_crash_the_listing():
|
||||
item = bases.list_bases(ROOT, [f"{ROOT}/nie-ma.xlsx"])[0]
|
||||
assert item["size_mb"] is None and item["modified"] is None
|
||||
@@ -45,6 +45,12 @@ class DataClient:
|
||||
payload=payload, headers=_auth_headers(),
|
||||
link=_link())
|
||||
|
||||
def bases(self) -> dict[str, Any]:
|
||||
"""Lista baz na udziale + metaopis (DAN-15) — same metadane, bez treści."""
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
||||
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.
|
||||
|
||||
@@ -108,6 +108,19 @@ class SynastryRequest(BaseModel):
|
||||
aspect_minor: bool = False
|
||||
|
||||
|
||||
@app.get("/bases")
|
||||
def bases() -> dict:
|
||||
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09).
|
||||
|
||||
Przelot do warstwy danych — same METADANE, żadnej treści baz."""
|
||||
from app.clients.data_client import DataClient
|
||||
|
||||
try:
|
||||
return DataClient().bases()
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Warstwa bazodanowa niedostępna: {e}")
|
||||
|
||||
|
||||
@app.post("/chart/synastry")
|
||||
def chart_synastry(req: SynastryRequest) -> dict:
|
||||
"""Synastria (PRE-04): dwa horoskopy natalne + aspekty MIĘDZY nimi (planeta
|
||||
|
||||
@@ -152,6 +152,12 @@ class LogicClient:
|
||||
client, f"{self.base_url}/chart/horoscope/stream",
|
||||
payload=payload, headers=_auth_headers(), link=_link())
|
||||
|
||||
def bases(self) -> dict[str, Any]:
|
||||
"""Bazy dostępne na udziale + metaopis i stan włączenia (DAN-15/PRE-09)."""
|
||||
with httpx.Client(timeout=settings.http_timeout) as client:
|
||||
return link_crypto.call_json(client, "GET", f"{self.base_url}/bases",
|
||||
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:
|
||||
|
||||
@@ -289,6 +289,23 @@ def compile_pdf(payload: dict):
|
||||
headers={"Content-Disposition": 'attachment; filename="raport.pdf"'})
|
||||
|
||||
|
||||
# ---------------- Ustawienia: bazy na udziale (DAN-15/PRE-09) ----------------
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
def settings_view(request: Request):
|
||||
"""Podgląd baz dostępnych na udziale + które biorą udział w interpretacji.
|
||||
|
||||
Same metadane (nazwa, rozmiar, data) — bez treści baz. Przełączniki są
|
||||
DEKLARATYWNE (`DISABLED_BASES`), bo warstwa danych nie ma trwałego zapisu:
|
||||
udział jest read-only, a cache to emptyDir. Zapis do pliku ginąłby po
|
||||
restarcie, po cichu włączając z powrotem wyłączoną bazę."""
|
||||
ctx: dict = {"result": None, "error": None}
|
||||
try:
|
||||
ctx["result"] = logic.bases()
|
||||
except httpx.HTTPError as e:
|
||||
ctx["error"] = _logic_error(e)
|
||||
return templates.TemplateResponse(request, "settings.html", ctx)
|
||||
|
||||
|
||||
# ---------------- Synastria (technika relacyjna) ----------------
|
||||
@app.get("/synastry", response_class=HTMLResponse)
|
||||
def synastry_form(request: Request):
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<a href="/synastry" class="{% block nav_synastry %}{% endblock %}">Synastria</a>
|
||||
<a href="/significators" class="{% block nav_sig %}{% endblock %}">Sygnifikatory</a>
|
||||
<a href="/compile" class="{% block nav_compile %}{% endblock %}">Skompiluj</a>
|
||||
<a href="/settings" class="{% block nav_settings %}{% endblock %}">Ustawienia</a>
|
||||
</nav>
|
||||
</header>
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Ustawienia{% endblock %}
|
||||
{% block nav_settings %}active{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
|
||||
|
||||
{% if error %}<div class="error">{{ error }}</div>{% endif %}
|
||||
|
||||
{% if result %}
|
||||
<div class="meta">Bazy na udziale: <strong>{{ result.total }}</strong> ·
|
||||
biorą udział w interpretacji: <strong>{{ result.enabled }}</strong>
|
||||
{% if result.total and result.enabled < result.total %}
|
||||
· wyłączonych: <strong>{{ result.total - result.enabled }}</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if result.bases %}
|
||||
<table>
|
||||
<thead><tr><th>Baza</th><th>Ścieżka</th><th>Rozmiar</th><th>Zmieniono</th><th>W interpretacji</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in result.bases %}
|
||||
<tr>
|
||||
<td>{{ b.name }}</td>
|
||||
<td class="muted small">{{ b.path }}</td>
|
||||
<td class="mono">{% if b.size_mb is not none %}{{ b.size_mb }} MB{% else %}—{% endif %}</td>
|
||||
<td class="mono">{{ b.modified or '—' }}</td>
|
||||
<td>{% if b.enabled %}tak{% else %}<span class="badge">wyłączona</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="muted">Na udziale nie widać żadnych baz (albo aktywny dostawca nie operuje na plikach).</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="meta">Jak wyłączyć bazę</div>
|
||||
<p class="muted small">
|
||||
Lista wyłączonych jest <strong>deklaratywna</strong> — trzyma ją zmienna
|
||||
<span class="mono">DISABLED_BASES</span> usługi danych (nazwy plików po przecinku,
|
||||
np. <span class="mono">stara_baza.xlsx,archiwum/testowa.xlsx</span>). Po zmianie
|
||||
zrestartuj warstwę danych.
|
||||
</p>
|
||||
<p class="muted small">
|
||||
Dlaczego nie klikaniem: udział z bazami jest montowany tylko do odczytu, a katalog
|
||||
cache to <span class="mono">emptyDir</span> — zapisany przełącznik ginąłby przy
|
||||
restarcie poda i po cichu włączał z powrotem wyłączoną bazę. Przełączanie z tego
|
||||
ekranu wymagałoby dołożenia trwałego wolumenu.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Ekran ustawień: bazy na udziale (PRE-09)."""
|
||||
import pathlib
|
||||
|
||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||
TPL = (APP / "templates" / "settings.html").read_text(encoding="utf-8")
|
||||
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
||||
MAIN = (APP / "main.py").read_text(encoding="utf-8")
|
||||
CLIENT = (APP / "clients" / "logic_client.py").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_tab_is_in_the_menu():
|
||||
assert 'href="/settings"' in BASE and "Ustawienia" in BASE
|
||||
|
||||
|
||||
def test_route_and_client_exist():
|
||||
assert '@app.get("/settings"' in MAIN and "logic.bases()" in MAIN
|
||||
assert 'f"{self.base_url}/bases"' in CLIENT
|
||||
|
||||
|
||||
def test_listing_shows_name_and_metadata():
|
||||
for col in ("b.name", "b.path", "b.size_mb", "b.modified"):
|
||||
assert col in TPL, f"brak kolumny {col}"
|
||||
|
||||
|
||||
def test_listing_shows_whether_base_counts_in_interpretation():
|
||||
assert "b.enabled" in TPL and "wyłączona" in TPL
|
||||
|
||||
|
||||
def test_page_explains_how_to_disable_and_why_not_clickable():
|
||||
"""Ekran ma mówić, JAK wyłączyć bazę — inaczej podgląd bez sprawczości myli."""
|
||||
assert "DISABLED_BASES" in TPL
|
||||
assert "emptyDir" in TPL # uzasadnienie: brak trwałego zapisu
|
||||
|
||||
|
||||
def test_error_from_logic_does_not_break_the_page():
|
||||
assert "_logic_error(e)" in MAIN[MAIN.index("def settings_view"):MAIN.index("def settings_view") + 900]
|
||||
Reference in New Issue
Block a user