keepup-modules 0.5.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_modules/__init__.py +42 -0
- keepup_modules/catalogue.py +812 -0
- keepup_modules/decisions.py +281 -0
- keepup_modules/plugin.py +120 -0
- keepup_modules/tables.py +81 -0
- keepup_modules-0.5.0.dist-info/METADATA +23 -0
- keepup_modules-0.5.0.dist-info/RECORD +10 -0
- keepup_modules-0.5.0.dist-info/WHEEL +5 -0
- keepup_modules-0.5.0.dist-info/entry_points.txt +2 -0
- keepup_modules-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""What the deployment decided about each plugin, and the surface that shows it.
|
|
2
|
+
|
|
3
|
+
Task keepup-113. The decision belongs to the modules capability: the kernel
|
|
4
|
+
reads it while it composes the runtime -- a plugin an administrator switched off
|
|
5
|
+
is not loaded at all -- and the panel shows it and changes it. The reading is
|
|
6
|
+
what the kernel keeps (through `keepup.plugins.registry`, which asks this
|
|
7
|
+
distribution and falls back to the file when it is not installed); the overrides
|
|
8
|
+
themselves, the endpoints and the trail an administrator's change leaves live
|
|
9
|
+
here.
|
|
10
|
+
|
|
11
|
+
Three sources decide whether a declared plugin runs: its own flag in the
|
|
12
|
+
configuration file, an administrator's override kept in this deployment's
|
|
13
|
+
database, and the environment of this stand. The rule itself is
|
|
14
|
+
``keepup.plugins.enablement``, which is pure; what lives here is everything
|
|
15
|
+
around it that is not -- reading the file, reading and writing the overrides,
|
|
16
|
+
and the administrative endpoints that report the decision and change it.
|
|
17
|
+
|
|
18
|
+
The endpoints are the reason any of this is worth reporting at all. Once the
|
|
19
|
+
server is up, a plugin that was switched off and a plugin that fell over look
|
|
20
|
+
exactly the same from outside: the routes are absent and the server is
|
|
21
|
+
healthy. The difference is kept by the manager and published here.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import os
|
|
28
|
+
from typing import Any, Dict
|
|
29
|
+
|
|
30
|
+
from fastapi import Depends, HTTPException
|
|
31
|
+
|
|
32
|
+
from keepup import clock
|
|
33
|
+
from keepup_auth import user_roles
|
|
34
|
+
from keepup_auth.dependencies import get_current_admin, get_current_user
|
|
35
|
+
from keepup_audit import trail as admin_trail
|
|
36
|
+
from keepup.plugins import enablement
|
|
37
|
+
from keepup.plugins import route_mask
|
|
38
|
+
|
|
39
|
+
#: What an application may import from this module. Everything else is
|
|
40
|
+
#: internal and may change without notice -- see doc/keepup.md.
|
|
41
|
+
__all__ = [
|
|
42
|
+
"clear_plugin_override",
|
|
43
|
+
"get_plugins",
|
|
44
|
+
"get_plugins_status",
|
|
45
|
+
"read_plugin_overrides",
|
|
46
|
+
"register_plugin_admin_routes",
|
|
47
|
+
"write_plugin_override",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
logger = logging.getLogger(__name__)
|
|
51
|
+
|
|
52
|
+
#: Where this deployment declares its plugins. The application names the file
|
|
53
|
+
#: in KeepupSettings.plugins_config_path, and configure() is handed that value
|
|
54
|
+
#: when the application is built; this is what a caller that never does reads.
|
|
55
|
+
MODULES_CONFIG_PATH = 'config/modules.json'
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def configure(path: str = None) -> None:
|
|
59
|
+
"""Where the administrative endpoints read the declared plugins from.
|
|
60
|
+
|
|
61
|
+
initialize_plugins() is handed the application's own path, and until this
|
|
62
|
+
existed the endpoints around it read the default instead: an application
|
|
63
|
+
that keeps its catalogue anywhere else answered 500 on the panel's plugin
|
|
64
|
+
report, and said so only in the log (keepup-93's integration test, run from
|
|
65
|
+
a directory without config/modules.json, is where it showed).
|
|
66
|
+
"""
|
|
67
|
+
global MODULES_CONFIG_PATH
|
|
68
|
+
if path:
|
|
69
|
+
MODULES_CONFIG_PATH = path
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def read_plugin_overrides():
|
|
73
|
+
"""What an administrator decided from the panel, per plugin (task 65).
|
|
74
|
+
|
|
75
|
+
Never fatal: a database that cannot answer must not keep the server from
|
|
76
|
+
starting. Without the overrides the file and the environment still decide,
|
|
77
|
+
which is the behaviour that existed before the panel could.
|
|
78
|
+
"""
|
|
79
|
+
from keepup_db import DatabaseManagerV2
|
|
80
|
+
try:
|
|
81
|
+
rows = DatabaseManagerV2.execute(
|
|
82
|
+
"SELECT plugin_id, enabled FROM plugin_overrides", {})
|
|
83
|
+
except Exception as e:
|
|
84
|
+
logger.warning(f"Could not read plugin overrides, falling back to file and environment: {e}")
|
|
85
|
+
return {}
|
|
86
|
+
return {row["plugin_id"]: bool(row["enabled"]) for row in rows}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def write_plugin_override(plugin_id: str, enabled: bool, changed_by=None) -> None:
|
|
90
|
+
"""Record the panel's decision about one plugin, replacing any previous."""
|
|
91
|
+
from keepup_db import DatabaseManagerV2
|
|
92
|
+
updated = DatabaseManagerV2.execute_commit(
|
|
93
|
+
"UPDATE plugin_overrides SET enabled = :enabled, changed_by = :who, "
|
|
94
|
+
"changed_at = :now WHERE plugin_id = :pid",
|
|
95
|
+
{"enabled": enabled, "who": changed_by, "now": clock.utcnow(), "pid": plugin_id})
|
|
96
|
+
if not updated:
|
|
97
|
+
DatabaseManagerV2.execute_commit(
|
|
98
|
+
"INSERT INTO plugin_overrides (plugin_id, enabled, changed_by, changed_at) "
|
|
99
|
+
"VALUES (:pid, :enabled, :who, :now)",
|
|
100
|
+
{"pid": plugin_id, "enabled": enabled, "who": changed_by, "now": clock.utcnow()})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def clear_plugin_override(plugin_id: str) -> None:
|
|
104
|
+
"""Give the decision back to the file and the environment."""
|
|
105
|
+
from keepup_db import DatabaseManagerV2
|
|
106
|
+
DatabaseManagerV2.execute_commit(
|
|
107
|
+
"DELETE FROM plugin_overrides WHERE plugin_id = :pid", {"pid": plugin_id})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _declared_plugin_ids():
|
|
111
|
+
with open(MODULES_CONFIG_PATH, 'r', encoding='utf-8') as f:
|
|
112
|
+
config = json.load(f)
|
|
113
|
+
return {p["id"] for p in enablement.declared_plugins(config)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _refuse_if_the_deployment_decides(plugin_id: str):
|
|
117
|
+
"""A plugin named in the environment is not the panel's to switch.
|
|
118
|
+
|
|
119
|
+
Storing an override the environment would overrule is a trap: the
|
|
120
|
+
administrator switches it, restarts, and nothing changes. The refusal
|
|
121
|
+
names the reason instead.
|
|
122
|
+
"""
|
|
123
|
+
if plugin_id not in _declared_plugin_ids():
|
|
124
|
+
raise HTTPException(status_code=404, detail="config/modules.json does not declare this plugin")
|
|
125
|
+
if enablement.decided_by_environment(plugin_id, os.environ):
|
|
126
|
+
raise HTTPException(
|
|
127
|
+
status_code=409,
|
|
128
|
+
detail=(f"This plugin is decided by the deployment: "
|
|
129
|
+
f"{enablement.ENABLE_ENV}/{enablement.DISABLE_ENV} name it. "
|
|
130
|
+
f"Change the environment of this stand and restart."))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def get_plugins(manager, current_user):
|
|
134
|
+
"""The plugins available to this user: granted to a role of theirs and running.
|
|
135
|
+
|
|
136
|
+
A module-level function rather than only a closure of the route, because
|
|
137
|
+
this is the decision worth checking and a closure can be reached only
|
|
138
|
+
through the HTTP layer.
|
|
139
|
+
|
|
140
|
+
A user holds a set of roles, and the lists of all of them are glued together
|
|
141
|
+
-- each plugin once, however many of the roles name it (keepup-51).
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
with open(MODULES_CONFIG_PATH, 'r', encoding='utf-8') as f:
|
|
145
|
+
config = json.load(f)
|
|
146
|
+
|
|
147
|
+
held = set(user_roles.held_by(current_user))
|
|
148
|
+
allowed_plugin_names = set()
|
|
149
|
+
for role in config.get('roles', []):
|
|
150
|
+
if role.get('name') in held:
|
|
151
|
+
allowed_plugin_names.update(role.get('plugins', []))
|
|
152
|
+
|
|
153
|
+
if not allowed_plugin_names:
|
|
154
|
+
return {"plugins": []}
|
|
155
|
+
|
|
156
|
+
# The role's list is visibility, not enablement: a plugin the role is
|
|
157
|
+
# shown but that did not come up has no routes to advertise.
|
|
158
|
+
available_plugins = []
|
|
159
|
+
for plugin_config in config.get('plugins', []):
|
|
160
|
+
if plugin_config.get('id') in allowed_plugin_names \
|
|
161
|
+
and manager.get_plugin(plugin_config.get('id')) is not None:
|
|
162
|
+
available_plugins.append({
|
|
163
|
+
"id": plugin_config.get('id'),
|
|
164
|
+
"name": plugin_config.get('name'),
|
|
165
|
+
"config": plugin_config.get('config', {})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
return {"plugins": available_plugins}
|
|
169
|
+
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.error(f"Error getting plugins: {str(e)}")
|
|
172
|
+
return {"plugins": []}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
async def get_plugins_status(manager, current_user=None):
|
|
176
|
+
"""The state of every declared plugin.
|
|
177
|
+
|
|
178
|
+
Enabled or not and from where, loaded or not, initialised or not, and why.
|
|
179
|
+
An enabled plugin that did not come up is otherwise visible only as a line
|
|
180
|
+
in the start-up log.
|
|
181
|
+
"""
|
|
182
|
+
try:
|
|
183
|
+
with open(MODULES_CONFIG_PATH, 'r', encoding='utf-8') as f:
|
|
184
|
+
config = json.load(f)
|
|
185
|
+
# Two resolutions: the snapshot taken at start-up is what is actually
|
|
186
|
+
# running, and the one computed now carries the panel's latest
|
|
187
|
+
# decisions. Where they differ, the row awaits a restart.
|
|
188
|
+
desired = enablement.resolve(
|
|
189
|
+
config, os.environ, overrides=await asyncio.to_thread(read_plugin_overrides))
|
|
190
|
+
return {"plugins": enablement.status_report(
|
|
191
|
+
config, manager.resolution, manager,
|
|
192
|
+
desired=desired, environ=os.environ)}
|
|
193
|
+
except Exception as e:
|
|
194
|
+
logger.error(f"Error reading plugin status: {str(e)}")
|
|
195
|
+
raise HTTPException(status_code=500, detail="Internal server error")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
async def set_plugin_enabled(plugin_id: str, request: Dict[str, Any], admin: dict):
|
|
199
|
+
"""Switch a backend plugin on or off from the panel.
|
|
200
|
+
|
|
201
|
+
Takes effect at the next start: the plugin's routes are built once, at
|
|
202
|
+
start-up, and are not removed from a running application.
|
|
203
|
+
"""
|
|
204
|
+
_refuse_if_the_deployment_decides(plugin_id)
|
|
205
|
+
enabled = (request or {}).get("enabled")
|
|
206
|
+
if not isinstance(enabled, bool):
|
|
207
|
+
raise HTTPException(status_code=400, detail="Field 'enabled' must be true or false")
|
|
208
|
+
try:
|
|
209
|
+
await asyncio.to_thread(write_plugin_override, plugin_id, enabled,
|
|
210
|
+
changed_by=admin.get("id"))
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.error(f"Could not store the plugin override for {plugin_id}: {e}")
|
|
213
|
+
raise HTTPException(status_code=500, detail="Internal server error")
|
|
214
|
+
await admin_trail.record(admin_trail.PLUGIN_DECISION_CHANGED, admin,
|
|
215
|
+
f"Plugin {plugin_id} {'enabled' if enabled else 'disabled'} "
|
|
216
|
+
f"from the panel", plugin_id=plugin_id, enabled=enabled)
|
|
217
|
+
return {"success": True, "plugin_id": plugin_id, "enabled": enabled,
|
|
218
|
+
"pending_restart": True}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def clear_plugin_enabled(plugin_id: str, admin: dict = None):
|
|
222
|
+
"""Give the decision about this plugin back to the file and the environment."""
|
|
223
|
+
_refuse_if_the_deployment_decides(plugin_id)
|
|
224
|
+
try:
|
|
225
|
+
await asyncio.to_thread(clear_plugin_override, plugin_id)
|
|
226
|
+
except Exception as e:
|
|
227
|
+
logger.error(f"Could not clear the plugin override for {plugin_id}: {e}")
|
|
228
|
+
raise HTTPException(status_code=500, detail="Internal server error")
|
|
229
|
+
await admin_trail.record(admin_trail.PLUGIN_DECISION_CHANGED, admin,
|
|
230
|
+
f"Decision about plugin {plugin_id} given back to the file",
|
|
231
|
+
plugin_id=plugin_id, enabled=None)
|
|
232
|
+
return {"success": True, "plugin_id": plugin_id}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def register_plugin_admin_routes(app, manager):
|
|
236
|
+
"""Register the plugin endpoints on the application."""
|
|
237
|
+
|
|
238
|
+
@app.get("/api/plugins")
|
|
239
|
+
async def get_plugins_endpoint(current_user: dict = Depends(get_current_user)):
|
|
240
|
+
"""Return the plugins available to the current user."""
|
|
241
|
+
return await get_plugins(manager, current_user)
|
|
242
|
+
|
|
243
|
+
@app.get("/api/admin/plugins")
|
|
244
|
+
async def get_plugins_status_endpoint(admin: dict = Depends(get_current_admin)):
|
|
245
|
+
"""The state of every declared plugin (administrators only)."""
|
|
246
|
+
return await get_plugins_status(manager, admin)
|
|
247
|
+
|
|
248
|
+
@app.get("/api/admin/route-masks")
|
|
249
|
+
async def get_route_masks_endpoint(admin: dict = Depends(get_current_admin)):
|
|
250
|
+
"""The declared masks of every plugin route (administrators only).
|
|
251
|
+
|
|
252
|
+
Machine-readable on purpose: the point of declaring what a route
|
|
253
|
+
accepts is that a wrong request can be refused before the application,
|
|
254
|
+
at a gateway or a proxy, and neither of those can read a signature.
|
|
255
|
+
|
|
256
|
+
Behind the administrator's role because it is a map of the
|
|
257
|
+
application's surface -- not a secret, but not something to hand to
|
|
258
|
+
everybody who signed in either.
|
|
259
|
+
"""
|
|
260
|
+
return {"routes": route_mask.describe_routes(manager.get_all_api_routes())}
|
|
261
|
+
|
|
262
|
+
@app.post("/api/admin/plugins/{plugin_id}/enabled")
|
|
263
|
+
async def set_plugin_enabled_endpoint(plugin_id: str, request: Dict[str, Any],
|
|
264
|
+
admin: dict = Depends(get_current_admin)):
|
|
265
|
+
"""Switch a backend plugin on or off from the panel (administrators only)."""
|
|
266
|
+
return await set_plugin_enabled(plugin_id, request, admin)
|
|
267
|
+
|
|
268
|
+
@app.delete("/api/admin/plugins/{plugin_id}/enabled")
|
|
269
|
+
async def clear_plugin_enabled_endpoint(plugin_id: str,
|
|
270
|
+
admin: dict = Depends(get_current_admin)):
|
|
271
|
+
"""Give the decision about this plugin back to the file (administrators only)."""
|
|
272
|
+
return await clear_plugin_enabled(plugin_id, admin)
|
|
273
|
+
|
|
274
|
+
# There is deliberately no route that calls a named handler by name.
|
|
275
|
+
# get_handlers() is how one plugin calls another inside the process, which
|
|
276
|
+
# is a trusted surface: a handler is written for a caller that has already
|
|
277
|
+
# decided who may do this, so many of them check nothing themselves.
|
|
278
|
+
# Published over HTTP it turned every such handler into an endpoint open to
|
|
279
|
+
# anybody signed in, and the difference between "no such plugin" and "no
|
|
280
|
+
# such handler" listed them for free (task keepup-10). A plugin publishes
|
|
281
|
+
# what it declares in get_api_routes(), and nothing besides.
|
keepup_modules/plugin.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The catalogue of panel sections as a capability (keepup-113).
|
|
2
|
+
|
|
3
|
+
`modules.py` held the model -- which section exists, who may see it, where its
|
|
4
|
+
files are -- in the kernel's package, together with the ten routes that manage
|
|
5
|
+
it; `schema.py` created its two tables for every deployment, panel or not. The
|
|
6
|
+
capability owns both now:
|
|
7
|
+
|
|
8
|
+
* the model is the `catalogue` service, read on every request for a person's
|
|
9
|
+
roles (through the replica cache, which is why a section's change is published
|
|
10
|
+
to the other replicas);
|
|
11
|
+
* the declaration of a section is *data*: the navigation fields are columns of
|
|
12
|
+
`frontend_modules` (`keepup_modules/tables.py`), and the file the framework
|
|
13
|
+
ships is imported into the database at start instead of being read by the
|
|
14
|
+
panel;
|
|
15
|
+
* the import runs on the freeze (``post_construct``), because it is the union of
|
|
16
|
+
what the running plugins declare, the framework's own `sections.json` and the
|
|
17
|
+
application's `config/modules.json` -- the optional set is only known then
|
|
18
|
+
(doc/plugin_constructor.md, 9.1);
|
|
19
|
+
* the decision about which backend plugins run is this capability's too
|
|
20
|
+
(`plugin_decisions`), because the kernel reads it without a panel.
|
|
21
|
+
|
|
22
|
+
The ten routes are declared in `keepup_modules.catalogue` and registered by the
|
|
23
|
+
application until the transport that mounts what a plugin declares exists
|
|
24
|
+
(`keepup-http`, keepup-121).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import logging
|
|
29
|
+
from typing import Any, Dict, List
|
|
30
|
+
|
|
31
|
+
from keepup.kernel.datasource import SERVICE_DATASOURCE
|
|
32
|
+
from keepup.kernel.descriptor import KIND_OPTIONAL, PluginDescriptor
|
|
33
|
+
from keepup.plugins.base import BasePlugin
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
__all__ = ["ModulesPlugin", "SERVICE_CATALOGUE", "SERVICE_PLUGIN_DECISIONS"]
|
|
38
|
+
|
|
39
|
+
#: What the panel asks for to know which sections a person may see.
|
|
40
|
+
SERVICE_CATALOGUE = "catalogue"
|
|
41
|
+
|
|
42
|
+
#: What the runtime reads to know what an administrator decided about a plugin.
|
|
43
|
+
SERVICE_PLUGIN_DECISIONS = "plugin_decisions"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ModulesPlugin(BasePlugin):
|
|
47
|
+
"""The catalogue of sections, and the decision about the plugins."""
|
|
48
|
+
|
|
49
|
+
descriptor = PluginDescriptor(
|
|
50
|
+
id="modules",
|
|
51
|
+
name="Module management",
|
|
52
|
+
version="0.5.0",
|
|
53
|
+
distribution="keepup-modules",
|
|
54
|
+
kind=KIND_OPTIONAL,
|
|
55
|
+
priority=70,
|
|
56
|
+
provides=(f"{SERVICE_CATALOGUE}>=1", f"{SERVICE_PLUGIN_DECISIONS}>=1"),
|
|
57
|
+
requires=("datasource>=1",),
|
|
58
|
+
contributions=("routes", "sections", "tables"),
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
def __init__(self, config=None):
|
|
62
|
+
super().__init__("modules", "Module management", config)
|
|
63
|
+
|
|
64
|
+
def register(self, services):
|
|
65
|
+
"""Publish the catalogue model where there is a database to read it from."""
|
|
66
|
+
if services.has(SERVICE_DATASOURCE):
|
|
67
|
+
from keepup_modules import catalogue, decisions
|
|
68
|
+
|
|
69
|
+
services.provide(SERVICE_CATALOGUE, catalogue, version=1, plugin_id="modules")
|
|
70
|
+
services.provide(SERVICE_PLUGIN_DECISIONS, decisions, version=1,
|
|
71
|
+
plugin_id="modules")
|
|
72
|
+
|
|
73
|
+
async def initialize(self):
|
|
74
|
+
"""Nothing to build: the tables are the data source's, the import is the freeze's."""
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
async def post_construct(self):
|
|
78
|
+
"""Import what the sections declare, now that every plugin is known.
|
|
79
|
+
|
|
80
|
+
Idempotent, and the same rule for both files: the fields that are code
|
|
81
|
+
are the declaring plugin's and are re-pointed, while the name, the
|
|
82
|
+
description, `is_active` and the grants are the administrator's and are
|
|
83
|
+
only ever inserted (doc/plugin_constructor.md, 9.1).
|
|
84
|
+
"""
|
|
85
|
+
settings = getattr(self.plugin_manager, "settings", None)
|
|
86
|
+
path = getattr(settings, "plugins_config_path", None)
|
|
87
|
+
from keepup_modules.catalogue import (import_declared_sections,
|
|
88
|
+
set_running_plugins,
|
|
89
|
+
sync_framework_sections,
|
|
90
|
+
sync_new_modules_from_json)
|
|
91
|
+
|
|
92
|
+
added = await asyncio.to_thread(sync_framework_sections)
|
|
93
|
+
declared = await asyncio.to_thread(sync_new_modules_from_json, path)
|
|
94
|
+
# What the running plugins declare themselves, each attributed to the
|
|
95
|
+
# plugin that declared it: a section of a plugin that does not run is
|
|
96
|
+
# not in the panel, and the row waits for it to come back (keepup-113).
|
|
97
|
+
plugins = getattr(self.plugin_manager, "loaded_plugins", {}) or {}
|
|
98
|
+
for plugin_id, plugin in sorted(plugins.items()):
|
|
99
|
+
sections = plugin.get_panel_sections() if hasattr(plugin, "get_panel_sections") else []
|
|
100
|
+
if sections:
|
|
101
|
+
await asyncio.to_thread(import_declared_sections,
|
|
102
|
+
{"modules": list(sections)}, plugin_id)
|
|
103
|
+
set_running_plugins(plugins)
|
|
104
|
+
logger.info("Catalogue imported: %s framework section(s), %s declared; %s plugin(s) running",
|
|
105
|
+
added, declared, len(plugins))
|
|
106
|
+
|
|
107
|
+
def get_declared_tables(self) -> List[Any]:
|
|
108
|
+
"""The catalogue's tables, declared by the capability that owns them."""
|
|
109
|
+
from keepup_modules.tables import FRONTEND_MODULES, PLUGIN_OVERRIDES, ROLE_MODULES
|
|
110
|
+
|
|
111
|
+
return [FRONTEND_MODULES, ROLE_MODULES, PLUGIN_OVERRIDES]
|
|
112
|
+
|
|
113
|
+
def get_api_routes(self) -> List[Dict[str, Any]]:
|
|
114
|
+
"""No routes yet: the application registers the ten through the bridge."""
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
def get_handlers(self) -> Dict[str, Any]:
|
|
118
|
+
from keepup_modules import catalogue
|
|
119
|
+
|
|
120
|
+
return {"catalogue": catalogue}
|
keepup_modules/tables.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""The catalogue's tables, declared where the capability lives.
|
|
2
|
+
|
|
3
|
+
Task keepup-113. `frontend_modules` was declared by the panel's distribution
|
|
4
|
+
because the panel drew the navigation from it; the model -- which section exists,
|
|
5
|
+
who may see it, where its files are -- is the catalogue's, so the declaration
|
|
6
|
+
travels with the catalogue. `keepup_db.schema`, which re-exported all three for
|
|
7
|
+
one
|
|
8
|
+
release, which is what a path written before the split still imports.
|
|
9
|
+
|
|
10
|
+
The navigation fields are columns now and not a JSON blob: the collapsed
|
|
11
|
+
navigation is rendered from `icon`, the order from `nav_order`, and
|
|
12
|
+
`declared_by` is what lets a section be filtered by the plugin that declared it
|
|
13
|
+
(doc/plugin_constructor.md, section 9.1).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from sqlalchemy import (Boolean, Column, DateTime, Index, Integer, Text,
|
|
17
|
+
UniqueConstraint)
|
|
18
|
+
from sqlalchemy import text as sql_text
|
|
19
|
+
|
|
20
|
+
from keepup_db import tables
|
|
21
|
+
|
|
22
|
+
#: The names the declarations came with, kept beside them.
|
|
23
|
+
MODULES_TABLE = "frontend_modules"
|
|
24
|
+
ROLE_MODULES_TABLE = "role_modules"
|
|
25
|
+
PLUGIN_OVERRIDES_TABLE = "plugin_overrides"
|
|
26
|
+
|
|
27
|
+
__all__ = ["FRONTEND_MODULES", "PLUGIN_OVERRIDES", "ROLE_MODULES"]
|
|
28
|
+
|
|
29
|
+
FRONTEND_MODULES = tables.table(
|
|
30
|
+
MODULES_TABLE,
|
|
31
|
+
tables.auto_id(),
|
|
32
|
+
Column("module_id", Text, unique=True, nullable=False),
|
|
33
|
+
Column("name", Text, nullable=False),
|
|
34
|
+
Column("description", Text),
|
|
35
|
+
Column("js_path", Text),
|
|
36
|
+
Column("css_path", Text),
|
|
37
|
+
Column("init_function", Text),
|
|
38
|
+
# What the catalogue entry says about how the panel shows the section
|
|
39
|
+
# (keepup-113): they lived only in sections.json, so a section added from
|
|
40
|
+
# the panel could not carry a handler, an address or an order.
|
|
41
|
+
Column("icon", Text),
|
|
42
|
+
Column("handler_function", Text),
|
|
43
|
+
Column("route_path", Text),
|
|
44
|
+
Column("section_id", Text),
|
|
45
|
+
Column("admin_only", Boolean, server_default=sql_text("FALSE")),
|
|
46
|
+
Column("nav_order", Integer),
|
|
47
|
+
# Which plugin -- or which file -- declared this section. What makes
|
|
48
|
+
# removal and the read-time filter possible.
|
|
49
|
+
Column("declared_by", Text),
|
|
50
|
+
Column("version", Text, server_default="1.0.0"),
|
|
51
|
+
Column("is_active", Boolean, server_default=sql_text("TRUE")),
|
|
52
|
+
Column("config", Text),
|
|
53
|
+
Column("created_at", DateTime, server_default=tables.NOW),
|
|
54
|
+
Column("updated_at", DateTime, server_default=tables.NOW),
|
|
55
|
+
Index("idx_frontend_modules_active", "is_active"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
ROLE_MODULES = tables.table(
|
|
59
|
+
ROLE_MODULES_TABLE,
|
|
60
|
+
tables.auto_id(),
|
|
61
|
+
Column("role_name", Text, nullable=False),
|
|
62
|
+
Column("module_id", Text, nullable=False),
|
|
63
|
+
Column("is_active", Boolean, server_default=sql_text("TRUE")),
|
|
64
|
+
Column("created_at", DateTime, server_default=tables.NOW),
|
|
65
|
+
UniqueConstraint("role_name", "module_id"),
|
|
66
|
+
Index("idx_role_modules_role", "role_name"),
|
|
67
|
+
Index("idx_role_modules_active", "is_active"),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
#: Which backend plugins an administrator switched on or off, by stand (task 65).
|
|
71
|
+
#: The file `config/modules.json` stays the seed: it is committed and shared
|
|
72
|
+
#: between deployments, so a decision about one stand belongs in that stand's
|
|
73
|
+
#: own database. Read at start-up by `keepup.plugins.enablement`; the
|
|
74
|
+
#: environment still overrules it.
|
|
75
|
+
PLUGIN_OVERRIDES = tables.table(
|
|
76
|
+
PLUGIN_OVERRIDES_TABLE,
|
|
77
|
+
Column("plugin_id", Text, primary_key=True, nullable=True),
|
|
78
|
+
Column("enabled", Boolean, nullable=False),
|
|
79
|
+
Column("changed_by", Integer),
|
|
80
|
+
Column("changed_at", DateTime, server_default=tables.NOW),
|
|
81
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: keepup-modules
|
|
3
|
+
Version: 0.5.0
|
|
4
|
+
Summary: The catalogue of panel sections, and the decision about backend plugins.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: keepup-admin>=0.4.0
|
|
8
|
+
Requires-Dist: keepup-db>=0.4.0
|
|
9
|
+
Requires-Dist: keepup-users>=0.4.0
|
|
10
|
+
Requires-Dist: keepup-auth>=0.4.0
|
|
11
|
+
Requires-Dist: fastapi<1,>=0.128
|
|
12
|
+
Requires-Dist: pydantic<3,>=2.11
|
|
13
|
+
|
|
14
|
+
# keepup-modules
|
|
15
|
+
|
|
16
|
+
The catalogue of panel sections, and the decision about backend plugins.
|
|
17
|
+
|
|
18
|
+
One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
|
|
19
|
+
panels: the base is installed as `keepup-admin` and imported as `keepup`, and
|
|
20
|
+
this distribution carries one part of it. It is installed on its own
|
|
21
|
+
(`pip install keepup-modules`) or together with the base (`pip install "keepup-admin[panel]"`),
|
|
22
|
+
and it declares itself to the kernel through its
|
|
23
|
+
`[project.entry-points."keepup.plugins"]` entry point.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
keepup_modules/__init__.py,sha256=jP8WvPP5oi07OMscW4NK0PJhbbYmweVH_1v6OV1TBtA,1460
|
|
2
|
+
keepup_modules/catalogue.py,sha256=qKcqD3gqXTFkKXY3tSeREwJvzxMSV_WHByTLPm7jRqg,34909
|
|
3
|
+
keepup_modules/decisions.py,sha256=POGGDt2oPWUVHG94F2zmuKY0CpgS-oSlHSjjUU-XvRs,12996
|
|
4
|
+
keepup_modules/plugin.py,sha256=sGYwEKCOE_F7JGRcyaPaSjfcNKniX8nuN1zkzAuOEbY,5508
|
|
5
|
+
keepup_modules/tables.py,sha256=V0_fT4On5k9XAL7gpTev3P2wS6YAncCHA-ZSQhFZud8,3459
|
|
6
|
+
keepup_modules-0.5.0.dist-info/METADATA,sha256=YC6l66gvnST46J6uEbpCQQAlT0GSCKk5XkQf6w2rZJA,957
|
|
7
|
+
keepup_modules-0.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
8
|
+
keepup_modules-0.5.0.dist-info/entry_points.txt,sha256=Q9G8GKQgRE_On0zqk_oEpYYrxTSaMWUuX_2nWzeOXKE,56
|
|
9
|
+
keepup_modules-0.5.0.dist-info/top_level.txt,sha256=rVSSTZQLLqP_XNO0I8k-Ymmgc-82B0gkJ64mvgR1iDk,15
|
|
10
|
+
keepup_modules-0.5.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
keepup_modules
|