pillarz 0.1.0.dev1__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.
- pillarz/__init__.py +309 -0
- pillarz/__main__.py +176 -0
- pillarz/_auth/__init__.py +2 -0
- pillarz/_auth/passwords.py +74 -0
- pillarz/_auth/setup.py +57 -0
- pillarz/_db/__init__.py +1 -0
- pillarz/_db/clock.py +67 -0
- pillarz/_db/connection.py +241 -0
- pillarz/_db/m0001_initial.py +194 -0
- pillarz/_db/migrations.py +272 -0
- pillarz/_i18n/__init__.py +11 -0
- pillarz/_i18n/babel-js.cfg +4 -0
- pillarz/_i18n/babel.cfg +8 -0
- pillarz/_i18n/catalogs.py +216 -0
- pillarz/_i18n/jinja.py +41 -0
- pillarz/_i18n/js.py +43 -0
- pillarz/_i18n/request.py +103 -0
- pillarz/_i18n/tools.py +200 -0
- pillarz/_protocol.py +315 -0
- pillarz/_runtime.py +86 -0
- pillarz/_themes.py +154 -0
- pillarz/_web/__init__.py +1 -0
- pillarz/_web/app.py +69 -0
- pillarz/_web/context.py +14 -0
- pillarz/_web/csp.py +148 -0
- pillarz/_web/errors.py +132 -0
- pillarz/_web/gate.py +134 -0
- pillarz/_web/guard.py +241 -0
- pillarz/_web/headers.py +62 -0
- pillarz/_web/net.py +48 -0
- pillarz/_web/pages.py +30 -0
- pillarz/_web/render.py +271 -0
- pillarz/_web/static.py +40 -0
- pillarz/config.py +336 -0
- pillarz/db.py +337 -0
- pillarz/i18n.py +126 -0
- pillarz/locale/en/LC_MESSAGES/pillarz-js.po +86 -0
- pillarz/locale/en/LC_MESSAGES/pillarz.po +622 -0
- pillarz/locale/pillarz-js.pot +83 -0
- pillarz/locale/pillarz.pot +588 -0
- pillarz/static/pillarz/css/base.css +531 -0
- pillarz/static/pillarz/favicon.svg +1 -0
- pillarz/static/pillarz/js/api.js +96 -0
- pillarz/static/pillarz/js/i18n.js +158 -0
- pillarz/static/pillarz/js/ui.js +433 -0
- pillarz/static/pillarz/vendor/lucide/LICENSE +43 -0
- pillarz/templates/pillarz/bare.html +25 -0
- pillarz/templates/pillarz/base.html +38 -0
- pillarz/templates/pillarz/error.html +14 -0
- pillarz/templates/pillarz/icons.svg +66 -0
- pillarz/templates/pillarz/partials/flash.html +8 -0
- pillarz/templates/pillarz/partials/nav.html +30 -0
- pillarz/templates/pillarz/plain.html +16 -0
- pillarz/testing/__init__.py +149 -0
- pillarz/testing/_web.py +112 -0
- pillarz/text.py +81 -0
- pillarz/web.py +182 -0
- pillarz-0.1.0.dev1.dist-info/METADATA +71 -0
- pillarz-0.1.0.dev1.dist-info/RECORD +62 -0
- pillarz-0.1.0.dev1.dist-info/WHEEL +5 -0
- pillarz-0.1.0.dev1.dist-info/licenses/LICENSE +661 -0
- pillarz-0.1.0.dev1.dist-info/top_level.txt +1 -0
pillarz/_db/clock.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Zentrale Uhr für alle Zeitstempel von pillarz (`pillarz.db.now()` & Co.).
|
|
2
|
+
|
|
3
|
+
Tests frieren sie über `pillarz.testing.clock.freeze(at)` ein (Ablauf von Sitzungen, Tokens,
|
|
4
|
+
Aufbewahrung des Protokolls), statt `datetime` zu patchen. Im Betrieb liefert sie einfach die
|
|
5
|
+
aktuelle UTC-Zeit. Der eingefrorene Zustand gilt je Prozess (nicht für Unterprozesse).
|
|
6
|
+
"""
|
|
7
|
+
import threading
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _utc(at: datetime | str) -> datetime:
|
|
13
|
+
if isinstance(at, str):
|
|
14
|
+
at = datetime.fromisoformat(at)
|
|
15
|
+
if not isinstance(at, datetime):
|
|
16
|
+
raise TypeError("Zeitpunkt muss datetime oder ISO-Text sein")
|
|
17
|
+
if at.tzinfo is None: # naive Angaben gelten als UTC
|
|
18
|
+
return at.replace(tzinfo=timezone.utc)
|
|
19
|
+
return at.astimezone(timezone.utc)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Clock:
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self._frozen: datetime | None = None
|
|
25
|
+
self._lock = threading.Lock()
|
|
26
|
+
|
|
27
|
+
def now(self) -> datetime:
|
|
28
|
+
"""Aktuelle Zeit (UTC, mit Zeitzone) – oder der eingefrorene Zeitpunkt."""
|
|
29
|
+
frozen = self._frozen
|
|
30
|
+
return frozen if frozen is not None else datetime.now(timezone.utc)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def frozen(self) -> bool:
|
|
34
|
+
return self._frozen is not None
|
|
35
|
+
|
|
36
|
+
def set(self, at: datetime | str) -> None:
|
|
37
|
+
"""Uhr auf einen festen Zeitpunkt stellen (bis `unfreeze()`)."""
|
|
38
|
+
with self._lock:
|
|
39
|
+
self._frozen = _utc(at)
|
|
40
|
+
|
|
41
|
+
def advance(self, delta: timedelta | None = None, **kwargs) -> datetime:
|
|
42
|
+
"""Eingefrorene Uhr vorstellen, z. B. `advance(minutes=16)`. Ohne Einfrieren: Fehler."""
|
|
43
|
+
delta = delta if delta is not None else timedelta(**kwargs)
|
|
44
|
+
with self._lock:
|
|
45
|
+
if self._frozen is None:
|
|
46
|
+
raise RuntimeError("Die Uhr ist nicht eingefroren – erst freeze() aufrufen.")
|
|
47
|
+
self._frozen = self._frozen + delta
|
|
48
|
+
return self._frozen
|
|
49
|
+
|
|
50
|
+
def unfreeze(self) -> None:
|
|
51
|
+
with self._lock:
|
|
52
|
+
self._frozen = None
|
|
53
|
+
|
|
54
|
+
@contextmanager
|
|
55
|
+
def freeze(self, at: datetime | str | None = None):
|
|
56
|
+
"""Zeit für die Dauer des Blocks festhalten (Standard: jetzt). Liefert die Uhr selbst,
|
|
57
|
+
damit der Block mit `advance()` weiterstellen kann."""
|
|
58
|
+
previous = self._frozen
|
|
59
|
+
self.set(at if at is not None else datetime.now(timezone.utc))
|
|
60
|
+
try:
|
|
61
|
+
yield self
|
|
62
|
+
finally:
|
|
63
|
+
with self._lock:
|
|
64
|
+
self._frozen = previous
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
clock = Clock()
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Verbindung und Transaktion – SQLite (Standard) oder PostgreSQL (wenn POSTGRES_DB gesetzt ist).
|
|
2
|
+
|
|
3
|
+
Portiert aus labelz `app/db.py` (`Conn`, `connect`, `_statements`, `reset`, `ping`), angepasst an ADR 0003:
|
|
4
|
+
- Konfiguration kommt aus der einen `Pillarz`-Instanz (`_runtime`), nicht aus Modul-Globals.
|
|
5
|
+
- SQLite: Verbindung im Autocommit-Modus des Treibers, die Transaktion beginnt ausdrücklich mit
|
|
6
|
+
`BEGIN` (bzw. `BEGIN IMMEDIATE` für den Migrations-Runner). So laufen auch `CREATE`/`ALTER` in der
|
|
7
|
+
Transaktion und werden bei einem Fehler mit zurückgerollt. `busy_timeout` für mehrere Prozesse auf
|
|
8
|
+
einer Datei, im Prozess weiterhin global serialisiert (RLock wie labelz).
|
|
9
|
+
- PostgreSQL: Verbindungspool je Prozess (`psycopg_pool`, `PG_POOL_SIZE`, Standard 5, `0` = eine
|
|
10
|
+
Verbindung je `connect()` wie labelz). Der Pool wird beim ersten `connect()` angelegt und nach einem
|
|
11
|
+
Fork nie geerbt (PID-Prüfung). Ist eine frisch geholte Verbindung tot (PG-Neustart), wird das erste
|
|
12
|
+
Statement **einmal** mit einer neuen Verbindung wiederholt – nie mitten in einer Transaktion.
|
|
13
|
+
"""
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sqlite3
|
|
17
|
+
import threading
|
|
18
|
+
from contextlib import contextmanager
|
|
19
|
+
|
|
20
|
+
import psycopg
|
|
21
|
+
from psycopg.rows import dict_row
|
|
22
|
+
|
|
23
|
+
from .. import _runtime
|
|
24
|
+
|
|
25
|
+
# labelz app/db.py: IntegrityError-Tupel – Aufrufer fangen `except IntegrityError:` auf beiden Systemen
|
|
26
|
+
IntegrityError: tuple = (sqlite3.IntegrityError, psycopg.IntegrityError)
|
|
27
|
+
|
|
28
|
+
_lock = threading.RLock()
|
|
29
|
+
|
|
30
|
+
# {pk} (Primärschlüssel), {blob} (Binärspalte), {bigint} (64-Bit-Ganzzahl) sind die einzigen Stellen,
|
|
31
|
+
# an denen sich die Systeme im DDL unterscheiden (labelz PK/BLOB)
|
|
32
|
+
_PLACEHOLDERS = {
|
|
33
|
+
"{pk}": {"sqlite": "INTEGER PRIMARY KEY AUTOINCREMENT",
|
|
34
|
+
"postgres": "BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY"},
|
|
35
|
+
"{blob}": {"sqlite": "BLOB", "postgres": "BYTEA"},
|
|
36
|
+
"{bigint}": {"sqlite": "INTEGER", "postgres": "BIGINT"},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
SQLITE_BUSY_MS = 5000
|
|
40
|
+
POOL_MAX_IDLE = 300
|
|
41
|
+
POOL_TIMEOUT = 10
|
|
42
|
+
CONNECT_TIMEOUT = 10
|
|
43
|
+
|
|
44
|
+
_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ident(name: str) -> str:
|
|
48
|
+
"""Bezeichner (Tabelle/Spalte) prüfen, bevor er in SQL eingesetzt wird – Werte gehen immer als
|
|
49
|
+
Parameter, Bezeichner können das nicht."""
|
|
50
|
+
if not isinstance(name, str) or not _IDENT.fullmatch(name):
|
|
51
|
+
raise ValueError(f"Ungültiger SQL-Bezeichner: {name!r}")
|
|
52
|
+
return name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Conn:
|
|
56
|
+
"""Dünne Hülle: „?“-Platzhalter und dict-Zeilen auf beiden Systemen (labelz `Conn`)."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, raw, postgres: bool, renew=None):
|
|
59
|
+
self.raw, self.postgres = raw, postgres
|
|
60
|
+
# Nur bis zum ersten Statement gesetzt: tauscht eine tote, frisch geholte Verbindung aus
|
|
61
|
+
self._renew = renew
|
|
62
|
+
|
|
63
|
+
def execute(self, sql: str, args: tuple | list = ()):
|
|
64
|
+
if self.postgres:
|
|
65
|
+
# Unser SQL enthält keine wörtlichen ? oder % – Werte gehen nur als Parameter
|
|
66
|
+
sql = sql.replace("%", "%%").replace("?", "%s")
|
|
67
|
+
args = tuple(args)
|
|
68
|
+
if self._renew is not None:
|
|
69
|
+
renew, self._renew = self._renew, None
|
|
70
|
+
try:
|
|
71
|
+
return self.raw.execute(sql, args)
|
|
72
|
+
except psycopg.OperationalError:
|
|
73
|
+
if not (self.raw.closed or self.raw.broken):
|
|
74
|
+
raise # echter Fehler, keine tote Verbindung
|
|
75
|
+
dead, self.raw = self.raw, None
|
|
76
|
+
self.raw = renew(dead) # einmal mit neuer Verbindung, noch nichts ausgeführt
|
|
77
|
+
return self.raw.execute(sql, args)
|
|
78
|
+
|
|
79
|
+
def rows(self, sql: str, args: tuple | list = ()) -> list[dict]:
|
|
80
|
+
return [dict(r) for r in self.execute(sql, args).fetchall()]
|
|
81
|
+
|
|
82
|
+
def row(self, sql: str, args: tuple | list = ()) -> dict | None:
|
|
83
|
+
r = self.execute(sql, args).fetchone()
|
|
84
|
+
return dict(r) if r else None
|
|
85
|
+
|
|
86
|
+
def scalar(self, sql: str, args: tuple | list = ()):
|
|
87
|
+
r = self.execute(sql, args).fetchone()
|
|
88
|
+
return None if r is None else list(dict(r).values())[0]
|
|
89
|
+
|
|
90
|
+
def insert(self, table: str, **fields) -> int:
|
|
91
|
+
"""INSERT mit `id`-Spalte; liefert die neue ID (PostgreSQL über `RETURNING id`)."""
|
|
92
|
+
cols = ", ".join(ident(c) for c in fields)
|
|
93
|
+
marks = ", ".join("?" for _ in fields)
|
|
94
|
+
sql = f"INSERT INTO {ident(table)} ({cols}) VALUES ({marks})"
|
|
95
|
+
if self.postgres:
|
|
96
|
+
return self.execute(sql + " RETURNING id", tuple(fields.values())).fetchone()["id"]
|
|
97
|
+
return self.execute(sql, tuple(fields.values())).lastrowid
|
|
98
|
+
|
|
99
|
+
def insert_plain(self, table: str, **fields) -> None:
|
|
100
|
+
"""INSERT für Tabellen ohne id-Spalte (zusammengesetzter Schlüssel)."""
|
|
101
|
+
cols = ", ".join(ident(c) for c in fields)
|
|
102
|
+
marks = ", ".join("?" for _ in fields)
|
|
103
|
+
self.execute(f"INSERT INTO {ident(table)} ({cols}) VALUES ({marks})", tuple(fields.values()))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------ PostgreSQL-Pool
|
|
107
|
+
_pool = None
|
|
108
|
+
_pool_pid: int | None = None
|
|
109
|
+
_pool_lock = threading.Lock()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _pg_kwargs(cfg) -> dict:
|
|
113
|
+
return dict(dbname=cfg.postgres_db, user=cfg.postgres_user, password=cfg.postgres_password,
|
|
114
|
+
host=cfg.postgres_host, port=cfg.postgres_port, connect_timeout=CONNECT_TIMEOUT)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _get_pool(cfg):
|
|
118
|
+
global _pool, _pool_pid
|
|
119
|
+
with _pool_lock:
|
|
120
|
+
if _pool is not None and _pool_pid != os.getpid():
|
|
121
|
+
# Nach einem Fork: Verbindungen gehören dem Elternprozess – nie anfassen, neu anlegen
|
|
122
|
+
_pool = None
|
|
123
|
+
if _pool is None:
|
|
124
|
+
from psycopg_pool import ConnectionPool
|
|
125
|
+
_pool = ConnectionPool(kwargs={**_pg_kwargs(cfg), "row_factory": dict_row},
|
|
126
|
+
min_size=1, max_size=cfg.pg_pool_size, max_idle=POOL_MAX_IDLE,
|
|
127
|
+
timeout=POOL_TIMEOUT, name=f"pillarz-{cfg.name}", open=True)
|
|
128
|
+
_pool_pid = os.getpid()
|
|
129
|
+
return _pool
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def close_pool() -> None:
|
|
133
|
+
"""Pool schließen (Lifespan-Ende, Worker-Befehl, Tests). Der nächste `connect()` legt neu an."""
|
|
134
|
+
global _pool, _pool_pid
|
|
135
|
+
with _pool_lock:
|
|
136
|
+
pool, _pool = _pool, None
|
|
137
|
+
mine = _pool_pid == os.getpid()
|
|
138
|
+
_pool_pid = None
|
|
139
|
+
if pool is not None and mine:
|
|
140
|
+
pool.close()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def pool_stats() -> dict | None:
|
|
144
|
+
"""Kennzahlen des Pools (für Tests/Diagnose), `None` ohne Pool."""
|
|
145
|
+
pool = _pool
|
|
146
|
+
return pool.get_stats() if pool is not None and _pool_pid == os.getpid() else None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@contextmanager
|
|
150
|
+
def _pg_transaction(cfg):
|
|
151
|
+
if cfg.pg_pool_size <= 0:
|
|
152
|
+
# Rückfall wie labelz: eine kurze eigene Verbindung je Aufruf
|
|
153
|
+
raw = psycopg.connect(**_pg_kwargs(cfg), row_factory=dict_row)
|
|
154
|
+
try:
|
|
155
|
+
yield Conn(raw, True)
|
|
156
|
+
raw.commit()
|
|
157
|
+
except BaseException:
|
|
158
|
+
raw.rollback()
|
|
159
|
+
raise
|
|
160
|
+
finally:
|
|
161
|
+
raw.close()
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
pool = _get_pool(cfg)
|
|
165
|
+
|
|
166
|
+
def renew(dead):
|
|
167
|
+
# Die tote Verbindung ist zurückgegeben, bevor getconn() es erneut versucht: schlägt das fehl
|
|
168
|
+
# (PoolTimeout), ist conn.raw None und der finally-Block gibt nichts doppelt zurück (C-M1)
|
|
169
|
+
pool.putconn(dead) # der Pool verwirft geschlossene/kaputte Verbindungen
|
|
170
|
+
pool.check() # übrige Ruheverbindungen prüfen (nach PG-Neustart alle tot)
|
|
171
|
+
return pool.getconn()
|
|
172
|
+
|
|
173
|
+
conn = Conn(pool.getconn(), True, renew=renew)
|
|
174
|
+
try:
|
|
175
|
+
yield conn
|
|
176
|
+
conn.raw.commit()
|
|
177
|
+
except BaseException:
|
|
178
|
+
if conn.raw is not None:
|
|
179
|
+
try:
|
|
180
|
+
conn.raw.rollback()
|
|
181
|
+
except psycopg.Error:
|
|
182
|
+
pass # tote Verbindung: der Pool verwirft sie beim Zurückgeben
|
|
183
|
+
raise
|
|
184
|
+
finally:
|
|
185
|
+
# Zurück nur im Ruhezustand – der Pool rollt Offenes zurück bzw. verwirft Kaputtes
|
|
186
|
+
if conn.raw is not None:
|
|
187
|
+
pool.putconn(conn.raw)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ------------------------------------------------------------------ SQLite
|
|
191
|
+
def sqlite_raw(path, *, busy_ms: int = SQLITE_BUSY_MS, foreign_keys: bool = True, uri: bool = False) -> sqlite3.Connection:
|
|
192
|
+
raw = sqlite3.connect(path, isolation_level=None, timeout=busy_ms / 1000, uri=uri)
|
|
193
|
+
raw.row_factory = sqlite3.Row
|
|
194
|
+
raw.execute(f"PRAGMA busy_timeout = {int(busy_ms)}")
|
|
195
|
+
# vor BEGIN – in einer Transaktion wirkungslos; OFF nur für Tabellenumbauten in Migrationen (ADR 0003)
|
|
196
|
+
raw.execute("PRAGMA foreign_keys = ON" if foreign_keys else "PRAGMA foreign_keys = OFF")
|
|
197
|
+
return raw
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@contextmanager
|
|
201
|
+
def _sqlite_transaction(cfg, *, immediate: bool, busy_ms: int, foreign_keys: bool = True):
|
|
202
|
+
with _lock:
|
|
203
|
+
raw = sqlite_raw(cfg.db_path, busy_ms=busy_ms, foreign_keys=foreign_keys)
|
|
204
|
+
try:
|
|
205
|
+
raw.execute("BEGIN IMMEDIATE" if immediate else "BEGIN")
|
|
206
|
+
yield Conn(raw, False)
|
|
207
|
+
raw.execute("COMMIT")
|
|
208
|
+
except BaseException:
|
|
209
|
+
if raw.in_transaction:
|
|
210
|
+
raw.execute("ROLLBACK")
|
|
211
|
+
raise
|
|
212
|
+
finally:
|
|
213
|
+
raw.close()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@contextmanager
|
|
217
|
+
def transaction(*, immediate: bool = False, busy_ms: int = SQLITE_BUSY_MS, foreign_keys: bool = True):
|
|
218
|
+
"""Eine Transaktion: Commit am Ende, Rollback bei Fehler. `immediate` (nur SQLite) nimmt die
|
|
219
|
+
Schreibsperre der Datei sofort – für den Migrations-Runner; `foreign_keys=False` (nur SQLite)
|
|
220
|
+
schaltet die Fremdschlüssel für Tabellenumbauten ab (Migrationsschritte mit `requires_fk_off`)."""
|
|
221
|
+
cfg = _runtime.config()
|
|
222
|
+
if cfg.postgres:
|
|
223
|
+
with _pg_transaction(cfg) as conn:
|
|
224
|
+
yield conn
|
|
225
|
+
else:
|
|
226
|
+
with _sqlite_transaction(cfg, immediate=immediate, busy_ms=busy_ms, foreign_keys=foreign_keys) as conn:
|
|
227
|
+
yield conn
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def is_postgres() -> bool:
|
|
231
|
+
return _runtime.config().postgres
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def statements(sql: str, postgres: bool | None = None) -> list[str]:
|
|
235
|
+
"""DDL mit Platzhaltern in einzelne Statements für das jeweilige System (labelz `_statements`).
|
|
236
|
+
`--`-Kommentare werden entfernt, getrennt wird an `;` – also keine `;` oder `--` in Literalen."""
|
|
237
|
+
system = "postgres" if (is_postgres() if postgres is None else postgres) else "sqlite"
|
|
238
|
+
for key, values in _PLACEHOLDERS.items():
|
|
239
|
+
sql = sql.replace(key, values[system])
|
|
240
|
+
lines = [line.split("--", 1)[0] for line in sql.splitlines()]
|
|
241
|
+
return [s.strip() for s in "\n".join(lines).split(";") if s.strip()]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""pillarz-Migration 0001: Konten (`users`) und die privaten `pz_*`-Tabellen von 0.1.
|
|
2
|
+
|
|
3
|
+
`users` übernimmt Spalten- und Indexnamen von labelz (`app/db.py`, Tabelle `users`), damit eine
|
|
4
|
+
bestehende labelz-Tabelle passt (ADR 0003 „Übernahme bestehender Datenbanken“): Ist die Tabelle schon
|
|
5
|
+
da, ergänzt der Schritt fehlende pillarz-Spalten per `add_column` – generisch, nicht
|
|
6
|
+
labelz-spezifisch. Datenumbau (z. B. `is_admin` → `role`) schreibt das App-Team als eigene
|
|
7
|
+
App-Migration (Architektur §5.4).
|
|
8
|
+
|
|
9
|
+
Alle `pz_*`-Tabellen mit `user_id` löschen per `ON DELETE CASCADE` mit (Konto löschen in einer
|
|
10
|
+
Transaktion, ADR 0006). Spalten der `pz_*`-Tabellen sind privat und dürfen sich additiv ändern.
|
|
11
|
+
"""
|
|
12
|
+
from ..db import add_column, create_index, ddl, has_column, has_table, now
|
|
13
|
+
from ..i18n import _
|
|
14
|
+
from .connection import Conn
|
|
15
|
+
|
|
16
|
+
USERS = """
|
|
17
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
18
|
+
id {pk},
|
|
19
|
+
username TEXT NOT NULL, -- eindeutig ohne Groß-/Kleinschreibung (Index unten)
|
|
20
|
+
name TEXT NOT NULL,
|
|
21
|
+
email TEXT UNIQUE, -- klein geschrieben; gehört pillarz, nicht authentik (F-4)
|
|
22
|
+
email_confirmed INTEGER NOT NULL DEFAULT 0, -- 1 = vom Admin/aus authentik/per Mail bestätigt (ADR 0008 §5)
|
|
23
|
+
password_hash TEXT, -- NULL = kein Passwort
|
|
24
|
+
role TEXT NOT NULL DEFAULT 'user', -- admin | user | guest
|
|
25
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
26
|
+
session_epoch INTEGER NOT NULL DEFAULT 0, -- +1 bei „alle Sitzungen beenden“
|
|
27
|
+
oidc_sub TEXT,
|
|
28
|
+
oidc_name TEXT,
|
|
29
|
+
oidc_email TEXT,
|
|
30
|
+
oidc_unlinked_at TEXT,
|
|
31
|
+
theme TEXT, -- NULL = Standard-Theme aus den Einstellungen
|
|
32
|
+
mode TEXT NOT NULL DEFAULT 'auto', -- auto | light | dark
|
|
33
|
+
locale TEXT, -- NULL = Standard
|
|
34
|
+
region TEXT,
|
|
35
|
+
seen_version TEXT,
|
|
36
|
+
created_at TEXT NOT NULL,
|
|
37
|
+
updated_at TEXT,
|
|
38
|
+
last_login TEXT
|
|
39
|
+
);
|
|
40
|
+
CREATE UNIQUE INDEX IF NOT EXISTS users_username ON users (lower(username));
|
|
41
|
+
CREATE UNIQUE INDEX IF NOT EXISTS users_oidc_sub ON users (oidc_sub) WHERE oidc_sub IS NOT NULL;
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
# Spalten, die einer übernommenen Tabelle fehlen dürfen (Typ wie oben, auf beiden Systemen gültig)
|
|
45
|
+
_ADOPTABLE = {
|
|
46
|
+
"email_confirmed": "INTEGER NOT NULL DEFAULT 0",
|
|
47
|
+
"password_hash": "TEXT",
|
|
48
|
+
"role": "TEXT NOT NULL DEFAULT 'user'",
|
|
49
|
+
"active": "INTEGER NOT NULL DEFAULT 1",
|
|
50
|
+
"session_epoch": "INTEGER NOT NULL DEFAULT 0",
|
|
51
|
+
"oidc_sub": "TEXT",
|
|
52
|
+
"oidc_name": "TEXT",
|
|
53
|
+
"oidc_email": "TEXT",
|
|
54
|
+
"oidc_unlinked_at": "TEXT",
|
|
55
|
+
"theme": "TEXT",
|
|
56
|
+
"mode": "TEXT NOT NULL DEFAULT 'auto'",
|
|
57
|
+
"locale": "TEXT",
|
|
58
|
+
"region": "TEXT",
|
|
59
|
+
"seen_version": "TEXT",
|
|
60
|
+
"updated_at": "TEXT",
|
|
61
|
+
"last_login": "TEXT",
|
|
62
|
+
}
|
|
63
|
+
# Ohne diese Spalten ist es keine Kontentabelle, die pillarz übernehmen kann
|
|
64
|
+
_REQUIRED = ("id", "username", "name", "created_at")
|
|
65
|
+
|
|
66
|
+
PZ_TABLES = """
|
|
67
|
+
-- Serverseitige Sitzungen (ADR 0005). id = SHA-256 (hex) des Cookie-Tokens, nie das Token selbst.
|
|
68
|
+
CREATE TABLE IF NOT EXISTS pz_sessions (
|
|
69
|
+
id TEXT PRIMARY KEY,
|
|
70
|
+
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
71
|
+
method TEXT NOT NULL, -- password | oidc | magic_link | passkey | invitation
|
|
72
|
+
created_at TEXT NOT NULL,
|
|
73
|
+
last_seen_at TEXT NOT NULL,
|
|
74
|
+
expires_at TEXT NOT NULL, -- absolut (SESSION_DAYS)
|
|
75
|
+
ip TEXT,
|
|
76
|
+
user_agent TEXT,
|
|
77
|
+
device_label TEXT,
|
|
78
|
+
elevated_until TEXT, -- Admin über authentik-Gruppe, befristet
|
|
79
|
+
reauth_at TEXT, -- letzte frische Anmeldung (Schritt-hoch)
|
|
80
|
+
oidc_sid TEXT,
|
|
81
|
+
oidc_id_token TEXT, -- für RP-initiated Logout (ADR 0008)
|
|
82
|
+
data TEXT NOT NULL DEFAULT '{}' -- JSON: Flash, kleine Merker
|
|
83
|
+
);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS pz_sessions_user ON pz_sessions (user_id, last_seen_at);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS pz_sessions_expires ON pz_sessions (expires_at);
|
|
86
|
+
|
|
87
|
+
-- Vorab-Zustand einer Anmeldung (OIDC state/nonce/PKCE, Verknüpfung, Challenges, MFA-Schritt);
|
|
88
|
+
-- einmalig, kurzlebig (ADR 0005). id = SHA-256 des Flow-Tokens.
|
|
89
|
+
CREATE TABLE IF NOT EXISTS pz_flows (
|
|
90
|
+
id TEXT PRIMARY KEY,
|
|
91
|
+
kind TEXT NOT NULL,
|
|
92
|
+
data TEXT NOT NULL DEFAULT '{}',
|
|
93
|
+
created_at TEXT NOT NULL,
|
|
94
|
+
expires_at TEXT NOT NULL
|
|
95
|
+
);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS pz_flows_expires ON pz_flows (expires_at);
|
|
97
|
+
|
|
98
|
+
-- Anmeldebremse: eine Zeile je Fehlversuch (ADR 0005)
|
|
99
|
+
CREATE TABLE IF NOT EXISTS pz_throttle (
|
|
100
|
+
key TEXT NOT NULL,
|
|
101
|
+
ts TEXT NOT NULL
|
|
102
|
+
);
|
|
103
|
+
CREATE INDEX IF NOT EXISTS pz_throttle_key ON pz_throttle (key, ts);
|
|
104
|
+
CREATE INDEX IF NOT EXISTS pz_throttle_ts ON pz_throttle (ts);
|
|
105
|
+
|
|
106
|
+
-- Einmal-Tokens (ADR 0007): nur der SHA-256-Hash steht in der Datenbank. user_id NULL bei Einladungen.
|
|
107
|
+
CREATE TABLE IF NOT EXISTS pz_tokens (
|
|
108
|
+
id {pk},
|
|
109
|
+
kind TEXT NOT NULL, -- oidc_link | magic_link | invitation | password_reset | email_change
|
|
110
|
+
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
|
|
111
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
112
|
+
data TEXT NOT NULL DEFAULT '{}', -- JSON, z. B. expected_username
|
|
113
|
+
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
|
|
114
|
+
created_at TEXT NOT NULL,
|
|
115
|
+
expires_at TEXT NOT NULL,
|
|
116
|
+
used_at TEXT,
|
|
117
|
+
revoked_at TEXT
|
|
118
|
+
);
|
|
119
|
+
-- höchstens ein offener Token je Konto und Art; ein neuer zieht den alten zurück (labelz F-19)
|
|
120
|
+
CREATE UNIQUE INDEX IF NOT EXISTS pz_tokens_open ON pz_tokens (user_id, kind)
|
|
121
|
+
WHERE user_id IS NOT NULL AND used_at IS NULL AND revoked_at IS NULL;
|
|
122
|
+
CREATE INDEX IF NOT EXISTS pz_tokens_user ON pz_tokens (user_id, created_at);
|
|
123
|
+
CREATE INDEX IF NOT EXISTS pz_tokens_kind ON pz_tokens (kind, expires_at);
|
|
124
|
+
|
|
125
|
+
-- Einstellungen: Schlüssel → JSON (pillarz + registrierte App-Einstellungen, Architektur §14.11)
|
|
126
|
+
CREATE TABLE IF NOT EXISTS pz_settings (
|
|
127
|
+
key TEXT PRIMARY KEY,
|
|
128
|
+
value TEXT NOT NULL,
|
|
129
|
+
updated_at TEXT
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
-- Eigene Themes (labelz Tabelle themes)
|
|
133
|
+
CREATE TABLE IF NOT EXISTS pz_themes (
|
|
134
|
+
id TEXT PRIMARY KEY,
|
|
135
|
+
name TEXT NOT NULL,
|
|
136
|
+
colors TEXT NOT NULL -- JSON: {light: {...}, dark: {...}}
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
-- Protokoll (labelz Tabelle events) + request_id, actor_id, ip (K-13). actor_id ohne FK, damit der
|
|
140
|
+
-- Eintrag ein Löschen des Kontos überlebt.
|
|
141
|
+
CREATE TABLE IF NOT EXISTS pz_log (
|
|
142
|
+
id {pk},
|
|
143
|
+
ts TEXT NOT NULL,
|
|
144
|
+
level TEXT NOT NULL,
|
|
145
|
+
source TEXT NOT NULL,
|
|
146
|
+
message TEXT NOT NULL,
|
|
147
|
+
request_id TEXT,
|
|
148
|
+
actor_id BIGINT,
|
|
149
|
+
ip TEXT
|
|
150
|
+
);
|
|
151
|
+
CREATE INDEX IF NOT EXISTS pz_log_ts ON pz_log (ts);
|
|
152
|
+
CREATE INDEX IF NOT EXISTS pz_log_source ON pz_log (source, ts);
|
|
153
|
+
CREATE INDEX IF NOT EXISTS pz_log_actor ON pz_log (actor_id, ts);
|
|
154
|
+
|
|
155
|
+
-- Interne Merker (setup_done – ADR 0015, Update-Check-Ergebnis, Start-Hinweise)
|
|
156
|
+
CREATE TABLE IF NOT EXISTS pz_meta (
|
|
157
|
+
key TEXT PRIMARY KEY,
|
|
158
|
+
value TEXT
|
|
159
|
+
);
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _users(conn: Conn) -> None:
|
|
164
|
+
existed = has_table(conn, "users")
|
|
165
|
+
for statement in ddl(USERS, conn.postgres)[:1]: # nur CREATE TABLE; Indizes nach dem Ergänzen
|
|
166
|
+
conn.execute(statement)
|
|
167
|
+
if existed:
|
|
168
|
+
missing = [c for c in _REQUIRED if not has_column(conn, "users", c)]
|
|
169
|
+
if missing:
|
|
170
|
+
raise RuntimeError(_("Die vorhandene Tabelle users passt nicht (es fehlen %(cols)s) – "
|
|
171
|
+
"pillarz kann sie nicht übernehmen.") % {"cols": ", ".join(missing)})
|
|
172
|
+
if add_column(conn, "users", "email", "TEXT"):
|
|
173
|
+
create_index(conn, "CREATE UNIQUE INDEX IF NOT EXISTS users_email ON users (email)")
|
|
174
|
+
added = {col for col, typ in _ADOPTABLE.items() if add_column(conn, "users", col, typ)}
|
|
175
|
+
if "email_confirmed" in added:
|
|
176
|
+
# Bestandskonten stammen vom Admin – ihre Adressen gelten als bestätigt (ADR 0008 §5)
|
|
177
|
+
conn.execute("UPDATE users SET email_confirmed = 1 WHERE email IS NOT NULL")
|
|
178
|
+
for statement in ddl(USERS, conn.postgres)[1:]:
|
|
179
|
+
create_index(conn, statement)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def initial(conn: Conn) -> None:
|
|
183
|
+
_users(conn)
|
|
184
|
+
for statement in ddl(PZ_TABLES, conn.postgres):
|
|
185
|
+
conn.execute(statement)
|
|
186
|
+
# Übernommene Datenbank mit Konten: die Einrichtungsseite bleibt zu (ADR 0015 §5)
|
|
187
|
+
if conn.scalar("SELECT COUNT(*) FROM users"):
|
|
188
|
+
conn.execute("INSERT INTO pz_meta (key, value) VALUES ('setup_done', ?) ON CONFLICT (key) DO NOTHING",
|
|
189
|
+
(now(),))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
STEPS = (
|
|
193
|
+
("0001_initial", initial),
|
|
194
|
+
)
|