"""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 kolejnosc: int = 0 # pozycja w nawigacji; nie zależy od kolejności importów # ── rejestr: katalog składa się z tego, co zgłoszą ekrany ──────────────── # Dawniej wszystkie funkcje były wypisane TUTAJ. To znaczyło, że obraz produktu, # który części z nich nie ma, i tak niósł ich nazwy — czyli spis funkcji, których # nie ma jak włączyć. Teraz ekran zgłasza siebie sam, przy imporcie swojego # modułu, więc katalog opisuje dokładnie ten produkt, który się zbudowało. # # `kolejnosc` jest jawna, bo o zawartości decyduje lista importów w `main.py`, # a kolejność importów nie może rządzić kolejnością zakładek w nawigacji. SCREENS: list[Feature] = [] # Pozycje nawigacji dla uprawnień NIENADAWALNYCH — tych, których nie ma w # GRANTABLE, więc nie da się ich przyznać z żadnego ekranu. Osobno od SCREENS # i wraz z wymaganym uprawnieniem, żeby szablon nie musiał go wypowiadać: wpisane # w szablonie na sztywno zostawiało nazwę ekranu i martwy odsyłacz w produkcie, # który tego ekranu nie ma. NAWIGACJA_SPECJALNA: list[tuple[str, str, str, str]] = [] # (klucz, adres, etykieta, uprawnienie) EXTRAS: list[Feature] = [] ALL: tuple[Feature, ...] = () BY_KEY: dict[str, Feature] = {} GRANTABLE: frozenset[str] = frozenset() ROUTES: dict[tuple[str, str], str | None] = {} STATIC: dict[str, frozenset[str] | str | None] = {} def _przelicz() -> None: global ALL, BY_KEY, GRANTABLE SCREENS.sort(key=lambda f: f.kolejnosc) EXTRAS.sort(key=lambda f: f.kolejnosc) ALL = tuple(SCREENS) + tuple(EXTRAS) BY_KEY = {f.key: f for f in ALL} GRANTABLE = frozenset(BY_KEY) # ADMIN celowo poza tym zbiorem def zarejestruj(*, ekran: Feature | None = None, rozszerzenie: Feature | None = None, trasy: dict[tuple[str, str], str | None] | None = None, zasoby: dict | None = None, nawigacja: tuple[str, str, str, str] | None = None) -> None: """Zgłoszenie funkcji, jej tras i jej zasobów. Idempotentne po kluczu: `export` zgłaszają dwa ekrany (raport do PDF-a i wyniki do arkusza), a wpis ma powstać raz.""" for pozycja, zbior in ((ekran, SCREENS), (rozszerzenie, EXTRAS)): if pozycja and pozycja.key not in {f.key for f in zbior}: zbior.append(pozycja) if nawigacja and nawigacja[0] not in {p[0] for p in NAWIGACJA_SPECJALNA}: NAWIGACJA_SPECJALNA.append(nawigacja) ROUTES.update(trasy or {}) STATIC.update(zasoby or {}) _przelicz() # Funkcje wnoszone przez moduły opcjonalne — zgłaszane tak samo jak ekrany, # tylko przez neutralny most, żeby katalog nie musiał ich wymieniać z nazwy. for _krotka in rozszerzenia.funkcje(): zarejestruj(rozszerzenie=Feature(*_krotka)) zarejestruj(trasy=rozszerzenia.trasy(), zasoby=rozszerzenia.zasoby()) # ── 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. # ── 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"}) 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 kolejności zadeklarowanej, nie w tej, w której akurat wykonały się importy.""" 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)