From 4c1e7f8808d64269ab4851844fbe928fa3e2d6d4 Mon Sep 17 00:00:00 2001 From: migatu Date: Mon, 3 Aug 2026 22:09:27 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20przegl=C4=85d=20baz=20na=20udziale=20+?= =?UTF-8?q?=20globalne=20w=C5=82=C4=85czanie/wy=C5=82=C4=85czanie=20(DAN-1?= =?UTF-8?q?5/PRE-09)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- services/data/app/bases.py | 66 +++++++++++++++++++ services/data/app/main.py | 10 +++ services/data/app/providers/base.py | 7 ++ services/data/app/providers/excel_provider.py | 26 +++++++- services/data/tests/test_bases.py | 50 ++++++++++++++ services/logic/app/clients/data_client.py | 6 ++ services/logic/app/main.py | 13 ++++ .../presentation/app/clients/logic_client.py | 6 ++ services/presentation/app/main.py | 17 +++++ services/presentation/app/templates/base.html | 1 + .../presentation/app/templates/settings.html | 51 ++++++++++++++ .../presentation/tests/test_settings_bases.py | 36 ++++++++++ 12 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 services/data/app/bases.py create mode 100644 services/data/tests/test_bases.py create mode 100644 services/presentation/app/templates/settings.html create mode 100644 services/presentation/tests/test_settings_bases.py diff --git a/services/data/app/bases.py b/services/data/app/bases.py new file mode 100644 index 0000000..997de23 --- /dev/null +++ b/services/data/app/bases.py @@ -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 diff --git a/services/data/app/main.py b/services/data/app/main.py index 8d9f5c9..0ecdbda 100644 --- a/services/data/app/main.py +++ b/services/data/app/main.py @@ -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() diff --git a/services/data/app/providers/base.py b/services/data/app/providers/base.py index 9ce9c87..794c050 100644 --- a/services/data/app/providers/base.py +++ b/services/data/app/providers/base.py @@ -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 [] diff --git a/services/data/app/providers/excel_provider.py b/services/data/app/providers/excel_provider.py index 93c4e85..d9c156f 100644 --- a/services/data/app/providers/excel_provider.py +++ b/services/data/app/providers/excel_provider.py @@ -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: diff --git a/services/data/tests/test_bases.py b/services/data/tests/test_bases.py new file mode 100644 index 0000000..4e59810 --- /dev/null +++ b/services/data/tests/test_bases.py @@ -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 diff --git a/services/logic/app/clients/data_client.py b/services/logic/app/clients/data_client.py index 61f67ec..1bf84e8 100644 --- a/services/logic/app/clients/data_client.py +++ b/services/logic/app/clients/data_client.py @@ -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. diff --git a/services/logic/app/main.py b/services/logic/app/main.py index 347be78..b4fd46b 100644 --- a/services/logic/app/main.py +++ b/services/logic/app/main.py @@ -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 diff --git a/services/presentation/app/clients/logic_client.py b/services/presentation/app/clients/logic_client.py index 79d37f3..7984c5b 100644 --- a/services/presentation/app/clients/logic_client.py +++ b/services/presentation/app/clients/logic_client.py @@ -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: diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py index d3eb06d..c82cb8e 100644 --- a/services/presentation/app/main.py +++ b/services/presentation/app/main.py @@ -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): diff --git a/services/presentation/app/templates/base.html b/services/presentation/app/templates/base.html index 80a9ec4..c771609 100644 --- a/services/presentation/app/templates/base.html +++ b/services/presentation/app/templates/base.html @@ -25,6 +25,7 @@ Synastria Sygnifikatory Skompiluj + Ustawienia {% block content %}{% endblock %} diff --git a/services/presentation/app/templates/settings.html b/services/presentation/app/templates/settings.html new file mode 100644 index 0000000..5d3c10d --- /dev/null +++ b/services/presentation/app/templates/settings.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}Ustawienia{% endblock %} +{% block nav_settings %}active{% endblock %} + +{% block content %} +

Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.

+ +{% if error %}
{{ error }}
{% endif %} + +{% if result %} +
Bazy na udziale: {{ result.total }} · + biorą udział w interpretacji: {{ result.enabled }} + {% if result.total and result.enabled < result.total %} + · wyłączonych: {{ result.total - result.enabled }} + {% endif %} +
+ + {% if result.bases %} + + + + {% for b in result.bases %} + + + + + + + + {% endfor %} + +
BazaŚcieżkaRozmiarZmienionoW interpretacji
{{ b.name }}{{ b.path }}{% if b.size_mb is not none %}{{ b.size_mb }} MB{% else %}—{% endif %}{{ b.modified or '—' }}{% if b.enabled %}tak{% else %}wyłączona{% endif %}
+ {% else %} +

Na udziale nie widać żadnych baz (albo aktywny dostawca nie operuje na plikach).

+ {% endif %} + +
Jak wyłączyć bazę
+

+ Lista wyłączonych jest deklaratywna — trzyma ją zmienna + DISABLED_BASES usługi danych (nazwy plików po przecinku, + np. stara_baza.xlsx,archiwum/testowa.xlsx). Po zmianie + zrestartuj warstwę danych. +

+

+ Dlaczego nie klikaniem: udział z bazami jest montowany tylko do odczytu, a katalog + cache to emptyDir — 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. +

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