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.
Files changed (86) hide show
  1. keepup/THIRD-PARTY.md +44 -0
  2. keepup/__init__.py +41 -0
  3. keepup/api_versions.py +100 -0
  4. keepup/audit.py +499 -0
  5. keepup/auth/__init__.py +7 -0
  6. keepup/auth/config.py +128 -0
  7. keepup/auth/dependencies.py +485 -0
  8. keepup/auth/dto/__init__.py +0 -0
  9. keepup/auth/dto/token.py +31 -0
  10. keepup/auth/factory.py +28 -0
  11. keepup/auth/login_throttle.py +151 -0
  12. keepup/auth/oidc.py +245 -0
  13. keepup/auth/oidc_policy.py +111 -0
  14. keepup/auth/oidc_routes.py +288 -0
  15. keepup/auth/panel_session.py +220 -0
  16. keepup/auth/permissions.py +59 -0
  17. keepup/auth/providers/__init__.py +0 -0
  18. keepup/auth/providers/base.py +168 -0
  19. keepup/auth/providers/local.py +180 -0
  20. keepup/auth/routes.py +660 -0
  21. keepup/auth/seed_accounts.py +322 -0
  22. keepup/auth/session_lifetime.py +104 -0
  23. keepup/auth/signing_key.py +138 -0
  24. keepup/auth/websocket.py +86 -0
  25. keepup/cluster.py +634 -0
  26. keepup/db.py +663 -0
  27. keepup/events.py +764 -0
  28. keepup/factory.py +484 -0
  29. keepup/instance.py +46 -0
  30. keepup/integrations.py +260 -0
  31. keepup/locks.py +412 -0
  32. keepup/logging_setup.py +690 -0
  33. keepup/metrics.py +818 -0
  34. keepup/metrics_retention.py +376 -0
  35. keepup/modules.py +572 -0
  36. keepup/notification_bus.py +355 -0
  37. keepup/plugins/__init__.py +9 -0
  38. keepup/plugins/admin.py +246 -0
  39. keepup/plugins/base.py +234 -0
  40. keepup/plugins/enablement.py +184 -0
  41. keepup/plugins/registry.py +171 -0
  42. keepup/plugins/route_mask.py +338 -0
  43. keepup/plugins/routes.py +376 -0
  44. keepup/roles.py +17 -0
  45. keepup/scheduler.py +93 -0
  46. keepup/schema.py +295 -0
  47. keepup/sections.json +104 -0
  48. keepup/settings.py +184 -0
  49. keepup/static/css/aos.css +1 -0
  50. keepup/static/css/main_nebula.css +232 -0
  51. keepup/static/css/main_new.css +852 -0
  52. keepup/static/css/tailwind.css +1 -0
  53. keepup/static/index_nebula.html +293 -0
  54. keepup/static/index_new.html +286 -0
  55. keepup/static/js/aos.js +1 -0
  56. keepup/static/js/feather-icons.js +13 -0
  57. keepup/static/js/main_new.js +1861 -0
  58. keepup/static/js/tailwind.js +83 -0
  59. keepup/static/modules/css/background_tasks.css +233 -0
  60. keepup/static/modules/css/cluster.css +16 -0
  61. keepup/static/modules/css/event_manager.css +386 -0
  62. keepup/static/modules/css/integration_logs.css +33 -0
  63. keepup/static/modules/css/metrics.css +115 -0
  64. keepup/static/modules/css/modules.css +189 -0
  65. keepup/static/modules/css/themes.css +563 -0
  66. keepup/static/modules/css/users.css +278 -0
  67. keepup/static/modules/js/background_tasks.js +657 -0
  68. keepup/static/modules/js/chart.js +14 -0
  69. keepup/static/modules/js/chartjs-adapter-date-fns.bundle.min.js +7 -0
  70. keepup/static/modules/js/cluster.js +363 -0
  71. keepup/static/modules/js/event_manager.js +979 -0
  72. keepup/static/modules/js/integration_logs.js +767 -0
  73. keepup/static/modules/js/metrics.js +908 -0
  74. keepup/static/modules/js/modules.js +1086 -0
  75. keepup/static/modules/js/themes.js +653 -0
  76. keepup/static/modules/js/users.js +784 -0
  77. keepup/tables.py +302 -0
  78. keepup/themes.py +496 -0
  79. keepup/web.py +182 -0
  80. keepup_admin-0.1.0.dist-info/METADATA +117 -0
  81. keepup_admin-0.1.0.dist-info/RECORD +86 -0
  82. keepup_admin-0.1.0.dist-info/WHEEL +5 -0
  83. keepup_admin-0.1.0.dist-info/licenses/LICENSE +202 -0
  84. keepup_admin-0.1.0.dist-info/licenses/NOTICE +22 -0
  85. keepup_admin-0.1.0.dist-info/licenses/THIRD-PARTY.md +44 -0
  86. 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
+