"""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 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("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"), ) ALL: tuple[Feature, ...] = SCREENS + EXTRAS 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, ("POST", "/horoscope/stream"): "ai", ("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, } # ── 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: `models.js` # wymienia dostawców modeli, `compile.js` — składanie raportu. 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, "x-ai.css": "ai", # 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", "models.js": "ai", "progress.js": "ai", "natal.js": "ai", "predictions.js": "ai", # 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ę. } 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)