Files
astrololo/services/presentation/app/features.py
T
gitea 62d6f9d4d5
build / build (push) Failing after 2s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 3s
Testy / Testy astrodemo (push) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 2s
Testy / Kontrola składni wszystkich warstw (push) Failing after 3s
astrololo: generowanie tekstu jako moduł odłączalny (2/5)
Drugi z pięciu kroków budowy trzech produktów. Czysty refaktor — zachowanie
aplikacji się nie zmienia, liczba testów rośnie tylko o nowe.

DLACZEGO WARUNEK W SZABLONIE NIE WYSTARCZA. Dotąd generowanie było chowane przez
`{% if can(request, 'ai') %}`. To sprawia, że funkcji nie WIDAĆ, ale nie że jej
NIE MA: plik szablonu dalej leży w obrazie i dalej zawiera jej nazwy, więc `grep`
po kontenerze pokazuje wszystko, czego warunek nie pokazał na ekranie. Astroklient
ma nie mieć śladu, nie mieć wyłączoną funkcję — więc granica musi być KATALOGIEM.

CO SIĘ PRZENIOSŁO do app/dodatki/: dwa szablony, pięć plików statycznych, trasa
strumienia, cztery metody klienta warstwy logicznej, wpis w katalogu funkcji,
wpisy tras i zasobów, akcje formularza i reguły CSS nazywające funkcję
(`textarea.prompt`).

JAK APLIKACJA O TO PYTA. Neutralny most `app/rozszerzenia.py` odpowiada wyłącznie
na pytanie „czy coś jest podpięte i co wnosi". Sam nie wymienia ani jednej nazwy —
pilnuje tego osobny test, bo most jest w KAŻDYM obrazie. Dlatego też katalog
nazywa się `dodatki`, a nie `ai`: nazwa w instrukcji importu byłaby dokładnie tym
śladem, którego wydzielanie ma się pozbyć.

TRZY MIEJSCA, KTÓRE OKAZAŁY SIĘ TRUDNE:

1. Pola formularza. FastAPI czyta je z SYGNATURY, a wspólny handler nie może
   wymieniać `prompt_budget` ani `llm_provider`. Rozwiązane zależnością: moduł
   deklaruje własne pola u siebie, handler wie tylko, że dostaje słownik.
2. Klient warstwy logicznej jest wspólny, więc metody `prompt`/`horoscope`/
   `llm_models` musiały z niego wyjść. Wspólny klient daje teraz samą drogę
   w dół (`wywolaj`, `pobierz`, `strumien`) — z szyfrowaniem łącza i tokenem
   międzywarstwowym; co nią pojedzie, jest sprawą modułu.
3. Podtytuł ekranu Skompiluj miał wariant „z AI" i wariant bez. Zamiast warunku
   jedno zdanie prawdziwe niezależnie od tego, jakie moduły są w obrazie.

DOCKERFILE. `COPY . .` wnosiło do obrazu także testy — a plik testowy nazywa
funkcje wprost. Teraz wchodzi wyłącznie `app/`.

TEST GRANICY. `test_ai_tylko_w_module.py` przechodzi po wszystkim poza modułem
i szuka siedemnastu słów. Znalazł dwanaście resztek, których nie widziałem:
komentarze w main.py i security.py, przykład `models.js` w komentarzu features.py,
komentarze w compile.js i w trzech arkuszach, oraz regułę `textarea.prompt`,
która przy rozbijaniu CSS trafiła do arkuszy ekranów zamiast do modułu.

Ma też kontrolę pozytywną (te słowa MAJĄ padać w module — inaczej test
przechodziłby, gdyby funkcję wydrążono) i test odłączalności mostu.

SPRAWDZONE NA KOPII BEZ MODUŁU: aplikacja startuje, wszystkie ekrany oddają 200,
katalog ma 13 funkcji zamiast 14, trasy strumienia nie ma, spreparowane
`action=prompt` wraca do akcji domyślnej bez śladu, a `grep` po drzewie nie
znajduje ani jednego ze słów.

Testy: presentation 362, logic 342, data 37, render 41.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 14:25:56 +00:00

211 lines
9.8 KiB
Python

"""Katalog funkcji i uprawnień (PRE-27).
DWIE ZASADY, KTÓRE RZĄDZĄ TYM MODUŁEM
1. Konto ograniczone ma widzieć program KOMPLETNY — tylko mniejszy. Nic nie może
sugerować, że istnieje coś więcej: żadnych wyszarzonych pozycji, żadnego
„brak uprawnień", żadnego 403. Ścieżka bez uprawnienia zwraca **404**, bo 403
jest samo w sobie informacją, że coś tam jest.
2. Mapa trasa → uprawnienie jest JEDNA i leży tutaj. Rozproszenie jej po
dekoratorach kończy się trasą, o której ochronie ktoś zapomniał — a taka
dziura jest niewidoczna, dopóki ktoś jej nie znajdzie. Test przechodzi po
wszystkich trasach aplikacji i wymaga, żeby każda była tu wymieniona.
PODZIAŁ NA GRUPY. Ekrany odpowiadają zakładkom — to naturalna jednostka, bo
zakładka jest tym, co widać w nawigacji. Rozszerzenia to POZIOMY ZŁOŻONOŚCI
wewnątrz ekranów: ktoś może liczyć horoskopy, nie mając dostępu do porównywania
systemów domów ani do generowania tekstu przez model (co kosztuje pieniądze).
"""
from __future__ import annotations
from dataclasses import dataclass
from app import rozszerzenia
ADMIN = "admin" # zakładanie kont i nadawanie uprawnień — nie do nadania z UI
@dataclass(frozen=True)
class Feature:
key: str
label: str
hint: str
group: str
href: str = "" # tylko ekrany; rozszerzenia nie mają własnej zakładki
# ── ekrany (zakładki) ────────────────────────────────────────────────────
SCREENS: tuple[Feature, ...] = (
Feature("chart", "Horoskop", "Pozycje, osie, domy, aspekty, kosmogram.", "Ekrany", "/"),
Feature("interpret", "Interpretacje", "Interpretacja natalna z baz i od modelu.", "Ekrany", "/interpret"),
Feature("timeline", "Kalendarz", "Predykcje na wybrany zakres dat.", "Ekrany", "/timeline"),
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"),
)
# ── rozszerzenia (poziomy złożoności wewnątrz ekranów) ───────────────────
EXTRAS: tuple[Feature, ...] = (
Feature("houses_compare", "Porównanie systemów domów",
"Wybór systemu innego niż domyślny i zestawienie kilku obok siebie.",
"Rozszerzenia"),
Feature("extra_charts", "Wykresy dodatkowe",
"Aspektarian, wykres deklinacji, oś antyscji.", "Rozszerzenia"),
Feature("advanced_calc", "Obliczenia zaawansowane",
"Stacje planet, tabele żywiołów i faz, aspekty poboczne, zodiaki syderyczne.",
"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"),
)
# Funkcje wnoszone przez moduły opcjonalne. Katalog nie wymienia ich z nazwy —
# gdyby wymieniał, nazwa funkcji siedziałaby w kodzie także w obrazie, który tego
# modułu nie zawiera, i `grep` po kontenerze pokazałby coś, czego tam nie ma.
DOKLADANE: tuple[Feature, ...] = tuple(Feature(*k) for k in rozszerzenia.funkcje())
ALL: tuple[Feature, ...] = SCREENS + EXTRAS + DOKLADANE
BY_KEY: dict[str, Feature] = {f.key: f for f in ALL}
GRANTABLE: frozenset[str] = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem
# ── mapa trasa → wymagane uprawnienie ────────────────────────────────────
# None = dostępne każdemu zalogowanemu. Dotyczy to pomocników (geokoder, strefa
# czasowa, health), które same w sobie NICZEGO nie zdradzają o funkcjach programu.
ROUTES: dict[tuple[str, str], str | None] = {
("GET", "/"): None, # korzeń przekierowuje na pierwszy dostępny ekran
("POST", "/"): "chart",
("GET", "/interpret"): "interpret",
("POST", "/interpret"): "interpret",
("GET", "/timeline"): "timeline",
("POST", "/timeline"): "timeline",
("GET", "/synastry"): "synastry",
("POST", "/synastry"): "synastry",
("GET", "/significators"): "significators",
("POST", "/significators"): "significators",
("GET", "/compile"): "compile",
("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,
("GET", "/accounts"): ADMIN,
("POST", "/accounts/create"): ADMIN,
("POST", "/accounts/update"): ADMIN,
("POST", "/accounts/delete"): ADMIN,
("GET", "/geocode"): None,
("GET", "/reverse"): None,
("GET", "/timezone"): None,
("GET", "/health"): None,
# Wylogowanie musi być dostępne dla każdego zalogowanego, niezależnie od tego,
# co mu przyznano — konto bez ani jednego ekranu też ma prawo wyjść.
("GET", "/logowanie"): None,
("POST", "/logowanie"): None,
("POST", "/wyloguj"): None,
("POST", "/accounts/sesje"): ADMIN,
}
# Trasy modułów opcjonalnych. Dokładane po utworzeniu mapy, żeby wpis o module
# nie musiał być wpisany w tym pliku z nazwy.
ROUTES.update(rozszerzenia.trasy())
# ── zasoby statyczne ─────────────────────────────────────────────────────
# Skrypt i arkusz stylów są CZĘŚCIĄ funkcji, nie jej dekoracją. Nazwa pliku jest
# zgadywalna, a treść opowiada o funkcji dokładniej niż przycisk: `compile.js`
# zdradza składanie raportu, a `wheelzoom.js` — istnienie kosmogramu. Dlatego każdy
# zasób przechodzi przez tę samą bramkę co ekrany.
#
# Wartość to ZBIÓR uprawnień, z których wystarczy JEDNO: wyszukiwarka lokalizacji
# siedzi na trzech ekranach i konto z którymkolwiek z nich musi ją dostać.
# `None` = każdy zalogowany. Zasób spoza mapy wymaga admina — jak trasa.
_FORMULARZE = frozenset({"chart", "interpret", "timeline", "compile"})
_LOKALIZACJA = frozenset({"chart", "interpret", "timeline"})
_KOLO = frozenset({"chart", "compile"})
STATIC: dict[str, frozenset[str] | str | None] = {
# arkusze ekranów — jeden ekran, jedno uprawnienie
"s-chart.css": "chart",
"s-interpret.css": "interpret",
"s-timeline.css": "timeline",
"s-synastry.css": "synastry",
"s-significators.css": "significators",
"s-compile.css": "compile",
"s-files.css": "files",
"s-settings.css": "settings",
"s-accounts.css": ADMIN,
# skrypty
"formsync.js": _FORMULARZE,
"now.js": _LOKALIZACJA,
"geo.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.js": _LOKALIZACJA,
"vendor/leaflet/leaflet.css": _LOKALIZACJA,
"wheelzoom.js": _KOLO,
"wheeltip.js": _KOLO,
"copy.js": frozenset({"interpret", "timeline"}),
"compile.js": "compile",
# base.css celowo NIE MA tu wpisu: potrzebuje go ekran logowania, więc jest
# publiczny (PUBLIC_PATHS w security.py). Dlatego nie wolno w nim trzymać
# niczego, co nazywa jakąkolwiek funkcję.
}
# Zasoby modułów opcjonalnych — tak samo jak trasy.
STATIC.update(rozszerzenia.zasoby())
def static_required(name: str) -> frozenset[str] | str | None:
"""Uprawnienie dla zasobu. Nieznany zasób → admin, jak nieznana trasa."""
return STATIC.get(name, ADMIN)
def required(method: str, path: str) -> frozenset[str] | str | None:
"""Uprawnienie wymagane przez trasę. Nieznana trasa → wymaga admina.
Domyślną odpowiedzią jest NAJWĘŻSZE uprawnienie, nie najszersze: dopisanie
trasy bez wpisu w ROUTES ma ją zamknąć, a nie otworzyć. Odwrotny domyślny
wybór oznaczałby, że każde przeoczenie tworzy dziurę."""
if path.startswith("/static/"):
return static_required(path[len("/static/"):])
return ROUTES.get((method.upper(), path), ADMIN)
def can(perms: frozenset[str], feature: frozenset[str] | str | None) -> bool:
"""Czy te uprawnienia otwierają tę funkcję.
Zbiór znaczy „wystarczy którekolwiek": jeden zasób bywa wspólny dla kilku
ekranów i nie ma powodu wymagać ich wszystkich naraz."""
if feature is None:
return True
if isinstance(feature, str):
return feature in perms
return bool(perms & feature)
def screens_for(perms: frozenset[str]) -> list[Feature]:
"""Zakładki do pokazania w nawigacji — w stałej kolejności katalogu."""
return [f for f in SCREENS if f.key in perms]
def normalise(keys) -> frozenset[str]:
"""Zbiór uprawnień z wejścia formularza: tylko znane klucze, bez ADMIN-a.
ADMIN odsiewamy TUTAJ, a nie w handlerze: konto administracyjne pochodzi
wyłącznie z konfiguracji środowiska, więc żadne wysłanie formularza — nawet
spreparowane — nie może go nadać."""
return frozenset(k for k in (keys or ()) if k in GRANTABLE)