keepup-admin 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.
- keepup/THIRD-PARTY.md +44 -0
- keepup/__init__.py +41 -0
- keepup/api_versions.py +100 -0
- keepup/audit.py +499 -0
- keepup/auth/__init__.py +7 -0
- keepup/auth/config.py +128 -0
- keepup/auth/dependencies.py +485 -0
- keepup/auth/dto/__init__.py +0 -0
- keepup/auth/dto/token.py +31 -0
- keepup/auth/factory.py +28 -0
- keepup/auth/login_throttle.py +151 -0
- keepup/auth/oidc.py +245 -0
- keepup/auth/oidc_policy.py +111 -0
- keepup/auth/oidc_routes.py +288 -0
- keepup/auth/panel_session.py +220 -0
- keepup/auth/permissions.py +59 -0
- keepup/auth/providers/__init__.py +0 -0
- keepup/auth/providers/base.py +168 -0
- keepup/auth/providers/local.py +180 -0
- keepup/auth/routes.py +660 -0
- keepup/auth/seed_accounts.py +322 -0
- keepup/auth/session_lifetime.py +104 -0
- keepup/auth/signing_key.py +138 -0
- keepup/auth/websocket.py +86 -0
- keepup/cluster.py +634 -0
- keepup/db.py +663 -0
- keepup/events.py +764 -0
- keepup/factory.py +484 -0
- keepup/instance.py +46 -0
- keepup/integrations.py +260 -0
- keepup/locks.py +412 -0
- keepup/logging_setup.py +690 -0
- keepup/metrics.py +818 -0
- keepup/metrics_retention.py +376 -0
- keepup/modules.py +572 -0
- keepup/notification_bus.py +355 -0
- keepup/plugins/__init__.py +9 -0
- keepup/plugins/admin.py +246 -0
- keepup/plugins/base.py +234 -0
- keepup/plugins/enablement.py +184 -0
- keepup/plugins/registry.py +171 -0
- keepup/plugins/route_mask.py +338 -0
- keepup/plugins/routes.py +376 -0
- keepup/roles.py +17 -0
- keepup/scheduler.py +93 -0
- keepup/schema.py +295 -0
- keepup/sections.json +104 -0
- keepup/settings.py +184 -0
- keepup/static/css/aos.css +1 -0
- keepup/static/css/main_nebula.css +232 -0
- keepup/static/css/main_new.css +852 -0
- keepup/static/css/tailwind.css +1 -0
- keepup/static/index_nebula.html +293 -0
- keepup/static/index_new.html +286 -0
- keepup/static/js/aos.js +1 -0
- keepup/static/js/feather-icons.js +13 -0
- keepup/static/js/main_new.js +1861 -0
- keepup/static/js/tailwind.js +83 -0
- keepup/static/modules/css/background_tasks.css +233 -0
- keepup/static/modules/css/cluster.css +16 -0
- keepup/static/modules/css/event_manager.css +386 -0
- keepup/static/modules/css/integration_logs.css +33 -0
- keepup/static/modules/css/metrics.css +115 -0
- keepup/static/modules/css/modules.css +189 -0
- keepup/static/modules/css/themes.css +563 -0
- keepup/static/modules/css/users.css +278 -0
- keepup/static/modules/js/background_tasks.js +657 -0
- keepup/static/modules/js/chart.js +14 -0
- keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
- keepup/static/modules/js/cluster.js +363 -0
- keepup/static/modules/js/event_manager.js +979 -0
- keepup/static/modules/js/integration_logs.js +767 -0
- keepup/static/modules/js/metrics.js +908 -0
- keepup/static/modules/js/modules.js +1086 -0
- keepup/static/modules/js/themes.js +653 -0
- keepup/static/modules/js/users.js +784 -0
- keepup/tables.py +302 -0
- keepup/themes.py +496 -0
- keepup/web.py +182 -0
- keepup_admin-0.1.0.dist-info/METADATA +117 -0
- keepup_admin-0.1.0.dist-info/RECORD +86 -0
- keepup_admin-0.1.0.dist-info/WHEEL +5 -0
- keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
- keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
- keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
- keepup_admin-0.1.0.dist-info/top_level.txt +1 -0
keepup/tables.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Tables declared once and created on either dialect.
|
|
2
|
+
|
|
3
|
+
Until this module every table was written out twice -- once for PostgreSQL and
|
|
4
|
+
once for SQLite -- and every column added later was a third and fourth copy in
|
|
5
|
+
an ``ALTER``. The copies drifted: a foreign key on one side only, ``VARCHAR``
|
|
6
|
+
on one and ``TEXT`` on the other, a column added to the PostgreSQL branch and
|
|
7
|
+
forgotten in the SQLite one.
|
|
8
|
+
|
|
9
|
+
A table is now a SQLAlchemy ``Table`` in the owner's data-access module, and
|
|
10
|
+
``ensure_tables()`` makes the database match it, idempotently and on every
|
|
11
|
+
start -- there are still no migrations:
|
|
12
|
+
|
|
13
|
+
- the table is created with ``CREATE TABLE IF NOT EXISTS``, which, unlike
|
|
14
|
+
inspecting first and creating second, is safe when two replicas start at
|
|
15
|
+
once;
|
|
16
|
+
- a declared column the table lacks is added -- one catalogue read per table,
|
|
17
|
+
not an ``ALTER`` per column per start, which on PostgreSQL would take a lock
|
|
18
|
+
each time;
|
|
19
|
+
- declared indexes are created ``IF NOT EXISTS``.
|
|
20
|
+
|
|
21
|
+
What a column cannot say for both dialects at once is said with SQLAlchemy's
|
|
22
|
+
own means: ``with_variant()`` for a type, ``ddl_if(dialect=...)`` for a
|
|
23
|
+
constraint that only one dialect had. The DDL is compiled for the dialect in
|
|
24
|
+
force and run through ``DatabaseManagerV2.execute_commit`` -- the same path as
|
|
25
|
+
any other statement, so a test harness that rebinds the manager sees it too.
|
|
26
|
+
|
|
27
|
+
See doc/database_tables.md.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import logging
|
|
31
|
+
import re
|
|
32
|
+
from typing import Dict, Iterable, List, Set
|
|
33
|
+
|
|
34
|
+
from sqlalchemy import BigInteger, Column, ForeignKeyConstraint, Index, Integer, MetaData, Sequence, Table
|
|
35
|
+
from sqlalchemy import text as sql_text
|
|
36
|
+
from sqlalchemy.ext.compiler import compiles
|
|
37
|
+
from sqlalchemy.sql.expression import ColumnElement
|
|
38
|
+
from sqlalchemy.dialects import postgresql, sqlite
|
|
39
|
+
from sqlalchemy.schema import CreateColumn, CreateIndex, CreateSequence, CreateTable
|
|
40
|
+
|
|
41
|
+
from keepup import db as _db
|
|
42
|
+
|
|
43
|
+
#: What an application may import from this module. Everything else is
|
|
44
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
45
|
+
__all__ = [
|
|
46
|
+
"ensure_columns",
|
|
47
|
+
"ensure_tables",
|
|
48
|
+
"foreign_key",
|
|
49
|
+
"identifier",
|
|
50
|
+
"per_dialect",
|
|
51
|
+
"table",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
logger = logging.getLogger(__name__)
|
|
55
|
+
|
|
56
|
+
#: One catalogue for every declared table of the process: a foreign key names
|
|
57
|
+
#: its target table, and SQLAlchemy resolves it here.
|
|
58
|
+
metadata = MetaData()
|
|
59
|
+
|
|
60
|
+
#: The default "now" of the hand-written DDL, kept as the database's own.
|
|
61
|
+
NOW = sql_text("CURRENT_TIMESTAMP")
|
|
62
|
+
|
|
63
|
+
_DIALECTS = {"postgres": postgresql.dialect(), "sqlite": sqlite.dialect()}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def dialect():
|
|
67
|
+
"""The SQLAlchemy dialect of the database in force."""
|
|
68
|
+
return _DIALECTS["postgres"] if _db.db_config.is_postgres() else _DIALECTS["sqlite"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def table(name: str, *columns, **kwargs) -> Table:
|
|
72
|
+
"""Declare a table in the shared catalogue.
|
|
73
|
+
|
|
74
|
+
``sqlite_autoincrement`` is on, as it was in every hand-written
|
|
75
|
+
``INTEGER PRIMARY KEY AUTOINCREMENT``: without it SQLite reuses the ids of
|
|
76
|
+
deleted rows, and an id that once meant another order is a bug waiting.
|
|
77
|
+
"""
|
|
78
|
+
kwargs.setdefault("sqlite_autoincrement", True)
|
|
79
|
+
# Test harnesses re-import data-access modules; a second import replaces
|
|
80
|
+
# the declaration instead of failing. Two different modules declaring one
|
|
81
|
+
# name is caught by tests/schema_declaration_tests.py, not here.
|
|
82
|
+
kwargs.setdefault("extend_existing", True)
|
|
83
|
+
declared = Table(name, metadata, *columns, **kwargs)
|
|
84
|
+
# SQLAlchemy writes NOT NULL on every key column; the hand-written SQLite
|
|
85
|
+
# DDL never did on a single-column key (``id INTEGER PRIMARY KEY
|
|
86
|
+
# AUTOINCREMENT``). On PostgreSQL the primary key makes the column NOT NULL
|
|
87
|
+
# by itself, so dropping the explicit word changes nothing there and keeps
|
|
88
|
+
# SQLite as it was. Composite keys were written with NOT NULL on each part
|
|
89
|
+
# and keep whatever the declaration says.
|
|
90
|
+
key = list(declared.primary_key.columns)
|
|
91
|
+
if len(key) == 1:
|
|
92
|
+
key[0].nullable = True
|
|
93
|
+
return declared
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def sequence(name: str, start: int = 1, increment: int = 1) -> Sequence:
|
|
97
|
+
"""A PostgreSQL sequence read by hand (``nextval``); SQLite has none."""
|
|
98
|
+
return Sequence(name, start=start, increment=increment, metadata=metadata)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def ensure_sequences(*sequences: Sequence) -> None:
|
|
102
|
+
"""Create these sequences on PostgreSQL; nothing to do on SQLite."""
|
|
103
|
+
if not _db.db_config.is_postgres():
|
|
104
|
+
return
|
|
105
|
+
for declared in sequences:
|
|
106
|
+
_db.DatabaseManagerV2.execute_commit(
|
|
107
|
+
str(CreateSequence(declared, if_not_exists=True).compile(dialect=dialect())).strip())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class per_dialect(ColumnElement):
|
|
111
|
+
"""A server default (or any SQL fragment) that the two dialects spell differently.
|
|
112
|
+
|
|
113
|
+
``server_default=per_dialect(postgres="NOW()", sqlite="CURRENT_TIMESTAMP")``.
|
|
114
|
+
SQLAlchemy has ``with_variant`` for types but nothing for defaults, and a
|
|
115
|
+
default that differed between the hand-written branches must keep differing.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
inherit_cache = False
|
|
119
|
+
|
|
120
|
+
def __init__(self, postgres: str, sqlite: str):
|
|
121
|
+
self.postgres = postgres
|
|
122
|
+
self.sqlite = sqlite
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@compiles(per_dialect)
|
|
126
|
+
def _compile_per_dialect(element, compiler, **kw):
|
|
127
|
+
return element.postgres if compiler.dialect.name == "postgresql" else element.sqlite
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _wanted_here(item, current) -> bool:
|
|
131
|
+
"""Whether an index or constraint marked ``ddl_if(dialect=...)`` belongs to this dialect."""
|
|
132
|
+
condition = getattr(item, "_ddl_if", None)
|
|
133
|
+
if condition is None or not condition.dialect:
|
|
134
|
+
return True
|
|
135
|
+
wanted = condition.dialect if isinstance(condition.dialect, (list, tuple, set)) else [condition.dialect]
|
|
136
|
+
return current.name in wanted
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def foreign_key(columns, target: str, target_columns=("id",), **kwargs) -> ForeignKeyConstraint:
|
|
140
|
+
"""A foreign key to a table that may be declared by another module, later.
|
|
141
|
+
|
|
142
|
+
SQLAlchemy resolves the target in the catalogue when compiling. Modules are
|
|
143
|
+
imported in whatever order start-up and tests happen to use, so the target
|
|
144
|
+
may not be declared yet; a placeholder holding just the referenced columns
|
|
145
|
+
stands in (``keep_existing``: it never overrides a real declaration), and
|
|
146
|
+
the real declaration replaces it when it comes (``extend_existing``).
|
|
147
|
+
The placeholder is never created: ``ensure_tables`` makes only the tables
|
|
148
|
+
it is given.
|
|
149
|
+
"""
|
|
150
|
+
if target not in metadata.tables:
|
|
151
|
+
Table(target, metadata, *[Column(name, Integer, primary_key=True) for name in target_columns],
|
|
152
|
+
keep_existing=True)
|
|
153
|
+
columns = [columns] if isinstance(columns, str) else list(columns)
|
|
154
|
+
return ForeignKeyConstraint(columns, [f"{target}.{c}" for c in target_columns], **kwargs)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def auto_id(name: str = "id") -> Column:
|
|
158
|
+
"""``SERIAL PRIMARY KEY`` / ``INTEGER PRIMARY KEY AUTOINCREMENT``."""
|
|
159
|
+
return Column(name, Integer, primary_key=True, autoincrement=True)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def big_auto_id(name: str = "id") -> Column:
|
|
163
|
+
"""``BIGSERIAL PRIMARY KEY`` on PostgreSQL, the same integer key on SQLite."""
|
|
164
|
+
return Column(name, BigInteger().with_variant(Integer(), "sqlite"),
|
|
165
|
+
primary_key=True, autoincrement=True)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def create_statements(tables: Iterable[Table]) -> List[str]:
|
|
169
|
+
"""The DDL ``ensure_tables`` runs for these tables, compiled for the dialect in force."""
|
|
170
|
+
current = dialect()
|
|
171
|
+
statements = []
|
|
172
|
+
for declared in tables:
|
|
173
|
+
statements.append(str(CreateTable(declared, if_not_exists=True).compile(dialect=current)).strip())
|
|
174
|
+
for index in sorted(declared.indexes, key=lambda i: i.name or ""):
|
|
175
|
+
if not _wanted_here(index, current):
|
|
176
|
+
continue
|
|
177
|
+
statements.append(str(CreateIndex(index, if_not_exists=True).compile(dialect=current)).strip())
|
|
178
|
+
return statements
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def existing_columns(name: str) -> Set[str]:
|
|
182
|
+
"""The columns the table has now, asked of the database in one read."""
|
|
183
|
+
manager = _db.DatabaseManagerV2
|
|
184
|
+
if _db.db_config.is_postgres():
|
|
185
|
+
# current_schema(): a table of the same name in another schema must not
|
|
186
|
+
# make a column look present when it is not.
|
|
187
|
+
rows = manager.execute(
|
|
188
|
+
"SELECT column_name FROM information_schema.columns "
|
|
189
|
+
"WHERE table_name = :table AND table_schema = current_schema()",
|
|
190
|
+
{"table": name})
|
|
191
|
+
return {row["column_name"] for row in rows or []}
|
|
192
|
+
# PRAGMA takes no parameters; the name comes from a declaration, never a request.
|
|
193
|
+
rows = manager.execute(f'PRAGMA table_info("{identifier(name)}")')
|
|
194
|
+
return {row["name"] for row in rows or []}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
#: What a table or column may be called. Checked rather than escaped: an
|
|
198
|
+
#: identifier of a schema is written by the code that owns it, so anything
|
|
199
|
+
#: outside this shape is a mistake to stop, not a string to quote. The package
|
|
200
|
+
#: offers a template where a table name is data (a table per application), and
|
|
201
|
+
#: an author who copies it without a check of their own would otherwise reach
|
|
202
|
+
#: the DDL with whatever arrived (task keepup-15).
|
|
203
|
+
IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def identifier(name: str) -> str:
|
|
207
|
+
"""The name, if it is one.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
name: a table or column name about to be written into a statement.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
The same name.
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
ValueError: when it is not a plain identifier.
|
|
217
|
+
"""
|
|
218
|
+
if not isinstance(name, str) or not IDENTIFIER.match(name):
|
|
219
|
+
raise ValueError(f"{name!r} is not a usable table or column name")
|
|
220
|
+
return name
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def column_definition(column: Column) -> str:
|
|
224
|
+
"""What follows the column name in ``ADD COLUMN``, for the dialect in force."""
|
|
225
|
+
spec = str(CreateColumn(column).compile(dialect=dialect())).strip()
|
|
226
|
+
quoted = dialect().identifier_preparer.quote(column.name)
|
|
227
|
+
for prefix in (quoted + " ", column.name + " "):
|
|
228
|
+
if spec.startswith(prefix):
|
|
229
|
+
return spec[len(prefix):]
|
|
230
|
+
return spec
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def ensure_tables(*tables: Table) -> Dict[str, List[str]]:
|
|
234
|
+
"""Make the database hold these tables, their columns and their indexes.
|
|
235
|
+
|
|
236
|
+
Returns, per table, the columns this call added -- empty when the table was
|
|
237
|
+
already complete. Order matters only for foreign keys, and the caller passes
|
|
238
|
+
tables in the order their references need, as the hand-written DDL did.
|
|
239
|
+
"""
|
|
240
|
+
manager = _db.DatabaseManagerV2
|
|
241
|
+
current = dialect()
|
|
242
|
+
added: Dict[str, List[str]] = {}
|
|
243
|
+
for declared in tables:
|
|
244
|
+
manager.execute_commit(
|
|
245
|
+
str(CreateTable(declared, if_not_exists=True).compile(dialect=current)).strip())
|
|
246
|
+
added[declared.name] = _add_missing(declared.name, declared.columns)
|
|
247
|
+
for index in sorted(declared.indexes, key=lambda i: i.name or ""):
|
|
248
|
+
if not _wanted_here(index, current):
|
|
249
|
+
continue
|
|
250
|
+
manager.execute_commit(
|
|
251
|
+
str(CreateIndex(index, if_not_exists=True).compile(dialect=current)).strip())
|
|
252
|
+
return added
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _add_missing(name: str, columns: Iterable[Column]) -> List[str]:
|
|
256
|
+
manager = _db.DatabaseManagerV2
|
|
257
|
+
present = existing_columns(name)
|
|
258
|
+
missing = [c for c in columns if c.name not in present]
|
|
259
|
+
for column in missing:
|
|
260
|
+
manager.add_column_if_missing(name, column.name, column_definition(column))
|
|
261
|
+
return [c.name for c in missing]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def ensure_columns(name: str, *columns: Column) -> List[str]:
|
|
265
|
+
"""Add columns to a table another module declares.
|
|
266
|
+
|
|
267
|
+
The application extends tables of the framework (``users`` gets a tenant's
|
|
268
|
+
SSH key, a Telegram persona); the framework cannot know those columns, and
|
|
269
|
+
declaring ``users`` a second time would make two owners of one table. The
|
|
270
|
+
columns are declared here, by the module that needs them, and added to the
|
|
271
|
+
existing table -- never creating it: that is its owner's job.
|
|
272
|
+
"""
|
|
273
|
+
for column in columns:
|
|
274
|
+
if getattr(column, "table", None) is None:
|
|
275
|
+
# A free column cannot be compiled; a throw-away table outside the
|
|
276
|
+
# catalogue gives it a parent without declaring anything.
|
|
277
|
+
Table(f"_columns_of_{name}", MetaData(), column)
|
|
278
|
+
return _add_missing(name, columns)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def ensure_indexes(name: str, *indexes: Index) -> None:
|
|
282
|
+
"""Create indexes on a table another module declares.
|
|
283
|
+
|
|
284
|
+
The counterpart of ``ensure_columns``: a plugin that joins against a table
|
|
285
|
+
it does not own may need an index there, and declaring the table a second
|
|
286
|
+
time to carry it would make two owners of one table. Each index is given
|
|
287
|
+
by name and column names; the table is never created here.
|
|
288
|
+
"""
|
|
289
|
+
current = dialect()
|
|
290
|
+
for index in indexes:
|
|
291
|
+
if index.table is None:
|
|
292
|
+
# Unbound columns cannot be compiled; a throw-away table outside the
|
|
293
|
+
# catalogue gives the index its columns without declaring anything.
|
|
294
|
+
Table(f"_indexes_of_{name}", MetaData(),
|
|
295
|
+
*[Column(expression, Integer) for expression in index.expressions], index)
|
|
296
|
+
if not _wanted_here(index, current):
|
|
297
|
+
continue
|
|
298
|
+
text = str(CreateIndex(index, if_not_exists=True).compile(dialect=current)).strip()
|
|
299
|
+
# The throw-away table's name is not the real one.
|
|
300
|
+
text = text.replace(f"_indexes_of_{name}", name)
|
|
301
|
+
_db.DatabaseManagerV2.execute_commit(text)
|
|
302
|
+
|