pillarz 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. pillarz/__init__.py +618 -0
  2. pillarz/__main__.py +176 -0
  3. pillarz/_api/__init__.py +15 -0
  4. pillarz/_api/account.py +66 -0
  5. pillarz/_api/admin.py +55 -0
  6. pillarz/_api/admin_links.py +24 -0
  7. pillarz/_api/admin_log.py +41 -0
  8. pillarz/_api/admin_sessions.py +43 -0
  9. pillarz/_api/admin_settings.py +68 -0
  10. pillarz/_api/admin_themes.py +96 -0
  11. pillarz/_api/admin_users.py +357 -0
  12. pillarz/_api/common.py +37 -0
  13. pillarz/_api/me.py +203 -0
  14. pillarz/_api/tokens.py +98 -0
  15. pillarz/_auth/__init__.py +4 -0
  16. pillarz/_auth/accounts.py +336 -0
  17. pillarz/_auth/devices.py +51 -0
  18. pillarz/_auth/flows.py +123 -0
  19. pillarz/_auth/identity.py +66 -0
  20. pillarz/_auth/linking.py +253 -0
  21. pillarz/_auth/oidc.py +482 -0
  22. pillarz/_auth/oidc_accounts.py +197 -0
  23. pillarz/_auth/oidc_routes.py +363 -0
  24. pillarz/_auth/passwords.py +146 -0
  25. pillarz/_auth/roles.py +64 -0
  26. pillarz/_auth/routes.py +398 -0
  27. pillarz/_auth/secretbox.py +52 -0
  28. pillarz/_auth/sessions.py +514 -0
  29. pillarz/_auth/setup.py +217 -0
  30. pillarz/_auth/stepup.py +46 -0
  31. pillarz/_auth/throttle.py +193 -0
  32. pillarz/_auth/tokens.py +126 -0
  33. pillarz/_db/__init__.py +1 -0
  34. pillarz/_db/clock.py +67 -0
  35. pillarz/_db/connection.py +338 -0
  36. pillarz/_db/m0001_initial.py +194 -0
  37. pillarz/_db/m0002_sessions.py +19 -0
  38. pillarz/_db/m0003_oidc_origin.py +27 -0
  39. pillarz/_db/migrations.py +274 -0
  40. pillarz/_events.py +128 -0
  41. pillarz/_i18n/__init__.py +11 -0
  42. pillarz/_i18n/babel-js.cfg +4 -0
  43. pillarz/_i18n/babel.cfg +8 -0
  44. pillarz/_i18n/catalogs.py +221 -0
  45. pillarz/_i18n/jinja.py +41 -0
  46. pillarz/_i18n/js.py +43 -0
  47. pillarz/_i18n/request.py +113 -0
  48. pillarz/_i18n/tools.py +200 -0
  49. pillarz/_protocol.py +351 -0
  50. pillarz/_runtime.py +86 -0
  51. pillarz/_settings.py +272 -0
  52. pillarz/_themes.py +218 -0
  53. pillarz/_web/__init__.py +1 -0
  54. pillarz/_web/app.py +82 -0
  55. pillarz/_web/context.py +14 -0
  56. pillarz/_web/csp.py +148 -0
  57. pillarz/_web/errors.py +169 -0
  58. pillarz/_web/gate.py +150 -0
  59. pillarz/_web/guard.py +253 -0
  60. pillarz/_web/headers.py +63 -0
  61. pillarz/_web/net.py +48 -0
  62. pillarz/_web/pages.py +30 -0
  63. pillarz/_web/render.py +341 -0
  64. pillarz/_web/static.py +40 -0
  65. pillarz/accounts.py +106 -0
  66. pillarz/config.py +377 -0
  67. pillarz/db.py +375 -0
  68. pillarz/events.py +228 -0
  69. pillarz/i18n.py +126 -0
  70. pillarz/locale/en/LC_MESSAGES/pillarz-js.po +1119 -0
  71. pillarz/locale/en/LC_MESSAGES/pillarz.po +1894 -0
  72. pillarz/locale/pillarz-js.pot +1106 -0
  73. pillarz/locale/pillarz.pot +1816 -0
  74. pillarz/settings.py +33 -0
  75. pillarz/static/pillarz/css/base.css +587 -0
  76. pillarz/static/pillarz/favicon.svg +1 -0
  77. pillarz/static/pillarz/js/account.js +337 -0
  78. pillarz/static/pillarz/js/admin-links.js +158 -0
  79. pillarz/static/pillarz/js/admin.js +627 -0
  80. pillarz/static/pillarz/js/api.js +96 -0
  81. pillarz/static/pillarz/js/forms.js +206 -0
  82. pillarz/static/pillarz/js/i18n.js +158 -0
  83. pillarz/static/pillarz/js/token.js +148 -0
  84. pillarz/static/pillarz/js/ui.js +540 -0
  85. pillarz/static/pillarz/vendor/lucide/LICENSE +43 -0
  86. pillarz/templates/pillarz/account.html +26 -0
  87. pillarz/templates/pillarz/admin.html +48 -0
  88. pillarz/templates/pillarz/bare.html +25 -0
  89. pillarz/templates/pillarz/base.html +38 -0
  90. pillarz/templates/pillarz/error.html +15 -0
  91. pillarz/templates/pillarz/icons.svg +66 -0
  92. pillarz/templates/pillarz/login.html +39 -0
  93. pillarz/templates/pillarz/partials/account/delete.html +5 -0
  94. pillarz/templates/pillarz/partials/account/devices.html +9 -0
  95. pillarz/templates/pillarz/partials/account/language.html +15 -0
  96. pillarz/templates/pillarz/partials/account/login.html +56 -0
  97. pillarz/templates/pillarz/partials/account/look.html +17 -0
  98. pillarz/templates/pillarz/partials/account/profile.html +17 -0
  99. pillarz/templates/pillarz/partials/flash.html +8 -0
  100. pillarz/templates/pillarz/partials/nav.html +34 -0
  101. pillarz/templates/pillarz/plain.html +16 -0
  102. pillarz/templates/pillarz/setup.html +51 -0
  103. pillarz/templates/pillarz/token.html +22 -0
  104. pillarz/testing/__init__.py +236 -0
  105. pillarz/testing/_web.py +113 -0
  106. pillarz/testing/fakeidp.py +362 -0
  107. pillarz/text.py +81 -0
  108. pillarz/web.py +246 -0
  109. pillarz-0.1.0.dist-info/METADATA +71 -0
  110. pillarz-0.1.0.dist-info/RECORD +113 -0
  111. pillarz-0.1.0.dist-info/WHEEL +5 -0
  112. pillarz-0.1.0.dist-info/licenses/LICENSE +661 -0
  113. pillarz-0.1.0.dist-info/top_level.txt +1 -0
pillarz/__init__.py ADDED
@@ -0,0 +1,618 @@
1
+ """pillarz – gemeinsames Fundament für FastAPI-Webservices (ADR 0001).
2
+
3
+ Eine App erzeugt **genau ein** `Pillarz`-Objekt je Prozess, meldet daran ihre Erweiterungen an und baut
4
+ zum Schluss mit `create_app()` die FastAPI-Instanz::
5
+
6
+ from pathlib import Path
7
+ from pillarz import Pillarz
8
+ from . import __version__, migrations
9
+
10
+ pz = Pillarz(name="labelz", title="Labelz", version=__version__,
11
+ package_dir=Path(__file__).resolve().parent, migrations=migrations.STEPS)
12
+ app = pz.create_app()
13
+
14
+ Der Import von pillarz hat keine Nebenwirkungen: Konfiguration, Datenverzeichnis und Schlüssel
15
+ entstehen erst beim Erzeugen von `Pillarz(...)` (`Config.from_env()`), die Datenbank erst im
16
+ Lifespan bzw. bei `python -m pillarz migrate`.
17
+ """
18
+ import logging
19
+ import re
20
+ from collections.abc import Callable
21
+ from contextlib import asynccontextmanager
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ from . import _runtime
26
+ from .config import Config
27
+ from .events import DeleteInput
28
+ from .i18n import lazy
29
+
30
+ __version__ = "0.1.0"
31
+
32
+ __all__ = ["Pillarz", "NavItem", "AdminTab", "AccountSection", "SettingsSection", "DeleteInput", "Setting", "Theme"]
33
+
34
+ log = logging.getLogger("pillarz.system")
35
+
36
+ _NAME_RE = re.compile(r"^[a-z][a-z0-9_-]{0,31}$")
37
+ _KEY_RE = re.compile(r"^[a-z][a-z0-9_-]{0,39}$")
38
+ DEFAULT_AUTH_METHODS = ("password", "oidc")
39
+
40
+
41
+ # ------------------------------------------------------------------ Datenklassen der Registrierungen
42
+ # Vorerst nur die Hüllen (Meilensteinplan 0.1, Paket A): der Schlüssel steht fest, die übrigen Felder
43
+ # kommen mit den Paketen, die sie nutzen (Architektur §3.3–§3.8).
44
+ @dataclass(frozen=True)
45
+ class NavItem:
46
+ """Navigationseintrag der App (Architektur §3.3), nur für Angemeldete sichtbar::
47
+
48
+ pz.nav(NavItem("library", "/", _l("Bibliothek"), icon="library", order=10))
49
+
50
+ - `icon`: Name eines Symbols `i-<icon>` im Sprite (pillarz' `icons.svg` oder `pz.icons()`).
51
+ - `visible(request, user) -> bool`: Eintrag nur zeigen, wenn wahr. `user` ist der angemeldete
52
+ `pillarz.accounts.User` (z. B. `lambda request, user: not user.is_guest`).
53
+ - `badge(request, user) -> int`: Zahl am Eintrag (nur auf Seiten mit Navigation berechnet; 0 = keine),
54
+ `badge_href` Ziel des Badges (Standard `href`), `badge_label(n) -> str` zugänglicher Name.
55
+ """
56
+ key: str
57
+ href: str = "/"
58
+ label: object = ""
59
+ icon: str | None = None
60
+ order: int = 100
61
+ visible: Callable | None = None
62
+ badge: Callable | None = None
63
+ badge_href: str | None = None
64
+ badge_label: Callable | None = None
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class AdminTab:
69
+ """Eigener Admin-Reiter (Architektur §3.3, §14.10)::
70
+
71
+ pz.admin_tab(AdminTab("sheets", _l("Bogenformate"), order=30, icon="ruler",
72
+ template="admin/sheets.html", module="/static/js/admin-sheets.js"))
73
+
74
+ `template` (App-Template, wird in den Reiter gerendert) und `module` (ES-Modul unter `/static/…`) sind
75
+ optional; das Modul exportiert `init(panel, ctx)` und wird beim ersten Öffnen per `import()` geladen.
76
+ Adresse `/admin#<key>` bzw. `/admin#<key>-<id>`. pillarz-Reiter: Konten (10), Geräte (20),
77
+ authentik-Links (30, nur mit aktivem authentik), Themes (50), Einstellungen (80), Protokoll (90); App-Reiter
78
+ stehen nach `order` dazwischen oder danach. Nur für Admins (`require_admin` inkl. befristeter
79
+ authentik-Admins); `visible(request, user)` blendet aus."""
80
+ key: str
81
+ label: object = ""
82
+ order: int = 100
83
+ icon: str | None = None
84
+ template: str | None = None
85
+ module: str | None = None
86
+ visible: Callable | None = None
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class AccountSection:
91
+ """Eigener Abschnitt der Kontoseite `/account` (Architektur §3.3, §12)::
92
+
93
+ pz.account_section(AccountSection("printing", _l("Druck"), order=50,
94
+ template="account/printing.html", module="/static/js/account-printing.js"))
95
+
96
+ pillarz-Abschnitte (Profil 10, Darstellung 20, Sprache 30, Anmeldung 40, Geräte 60, Konto löschen 90)
97
+ stehen fest; App-Abschnitte nach `order`. Das Modul exportiert `init(section, ctx)`. `visible(request,
98
+ user)` blendet aus (z. B. nicht für Gäste)."""
99
+ key: str
100
+ label: object = ""
101
+ order: int = 100
102
+ template: str | None = None
103
+ module: str | None = None
104
+ visible: Callable | None = None
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class SettingsSection:
109
+ """App-Abschnitt im Admin-Reiter „Einstellungen“ (Architektur §3.8, P-102)::
110
+
111
+ pz.settings_section(SettingsSection("printing", _l("Druck"), order=50,
112
+ keys=("default_font", "default_printer_id"),
113
+ template="admin/settings-printing.html", module="/static/js/settings-printing.js"))
114
+
115
+ `keys` sind registrierte `Setting`s, die der Abschnitt bearbeitet (je Schlüssel höchstens ein Abschnitt,
116
+ sonst Startabbruch). Das Modul exportiert `init(section, ctx)`; gespeichert wird über
117
+ `ctx.save({schlüssel: wert})` (`PATCH /api/pz/admin/settings`, 422 mit `field`)."""
118
+ key: str
119
+ label: object = ""
120
+ order: int = 100
121
+ keys: tuple = ()
122
+ template: str | None = None
123
+ module: str | None = None
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class Setting:
128
+ """Registrierte App-Einstellung (Architektur §3.8)::
129
+
130
+ pz.setting(Setting("show_names", default=False, kind="bool", label=_l("Namen zeigen"), ui=True))
131
+ pz.setting(Setting("default_sheet_id", default=None, kind="custom", validate=sheets.check_default))
132
+
133
+ - `kind`: `text` (einzeilig, `max_length`, `required`), `int` (`min`/`max`), `bool`, `choice`
134
+ (`options` = `[(wert, beschriftung), …]` oder Funktion, die das liefert; Werte dürfen auch Zahlen
135
+ sein), `custom` (beliebiges JSON, **`validate` Pflicht**).
136
+ - `validate(value) -> value`: zusätzlicher Prüfer; `ValueError(text)` lehnt mit `text` am Feld ab. Lehnt
137
+ er einen schon gespeicherten Wert ab, gilt der Standard (labelz `effective()`).
138
+ - `ui=True`: erscheint mit `label`/`help` im Reiter „Einstellungen“ (nicht bei `custom` – dafür
139
+ `SettingsSection`)."""
140
+ key: str
141
+ default: object = None
142
+ kind: str = "text"
143
+ label: object = ""
144
+ help: object = None
145
+ ui: bool = False
146
+ validate: Callable | None = None
147
+ options: object = ()
148
+ required: bool = False
149
+ max_length: int = 200
150
+ min: int | None = None
151
+ max: int | None = None
152
+
153
+
154
+ @dataclass(frozen=True)
155
+ class Theme:
156
+ """Weiteres eingebautes Theme (Architektur §14.5): vier Grundfarben je Modus als `#rrggbb`::
157
+
158
+ pz.theme(Theme("etikett", _l("Etikett"),
159
+ light={"bg": "#…", "surface": "#…", "ink": "#…", "accent": "#…"}, dark={…}))
160
+ """
161
+ id: str
162
+ name: object = ""
163
+ light: dict = field(default_factory=dict)
164
+ dark: dict = field(default_factory=dict)
165
+
166
+
167
+ _ADMIN_TABS = ("users", "links", "sessions", "themes", "settings", "log")
168
+ _ACCOUNT_SECTIONS = ("profile", "look", "language", "login", "devices", "delete")
169
+
170
+
171
+ def _check_extension(where: str, item, existing, reserved) -> None:
172
+ """Schlüssel, Template und Modul von Admin-Reitern, Konto- und Einstellungsabschnitten prüfen."""
173
+ if not isinstance(item.key, str) or not _KEY_RE.fullmatch(item.key) or item.key in reserved \
174
+ or any(e.key == item.key for e in existing):
175
+ raise ValueError(f"{where}(): Schlüssel {item.key!r} ist ungültig, von pillarz belegt oder schon vergeben")
176
+ if not isinstance(item.order, int) or isinstance(item.order, bool):
177
+ raise ValueError(f"{where}({item.key!r}): order muss eine ganze Zahl sein")
178
+ module = item.module
179
+ if module is not None and (not isinstance(module, str) or not module.startswith("/static/")
180
+ or module.startswith("/static/pillarz/") or ".." in module
181
+ or not module.endswith(".js")):
182
+ raise ValueError(f"{where}({item.key!r}): module muss ein ES-Modul der App unter /static/…/*.js sein")
183
+ template = item.template
184
+ if template is not None and (not isinstance(template, str) or template.startswith(("/", "pillarz/"))
185
+ or ".." in template):
186
+ raise ValueError(f"{where}({item.key!r}): template muss ein Template der App sein (relativ, nicht pillarz/…)")
187
+
188
+
189
+ # ------------------------------------------------------------------ das App-Objekt
190
+ class Pillarz:
191
+ """Das eine pillarz-Objekt einer App.
192
+
193
+ - `name`: Kennung (Cookie, Logger, Standard-Dateiname der SQLite-Datenbank `DATA_DIR/<name>.db`),
194
+ klein, `[a-z][a-z0-9_-]*`, höchstens 32 Zeichen; `pillarz` ist reserviert.
195
+ - `title`: Standard-Seitenname, `version`: Version der App.
196
+ - `package_dir`: Verzeichnis der App mit `templates/`, `static/`, `locale/` (je optional).
197
+ - `migrations`: geordnete App-Migrationen `[("0001_initial", step), …]`, `step(conn)` (ADR 0003).
198
+ - `auth_methods`: Vorgabe für die Anmeldemethoden; `AUTH_METHODS` in der Umgebung überschreibt.
199
+ - `weak_passwords`: ergänzt pillarz' Liste verbotener Platzhalter-Passwörter.
200
+ - `self_delete`: Nutzer dürfen ihr eigenes Konto löschen (Kontoseite, ADR 0006; Standard an). `False`
201
+ blendet den Abschnitt aus, `GET/DELETE /api/pz/me/delete` bzw. `/api/pz/me` antworten 404.
202
+ - `environ`: nur für Tests – statt `os.environ`.
203
+ """
204
+
205
+ def __init__(self, *, name: str, title: str | None = None, version: str = "0", package_dir=None,
206
+ migrations=(), auth_methods=DEFAULT_AUTH_METHODS, weak_passwords=(), self_delete: bool = True,
207
+ environ=None):
208
+ # TODO(0.2): changelog und weitere Parameter aus Architektur §3.1
209
+ from ._db.migrations import validate
210
+ _runtime.check_free()
211
+ if not isinstance(name, str) or not _NAME_RE.fullmatch(name) or name == "pillarz":
212
+ raise ValueError(f"Pillarz(name=…): {name!r} ist keine gültige Kennung "
213
+ "(klein, [a-z][a-z0-9_-]*, höchstens 32 Zeichen, nicht 'pillarz')")
214
+ self.name = name
215
+ self.title = title or name
216
+ self.version = str(version)
217
+ self.package_dir = Path(package_dir).resolve() if package_dir is not None else None
218
+ self.migrations = validate(migrations, "app")
219
+ self.weak_passwords = frozenset(w.strip().lower() for w in weak_passwords if w and w.strip())
220
+ self.self_delete = bool(self_delete)
221
+ self._registry = _runtime.Registry()
222
+ self._app = None
223
+ self._web = None # _web.app.WebState, ab create_app()
224
+ self._setup_closed = False # Einrichtung (ADR 0015) als „zu“ erkannt – wird nie wieder offen
225
+ self.config: Config = Config.from_env(environ, name=name, auth_methods=tuple(auth_methods))
226
+ _runtime.install(self)
227
+
228
+ def __repr__(self) -> str:
229
+ return f"<Pillarz {self.name!r} {self.version}>"
230
+
231
+ # -------------------------------------------------------------- Registrierungen
232
+ def on_startup(self, fn: Callable[[], None]) -> Callable[[], None]:
233
+ """Funktion nach den Migrationen und vor dem ersten Request aufrufen (als Dekorator nutzbar)."""
234
+ self._registry.add("startup", fn)
235
+ return fn
236
+
237
+ def on_shutdown(self, fn: Callable[[], None]) -> Callable[[], None]:
238
+ """Funktion beim Herunterfahren aufrufen (umgekehrte Reihenfolge der Anmeldung)."""
239
+ self._registry.add("shutdown", fn)
240
+ return fn
241
+
242
+ def nav(self, item: NavItem) -> NavItem:
243
+ """Navigationseintrag anmelden (Architektur §3.3). pillarz hängt rechts Konto, Admin und Abmelden an."""
244
+ if not isinstance(item, NavItem):
245
+ raise TypeError("pz.nav() erwartet ein NavItem")
246
+ if not _KEY_RE.fullmatch(item.key) or any(i.key == item.key for i in self._registry.items("nav")):
247
+ raise ValueError(f"pz.nav(): Schlüssel {item.key!r} ist ungültig oder schon vergeben")
248
+ if not isinstance(item.href, str) or not item.href.startswith("/") or item.href.startswith("//"):
249
+ raise ValueError(f"pz.nav({item.key!r}): href muss ein Pfad dieser Seite sein (/…)")
250
+ self._registry.add("nav", item)
251
+ return item
252
+
253
+ def csp(self, **directives) -> None:
254
+ """CSP der App ergänzen, z. B. `pz.csp(img_src=["blob:"], worker_src=["'self'"])`. Lockern ist
255
+ unmöglich: `'unsafe-*'` (außer `'wasm-unsafe-eval'`), `*`, reine Schemata in `script-src`, `javascript:`,
256
+ in `worker-src`/`child-src` mehr als `'self'`/`blob:`, Hashes in `style-src`, bei reinem HTTPS
257
+ `http://`/`ws://`-Hosts und jede Änderung an `object-src`/`base-uri`/`frame-ancestors` →
258
+ `ValueError` (Startabbruch, ADR 0004 §5)."""
259
+ from ._web.csp import check
260
+ self._registry.add("csp", check(directives, https_only=self.config.https_only))
261
+
262
+ def upload_limit(self, path, size: int) -> None:
263
+ """Größere Body-Grenze für einen Upload-Pfad (nur POST, `Content-Length` Pflicht): exakter Pfad,
264
+ Präfix mit `/` am Ende oder `re.compile(…)` (ganzer Pfad). Sonst gilt 1 MiB."""
265
+ from ._web.headers import PathRule
266
+ if not isinstance(size, int) or size <= 0:
267
+ raise ValueError("pz.upload_limit(): Größe in Bytes > 0 erwartet")
268
+ PathRule(path)
269
+ self._registry.add("upload_limit", (path, size))
270
+
271
+ def no_store(self, path) -> None:
272
+ """Antworten auf diesen Pfad (Präfix mit `/` am Ende, exakter Pfad oder `re.compile`) nie
273
+ zwischenspeichern, nie als Referer weitergeben, nicht indexieren – für Link-/Token-Seiten."""
274
+ from ._web.headers import PathRule
275
+ PathRule(path)
276
+ self._registry.add("no_store", path)
277
+
278
+ def public_mount(self, path: str) -> None:
279
+ """Eine eingehängte Unter-App (`app.mount(path, …)`) ohne Anmeldung erreichbar machen.
280
+
281
+ Mounts, Starlette-Routen (`app.add_route`) und `Host`-Routen laufen am Gate vorbei (sie kennen
282
+ keine FastAPI-Dependencies). pillarz behandelt sie deshalb als **geschützt**: der Guard weist
283
+ anonyme Anfragen an sie ab (401 bzw. 303 auf `/login`), `testing.route_audit()` meldet sie.
284
+ Nur so freigegebene Mount-Pfade (genau der Pfad aus `app.mount`, z. B. `"/docs-static"`) und
285
+ pillarz' eigene Static-Mounts sind öffentlich (ADR 0004 §2)."""
286
+ if not isinstance(path, str) or not path.startswith("/") or path.startswith("//"):
287
+ raise ValueError(f"pz.public_mount(): {path!r} ist kein Pfad (/…)")
288
+ self._registry.add("public_mount", path.rstrip("/") or "/")
289
+
290
+ def boot(self, fn: Callable) -> Callable:
291
+ """`fn(request, user) -> dict` ergänzt `boot.app` im `#boot`-Block (nur für Angemeldete; `user` ist
292
+ ein `pillarz.accounts.User`)."""
293
+ self._registry.add("boot", fn)
294
+ return fn
295
+
296
+ def context(self, fn: Callable) -> Callable:
297
+ """Anfrage-Kontext der App (P-9): `fn(request, user) -> dict` liefert Werte für den Template-Kontext
298
+ jeder Seite und für die Routen (`pillarz.web.request_context(request)`), z. B. Navigations-Badges::
299
+
300
+ @pz.context
301
+ def badges(request, user):
302
+ return {"nav_answers": prints.needing_answer(user.id)}
303
+
304
+ - Nur für Angemeldete (Sitzung oder App-Authenticator); `user` ist ein `pillarz.accounts.User`. Ohne
305
+ Anmeldung wird der Haken nie aufgerufen, der Kontext ist `{}`.
306
+ - **Höchstens einmal je Request und erst bei Bedarf**: beim ersten `render()` bzw.
307
+ `request_context()`; API-Routen, die beides nicht nutzen, zahlen nichts. Das Ergebnis liegt danach
308
+ auch in `request.state.app_context`.
309
+ - Mehrere Haken werden in Anmeldereihenfolge zusammengeführt (spätere überschreiben frühere).
310
+ Schlüssel, die pillarz im Template-Kontext selbst setzt (`user`, `nav`, `boot` …), sind verboten
311
+ (`RuntimeError`); Schlüssel aus `render(**ctx)` haben Vorrang vor dem Haken.
312
+ - Eine Ausnahme im Haken ist ein Fehler der Anfrage: 500 mit pillarz-Fehlerseite und Eintrag im
313
+ Protokoll (nichts wird still verschluckt). Das `User`-Objekt bleibt unverändert."""
314
+ if not callable(fn):
315
+ raise TypeError("pz.context(): erwartet eine Funktion fn(request, user) -> dict")
316
+ self._registry.add("context", fn)
317
+ return fn
318
+
319
+ def secret_user_column(self, name: str) -> None:
320
+ """Eine (app-eigene oder übernommene) Spalte von `users` als **geheim** markieren (S-N6): sie landet
321
+ nie in pillarz' privater Kontozeile der Anfrage, in Templates oder JSON-Antworten. `password_hash`
322
+ trägt pillarz selbst ein (ab 0.4 auch die TOTP-Spalten). `current_user()` liefert ohnehin nur die
323
+ öffentlichen Spalten (`User`)::
324
+
325
+ pz.secret_user_column("api_token_hash")
326
+ """
327
+ from ._db.connection import ident
328
+ ident(name)
329
+ if name in self._registry.items("secret_user_column"):
330
+ raise ValueError(f"pz.secret_user_column(): {name!r} ist schon eingetragen")
331
+ self._registry.add("secret_user_column", name)
332
+
333
+ def log_source(self, logger: str, source: str, label) -> None:
334
+ """Logger der App einem Protokoll-Bereich zuordnen, z. B.
335
+ `pz.log_source("labelz.direct", "direct", _l("Direktdruck"))`. Nur Logger unter `<name>`."""
336
+ if not (logger == self.name or logger.startswith(self.name + ".")):
337
+ raise ValueError(f"pz.log_source(): Logger {logger!r} gehört nicht zur App {self.name!r}")
338
+ if not _KEY_RE.fullmatch(source):
339
+ raise ValueError(f"pz.log_source(): Bereich {source!r} ist ungültig ([a-z][a-z0-9_-]*)")
340
+ self._registry.add("log_source", (logger, source, label))
341
+
342
+ def theme(self, theme: Theme) -> Theme:
343
+ """Weiteres eingebautes Theme anmelden (nach pillarz' fünf, in Anmeldereihenfolge)."""
344
+ from ._themes import check_registration
345
+ if not isinstance(theme, Theme):
346
+ raise TypeError("pz.theme() erwartet ein Theme")
347
+ check_registration(theme)
348
+ if any(t.id == theme.id for t in self._registry.items("theme")):
349
+ raise ValueError(f"pz.theme(): {theme.id!r} ist schon angemeldet")
350
+ self._registry.add("theme", theme)
351
+ return theme
352
+
353
+ def icons(self, path) -> None:
354
+ """Weitere `<symbol id="i-…">` aus einer SVG-Datei ins Sprite jeder Seite aufnehmen."""
355
+ path = Path(path)
356
+ if not path.is_file():
357
+ raise ValueError(f"pz.icons(): {path} gibt es nicht")
358
+ self._registry.add("icons", path)
359
+
360
+ # -------------------------------------------------------------- Konten, Events, Verwaltung (Paket E)
361
+ def on(self, event: str, fn: Callable | None = None, *, order: int = 100):
362
+ """Event-Handler anmelden (ADR 0006, `pillarz.events`), als Dekorator::
363
+
364
+ @pz.on("user.deleting", order=10)
365
+ def transfer(ev): …
366
+
367
+ tx-Events (`user.created`, `user.updated`, `user.deactivating`, `user.reactivated`, `user.deleting`)
368
+ laufen in der pillarz-Transaktion: `ev.conn` bzw. jeder `pillarz.db.connect()` auf diesem Thread ist
369
+ dieselbe Verbindung; **keine Threads starten** (`pillarz.db.TxThreadError`); Datei-Arbeit gehört in
370
+ die Nach-Commit-Events `user.deleted`, `user.signed_in`, `user.signed_out`, deren Fehler nur
371
+ protokolliert werden. Reihenfolge: `order`, dann Anmeldung."""
372
+ from ._events import check_name
373
+ check_name(event)
374
+ if not isinstance(order, int) or isinstance(order, bool):
375
+ raise ValueError("pz.on(): order muss eine ganze Zahl sein")
376
+
377
+ def register(handler: Callable) -> Callable:
378
+ from ._events import handler_name
379
+ if not callable(handler):
380
+ raise TypeError("pz.on() erwartet eine Funktion")
381
+ name = handler_name(handler)
382
+ if any(e == event and handler_name(fn) == name for e, fn, _o in self._registry.items("event")):
383
+ # Rückgaben landen unter diesem Namen in user.deleted.result – doppelt hieße überschrieben (C-L7)
384
+ raise ValueError(f"pz.on({event!r}): Handler {name} ist für dieses Event schon angemeldet")
385
+ self._registry.add("event", (event, handler, order))
386
+ return handler
387
+
388
+ return register(fn) if fn is not None else register
389
+
390
+ def delete_input(self, item: DeleteInput) -> DeleteInput:
391
+ """Zusatzangabe für den Löschdialog anmelden (ADR 0006, `pillarz.events.DeleteInput`)."""
392
+ if not isinstance(item, DeleteInput):
393
+ raise TypeError("pz.delete_input() erwartet ein DeleteInput")
394
+ if not _KEY_RE.fullmatch(item.key) or any(i.key == item.key for i in self._registry.items("delete_input")):
395
+ raise ValueError(f"pz.delete_input(): Schlüssel {item.key!r} ist ungültig oder schon vergeben")
396
+ if item.kind not in ("user", "choice", "bool", "text"):
397
+ raise ValueError(f"pz.delete_input({item.key!r}): kind muss user, choice, bool oder text sein")
398
+ if item.on_self not in (None, "ask", "default"):
399
+ raise ValueError(f"pz.delete_input({item.key!r}): on_self muss 'ask' oder 'default' sein")
400
+ if item.kind == "user" and item.on_self == "ask":
401
+ raise ValueError(f"pz.delete_input({item.key!r}): kind='user' wird bei Selbstlöschung nie abgefragt "
402
+ "(on_self='default' mit self_default)")
403
+ if item.self_default == "actor":
404
+ raise ValueError(f"pz.delete_input({item.key!r}): self_default='actor' wäre das gelöschte Konto")
405
+ if item.kind == "choice" and not item.options:
406
+ raise ValueError(f"pz.delete_input({item.key!r}): kind='choice' braucht options")
407
+ if item.visible is not None and not callable(item.visible):
408
+ raise ValueError(f"pz.delete_input({item.key!r}): visible muss eine Funktion (user, conn) sein")
409
+ if not isinstance(item.max_length, int) or not 1 <= item.max_length <= 1000:
410
+ raise ValueError(f"pz.delete_input({item.key!r}): max_length 1–1000")
411
+ self._registry.add("delete_input", item)
412
+ return item
413
+
414
+ def request_authenticator(self, name: str, *, max_role: str = "user", methods=("GET", "HEAD")):
415
+ """Eigenen Anmeldeweg für Spezialfälle anmelden (labelz-Render-Ticket, Architektur §3.6, ADR 0004 §6)::
416
+
417
+ @pz.request_authenticator(name="direct", max_role="user")
418
+ def ticket(request) -> AuthResult | int | None: …
419
+
420
+ Läuft im Guard **vor dem Gate**, nur ohne gültige Sitzung und nur für `methods` (Standard GET/HEAD),
421
+ in Anmeldereihenfolge (erster Treffer gewinnt); sieht Methode, Pfad, Query und Header. Rückgabe
422
+ `None`, Konto-ID oder `pillarz.web.AuthResult(user_id, context)` (`auth_context(request)`). Gesperrte
423
+ Konten gelten nie; die Rolle ist auf `max_role` gedeckelt; pillarz-Seiten und `/api/pz/*` antworten
424
+ 403. Keine Nebenwirkungen (keine Sitzung, kein Cookie, kein `last_seen`, kein `user.signed_in`).
425
+ Eine Ausnahme im Authenticator ergibt 500 (nie still anonym)."""
426
+ if not isinstance(name, str) or not _KEY_RE.fullmatch(name) or name == "session":
427
+ raise ValueError(f"pz.request_authenticator(): Name {name!r} ist ungültig")
428
+ if any(a[0] == name for a in self._registry.items("authenticator")):
429
+ raise ValueError(f"pz.request_authenticator(): {name!r} ist schon angemeldet")
430
+ if max_role not in ("admin", "user", "guest"):
431
+ raise ValueError("pz.request_authenticator(): max_role muss admin, user oder guest sein")
432
+ allowed = {"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"}
433
+ methods = tuple(m.upper() for m in methods)
434
+ if not methods or any(m not in allowed for m in methods):
435
+ raise ValueError(f"pz.request_authenticator(): methods {methods!r} ungültig")
436
+
437
+ def register(fn: Callable) -> Callable:
438
+ self._registry.add("authenticator", (name, fn, max_role, frozenset(methods)))
439
+ return fn
440
+
441
+ return register
442
+
443
+ def admin_tab(self, tab: AdminTab) -> AdminTab:
444
+ """Eigenen Admin-Reiter anmelden (siehe `AdminTab`)."""
445
+ if not isinstance(tab, AdminTab):
446
+ raise TypeError("pz.admin_tab() erwartet ein AdminTab")
447
+ _check_extension("pz.admin_tab", tab, self._registry.items("admin_tab"), _ADMIN_TABS)
448
+ self._registry.add("admin_tab", tab)
449
+ return tab
450
+
451
+ def account_section(self, section: AccountSection) -> AccountSection:
452
+ """Eigenen Abschnitt der Kontoseite anmelden (siehe `AccountSection`)."""
453
+ if not isinstance(section, AccountSection):
454
+ raise TypeError("pz.account_section() erwartet eine AccountSection")
455
+ _check_extension("pz.account_section", section, self._registry.items("account_section"), _ACCOUNT_SECTIONS)
456
+ self._registry.add("account_section", section)
457
+ return section
458
+
459
+ def settings_section(self, section: SettingsSection) -> SettingsSection:
460
+ """App-Abschnitt im Reiter „Einstellungen“ anmelden (siehe `SettingsSection`)."""
461
+ if not isinstance(section, SettingsSection):
462
+ raise TypeError("pz.settings_section() erwartet eine SettingsSection")
463
+ _check_extension("pz.settings_section", section, self._registry.items("settings_section"), ())
464
+ if not section.keys or not all(isinstance(k, str) for k in section.keys):
465
+ raise ValueError(f"pz.settings_section({section.key!r}): keys fehlt")
466
+ self._registry.add("settings_section", section)
467
+ return section
468
+
469
+ def setting(self, setting: Setting) -> Setting:
470
+ """App-Einstellung anmelden (siehe `Setting`, `pillarz.settings`)."""
471
+ from ._settings import KINDS, PILLARZ_KEYS
472
+ if not isinstance(setting, Setting):
473
+ raise TypeError("pz.setting() erwartet ein Setting")
474
+ if not _KEY_RE.fullmatch(setting.key) or setting.key in PILLARZ_KEYS or \
475
+ any(s.key == setting.key for s in self._registry.items("setting")):
476
+ raise ValueError(f"pz.setting(): Schlüssel {setting.key!r} ist ungültig oder schon vergeben")
477
+ if setting.kind not in KINDS:
478
+ raise ValueError(f"pz.setting({setting.key!r}): kind muss {', '.join(KINDS)} sein")
479
+ if setting.kind == "choice" and not setting.options:
480
+ raise ValueError(f"pz.setting({setting.key!r}): kind='choice' braucht options")
481
+ if setting.kind == "custom" and setting.validate is None:
482
+ # Ohne Prüfer nähme PATCH /api/pz/admin/settings beliebiges JSON an (S-N4)
483
+ raise ValueError(f"pz.setting({setting.key!r}): kind='custom' braucht validate")
484
+ if setting.ui and setting.kind == "custom":
485
+ raise ValueError(f"pz.setting({setting.key!r}): kind='custom' hat keine eingebaute Oberfläche – "
486
+ "SettingsSection nutzen")
487
+ self._registry.add("setting", setting)
488
+ return setting
489
+
490
+ def _check_registrations(self) -> None:
491
+ """Beim `create_app()`: Kombinationen prüfen, die erst mit allen Registrierungen feststehen."""
492
+ if self.self_delete:
493
+ for item in self._registry.items("delete_input"):
494
+ asks = item.asks_self
495
+ if not asks and item.required and item.self_default is None:
496
+ raise ValueError(
497
+ f"pz.delete_input({item.key!r}): Pflichtangabe ohne self_default – bei Selbstlöschung "
498
+ f"wird {'kind=user' if item.kind == 'user' else 'on_self=default'} nicht abgefragt. "
499
+ "self_default setzen, die Selbstlöschung im user.deleting-Handler ablehnen oder "
500
+ "Pillarz(self_delete=False).")
501
+ settings = {s.key for s in self._registry.items("setting")}
502
+ owner: dict[str, str] = {}
503
+ for section in self._registry.items("settings_section"):
504
+ for key in section.keys:
505
+ if key not in settings:
506
+ raise ValueError(f"pz.settings_section({section.key!r}): Einstellung {key!r} ist nicht "
507
+ "mit pz.setting() angemeldet")
508
+ if key in owner:
509
+ raise ValueError(f"pz.settings_section(): Einstellung {key!r} gehört schon zum Abschnitt "
510
+ f"{owner[key]!r}")
511
+ owner[key] = section.key
512
+
513
+ @property
514
+ def frozen(self) -> bool:
515
+ return self._registry.frozen
516
+
517
+ # -------------------------------------------------------------- Start
518
+ def migrate(self, *, dry_run: bool = False) -> list[str]:
519
+ """Offene Migrationen (pillarz, dann App) ausführen – wie beim Start. Rückgabe: angewendete
520
+ Schritte als `"komponente/name"`."""
521
+ from ._db.migrations import run
522
+ return run(self, dry_run=dry_run)
523
+
524
+ def create_app(self):
525
+ """FastAPI-Instanz bauen: Guard, Gate „default deny“, Fehler-Handler, Static, Templates,
526
+ `/healthz`, `/themes.css` (Paket B). Danach sind alle Registrierungen eingefroren."""
527
+ from ._web.app import build
528
+
529
+ if self._app is not None:
530
+ return self._app
531
+ self._check_registrations()
532
+ self._registry.freeze()
533
+ pz = self
534
+
535
+ @asynccontextmanager
536
+ async def lifespan(_app):
537
+ from starlette.concurrency import run_in_threadpool
538
+ for warning in pz.config.warnings:
539
+ log.warning(warning)
540
+ await run_in_threadpool(pz._startup)
541
+ try:
542
+ yield
543
+ finally:
544
+ await run_in_threadpool(pz._shutdown)
545
+
546
+ self._app = build(self, lifespan)
547
+ return self._app
548
+
549
+ def _startup(self) -> None:
550
+ from . import _protocol
551
+ from ._i18n import catalogs
552
+ catalogs.load(self) # kaputte .po stoppen den Start (statt still deutsch anzuzeigen)
553
+ self.migrate()
554
+ _protocol.install(self) # nach den Migrationen (Tabelle pz_log), vor allem anderen
555
+ urls = self.config.public_urls
556
+ # S-N1: Secure/__Host- hängen an PUBLIC_URLS. Leer ist in Tests normal (keine Warnung), gemischt nie.
557
+ if not urls and not _runtime.testing:
558
+ log.warning(lazy("PUBLIC_URLS ist nicht gesetzt – kein HSTS, Anmelde-Cookie ohne Secure-Flag und keine "
559
+ "Links nach außen. Für den Betrieb die öffentliche https://-Adresse setzen."))
560
+ elif not self.config.https_only and any(u.startswith("https://") for u in urls):
561
+ log.warning(lazy("PUBLIC_URLS mischt http:// und https:// – deshalb kein HSTS und das Anmelde-Cookie "
562
+ "wird auch über unverschlüsseltes HTTP gesendet."))
563
+ if "oidc" in self.config.auth_methods and not urls:
564
+ log.warning(lazy("Anmeldung über authentik ist aktiv, aber PUBLIC_URLS ist nicht gesetzt – ohne öffentliche "
565
+ "Adresse gibt es keine Rückleitung von authentik und keine Verknüpfungslinks."))
566
+ self._prune()
567
+ from ._auth.setup import seed_admin
568
+ seed_admin(self) # ADMIN_* bei leerer DB (K-15: schwaches Passwort bricht ab), Warnung /setup
569
+ self._check_admin_reachable()
570
+ self._check_oidc()
571
+ for fn in self._registry.items("startup"):
572
+ fn()
573
+
574
+ def _prune(self) -> None:
575
+ """Aufräumen beim Start (P-43): abgelaufene/im Leerlauf ungültige Sitzungen, abgelaufene Flows, alte
576
+ Bremsen-Einträge, verbrauchte/zurückgezogene/abgelaufene Einmal-Tokens nach 30 Tagen. Ab 0.5
577
+ zusätzlich regelmäßig im System-Job `pillarz.prune`."""
578
+ from ._auth import flows, sessions, throttle, tokens
579
+ counts = {"sessions": sessions.prune(), "flows": flows.prune(), "throttle": throttle.prune(),
580
+ "tokens": tokens.prune()}
581
+ if any(counts.values()):
582
+ log.info(lazy("Aufgeräumt: %(sessions)d Sitzungen, %(flows)d Anmelde-Zwischenstände, "
583
+ "%(throttle)d Bremsen-Einträge, %(tokens)d Einmal-Tokens"), counts)
584
+
585
+ def _check_admin_reachable(self) -> None:
586
+ """C-L4: Konten, aber kein aktives Admin-Konto und keine `OIDC_ADMIN_GROUPS` – niemand kommt in
587
+ die Verwaltung (typisch nach der Übernahme einer Datenbank ohne Umbau `is_admin` → `role`).
588
+ Kein Startabbruch, aber ein Fehler im Protokoll. Leere Datenbank = Einrichtung, kein Fehler."""
589
+ from .db import connect
590
+ if self.config.oidc_admin_groups:
591
+ return
592
+ with connect() as conn:
593
+ users = conn.scalar("SELECT COUNT(*) FROM users")
594
+ admins = conn.scalar("SELECT COUNT(*) FROM users WHERE role = 'admin' AND active = 1")
595
+ if users and not admins:
596
+ log.error(lazy("Kein aktives Admin-Konto (Konten insgesamt: %(users)d) und OIDC_ADMIN_GROUPS ist leer "
597
+ "– niemand kann die Verwaltung öffnen. Ein Konto zum Admin machen (role = 'admin') "
598
+ "oder OIDC_ADMIN_GROUPS setzen."), {"users": users})
599
+
600
+ def _check_oidc(self) -> None:
601
+ """P-6: Discovery im Hintergrund prüfen – ein falscher `OIDC_ISSUER` steht so schon nach dem Start im Protokoll
602
+ und im Admin-Bereich, nicht erst nach der ersten fehlgeschlagenen Anmeldung. Nicht im Testmodus."""
603
+ from ._auth import oidc
604
+ if oidc.enabled() and not _runtime.testing:
605
+ oidc.check_in_background()
606
+
607
+ def _shutdown(self) -> None:
608
+ from ._db.connection import close_pool
609
+ try:
610
+ for fn in reversed(self._registry.items("shutdown")):
611
+ try:
612
+ fn()
613
+ except Exception: # noqa: BLE001 – weitere Handler und das Schließen des Pools laufen trotzdem
614
+ log.exception(lazy("on_shutdown-Handler %r ist fehlgeschlagen"), fn)
615
+ finally:
616
+ from . import _protocol
617
+ _protocol.uninstall()
618
+ close_pool()