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.
@@ -0,0 +1,812 @@
1
+ """The catalogue of panel sections.
2
+
3
+ A section is a piece of the front end -- a script, a stylesheet, an init
4
+ function -- and a role is granted the sections it may see. The catalogue lives
5
+ in the database; the file the application ships is a seed and a fallback, and
6
+ new sections are added from it without touching what an administrator has
7
+ already switched off or taken away from a role.
8
+ """
9
+
10
+ import asyncio
11
+ import json
12
+ from pathlib import Path
13
+ import logging
14
+ import time
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ import keepup
18
+ from fastapi import Depends, HTTPException
19
+ from pydantic import BaseModel
20
+
21
+ from keepup_auth.dependencies import get_current_admin, get_current_user
22
+ from keepup_auth import user_roles
23
+ from keepup import cache
24
+ from keepup_audit import trail as admin_trail
25
+ from keepup.logging_setup import for_log
26
+
27
+ #: What an application may import from this module. Everything else is
28
+ #: internal and may change without notice -- see doc/keepup.md.
29
+ __all__ = [
30
+ "create_or_update_module",
31
+ "delete_module",
32
+ "get_all_modules_from_db",
33
+ "get_module_by_id",
34
+ "get_modules_for_role",
35
+ "get_modules_for_roles",
36
+ "import_modules_from_json",
37
+ "register_module_routes",
38
+ "sync_framework_sections",
39
+ "sync_new_modules_from_json",
40
+ "update_role_modules",
41
+ ]
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ MODULES_CONFIG_PATH = 'config/modules.json'
46
+
47
+
48
+ def configure(path: str = None) -> None:
49
+ """Where this deployment keeps its catalogue of sections and plugins.
50
+
51
+ The application names the file in KeepupSettings.plugins_config_path and
52
+ create_app() hands it here. Until this existed only initialize_plugins()
53
+ was given that path: the catalogue was seeded from the default file, so a
54
+ deployment that named another one came up with the framework's own sections
55
+ and none of its own, and nothing said why (keepup-98).
56
+ """
57
+ global MODULES_CONFIG_PATH
58
+ if path:
59
+ MODULES_CONFIG_PATH = path
60
+
61
+
62
+ class ModuleCreate(BaseModel):
63
+ """A panel section being declared."""
64
+
65
+ module_id: str
66
+ name: str
67
+ description: Optional[str] = None
68
+ js_path: Optional[str] = None
69
+ css_path: Optional[str] = None
70
+ init_function: Optional[str] = None
71
+ version: str = "1.0.0"
72
+ config: Optional[Dict[str, Any]] = None
73
+
74
+
75
+ class ModuleUpdate(BaseModel):
76
+ """The parts of a declared section that may be changed."""
77
+
78
+ name: Optional[str] = None
79
+ description: Optional[str] = None
80
+ js_path: Optional[str] = None
81
+ css_path: Optional[str] = None
82
+ init_function: Optional[str] = None
83
+ version: Optional[str] = None
84
+ config: Optional[Dict[str, Any]] = None
85
+ is_active: Optional[bool] = None
86
+
87
+
88
+ class RoleModulesUpdate(BaseModel):
89
+ """Which sections a role is granted."""
90
+
91
+ role_name: str
92
+ module_ids: List[str]
93
+
94
+
95
+ def get_all_modules_from_db():
96
+ """Return every module from the database."""
97
+ from keepup_db import DatabaseManagerV2
98
+ return DatabaseManagerV2.execute('''
99
+ SELECT * FROM frontend_modules
100
+ WHERE is_active = TRUE
101
+ ORDER BY name
102
+ ''')
103
+
104
+
105
+ def get_module_by_id(module_id: str):
106
+ """Return a module by id."""
107
+ from keepup_db import DatabaseManagerV2
108
+ return DatabaseManagerV2.execute_one('''
109
+ SELECT * FROM frontend_modules
110
+ WHERE module_id = :module_id AND is_active = TRUE
111
+ ''', {"module_id": module_id})
112
+
113
+
114
+ #: The replica cache of each role's sections (keepup/cache.py): every page of
115
+ #: the panel asks for its role's list, and the catalogue changes only when an
116
+ #: administrator changes it.
117
+ SECTIONS_CACHE = "panel_sections"
118
+
119
+ #: Which plugins have declared a section, and which of them are running. A
120
+ #: section whose plugin is not running is not in the panel -- and its row and
121
+ #: the grants an administrator made are kept, so it comes back with its plugin
122
+ #: (keepup-113, doc/plugin_constructor.md 9.1).
123
+ _declaring_plugins: set = set()
124
+ _running_plugins: set = set()
125
+
126
+
127
+ def set_declared_plugins(plugin_ids) -> None:
128
+ """Remember which plugins declared a section, whoever asked when."""
129
+ global _declaring_plugins
130
+
131
+ _declaring_plugins = set(plugin_ids)
132
+ _sections_changed()
133
+
134
+
135
+ def set_running_plugins(plugin_ids) -> None:
136
+ """Remember which plugins came up; the panel reads the sections of those."""
137
+ global _running_plugins
138
+
139
+ _running_plugins = set(plugin_ids)
140
+ _sections_changed()
141
+
142
+
143
+ def hidden_plugins() -> tuple:
144
+ """The plugins that declared a section and are not running now."""
145
+ return tuple(sorted(_declaring_plugins - _running_plugins))
146
+
147
+
148
+ def get_modules_for_role(role_name: str):
149
+ """Return the modules granted to a role.
150
+
151
+ Kept in this replica for the cache's lifetime and dropped whenever the
152
+ catalogue or a grant changes; each caller gets its own copies.
153
+ """
154
+ rows = cache.get_cache(SECTIONS_CACHE).get(
155
+ role_name, lambda: _read_modules_for_role(role_name))
156
+ return [dict(row) for row in rows]
157
+
158
+
159
+ def get_modules_for_roles(role_names):
160
+ """Return the modules granted to any of these roles, each of them once.
161
+
162
+ A user holds a set of roles (keepup/auth/user_roles.py) and sees the sections
163
+ of all of them glued together. A section granted to two of the roles is in
164
+ the answer once: the loader mounts a section by its id, and a second copy
165
+ would initialise the page twice.
166
+
167
+ Each role's list is still read and kept on its own (the cache is keyed by
168
+ role name), so gluing costs nothing beyond the roles the person actually
169
+ holds. The order is by name and does not depend on the order of the roles --
170
+ the panel's menu must not rearrange itself because a role was granted later.
171
+
172
+ Args:
173
+ role_names: the roles to glue.
174
+
175
+ Returns:
176
+ The modules, ordered by name.
177
+ """
178
+ glued = {}
179
+ for role_name in role_names or []:
180
+ for module in get_modules_for_role(role_name):
181
+ glued.setdefault(module["module_id"], module)
182
+ def place(module):
183
+ order = module.get("nav_order")
184
+ return (order is None, order or 0, module.get("name") or "", module["module_id"])
185
+
186
+ return sorted(glued.values(), key=place)
187
+
188
+
189
+ def _sections_changed() -> None:
190
+ cache.invalidate_everywhere(SECTIONS_CACHE)
191
+
192
+
193
+ def _read_modules_for_role(role_name: str):
194
+ """The sections this role may see, from plugins that are running.
195
+
196
+ The filter is what makes a capability switched off by an administrator
197
+ disappear from the panel with its routes; the row stays, so switching it
198
+ back on brings the section and its grants back (keepup-113).
199
+ """
200
+ from keepup_db import DatabaseManagerV2
201
+
202
+ hidden = hidden_plugins()
203
+ statement = ("SELECT fm.* FROM frontend_modules fm"
204
+ " JOIN role_modules rm ON fm.module_id = rm.module_id"
205
+ " WHERE rm.role_name = :role_name"
206
+ " AND rm.is_active = TRUE AND fm.is_active = TRUE")
207
+ params: Dict[str, Any] = {"role_name": role_name}
208
+ if hidden:
209
+ placeholders = ", ".join(f":hidden_{index}" for index in range(len(hidden)))
210
+ statement += f" AND (fm.declared_by IS NULL OR fm.declared_by NOT IN ({placeholders}))"
211
+ params.update({f"hidden_{index}": name for index, name in enumerate(hidden)})
212
+ statement += " ORDER BY fm.name"
213
+ return DatabaseManagerV2.execute(statement, params)
214
+
215
+
216
+ def create_or_update_module(module_data: Dict[str, Any]):
217
+ """Create or update a module."""
218
+ from keepup_db import DatabaseManagerV2, db_config
219
+ try:
220
+ module_id = module_data['id']
221
+ logger.info(f"=== Processing module: {module_id} ===")
222
+
223
+ name = module_data['name']
224
+ description = module_data.get('description')
225
+ js_path = module_data.get('js')
226
+ css_path = module_data.get('css')
227
+ init_function = module_data.get('initFunction')
228
+ version = module_data.get('version', '1.0.0')
229
+ config = json.dumps(module_data.get('config', {}))
230
+
231
+ logger.info(f"DB type: {db_config.db_type}")
232
+ logger.info(f"Name: {name}")
233
+ logger.info(f"JS path: {js_path}")
234
+ logger.info(f"CSS path: {css_path}")
235
+ logger.info(f"Init function: {init_function}")
236
+
237
+ existing = get_module_by_id(module_id)
238
+ logger.info(f"Module exists: {existing is not None}")
239
+
240
+ params = {"module_id": module_id, "name": name, "description": description,
241
+ "js_path": js_path, "css_path": css_path, "init_function": init_function,
242
+ "version": version, "config": config}
243
+ if existing:
244
+ logger.info(f"UPDATE path for module: {module_id}")
245
+ query = '''
246
+ UPDATE frontend_modules
247
+ SET name = :name, description = :description, js_path = :js_path,
248
+ css_path = :css_path, init_function = :init_function, version = :version,
249
+ config = :config, updated_at = CURRENT_TIMESTAMP
250
+ WHERE module_id = :module_id
251
+ '''
252
+ else:
253
+ logger.info(f"INSERT path for module: {module_id}")
254
+ query = '''
255
+ INSERT INTO frontend_modules
256
+ (module_id, name, description, js_path, css_path, init_function, version, config)
257
+ VALUES (:module_id, :name, :description, :js_path, :css_path, :init_function,
258
+ :version, :config)
259
+ '''
260
+
261
+ start_time = time.time()
262
+ result = DatabaseManagerV2.execute_commit(query, params)
263
+ _sections_changed()
264
+
265
+ execution_time = time.time() - start_time
266
+ logger.info(f"Query executed in {execution_time:.2f} seconds")
267
+
268
+ if result:
269
+ logger.info(f"✓ Successfully {'updated' if existing else 'created'} module: {module_id}")
270
+ else:
271
+ logger.error(f"✗ Failed to {'update' if existing else 'create'} module: {module_id}")
272
+
273
+ except Exception as e:
274
+ logger.error(f"🚨 Error in create_or_update_module for {module_data.get('id', 'unknown')}: {str(e)}")
275
+ import traceback
276
+ logger.error(f"Stack trace: {traceback.format_exc()}")
277
+ raise
278
+
279
+
280
+ def _catalogue_entry(module_id: str, fields: Dict[str, Any]) -> Dict[str, Any]:
281
+ """A section as the routes describe it, in the shape the catalogue file has.
282
+
283
+ create_or_update_module() reads the file's names (`id`, `js`, `css`,
284
+ `initFunction`), and the routes handed it their own (`module_id`, `js_path`,
285
+ ...): every create and every change through the API answered 400 (keepup-80).
286
+ """
287
+ config = fields.get("config") or {}
288
+ if isinstance(config, str):
289
+ config = json.loads(config or "{}")
290
+ return {"id": module_id, "name": fields["name"],
291
+ "description": fields.get("description"),
292
+ "js": fields.get("js_path"), "css": fields.get("css_path"),
293
+ "initFunction": fields.get("init_function"),
294
+ "version": fields.get("version") or "1.0.0", "config": config}
295
+
296
+
297
+ #: The fields of a stored section a change through the API may carry.
298
+ _EDITABLE = ("name", "description", "js_path", "css_path", "init_function", "version", "config")
299
+
300
+
301
+ def _changed_entry(existing: Dict[str, Any], changes: Dict[str, Any]) -> Dict[str, Any]:
302
+ """The stored section with the given fields changed and the rest kept."""
303
+ merged = {field: existing.get(field) for field in _EDITABLE}
304
+ merged.update({field: value for field, value in changes.items() if field in _EDITABLE})
305
+ return _catalogue_entry(existing["module_id"], merged)
306
+
307
+
308
+ def update_role_modules(role_name: str, module_ids: List[str]):
309
+ """Update the modules granted to a role."""
310
+ from keepup_db import DatabaseManagerV2, db_config
311
+ DatabaseManagerV2.execute_commit('''
312
+ UPDATE role_modules
313
+ SET is_active = FALSE
314
+ WHERE role_name = :role_name
315
+ ''', {"role_name": role_name})
316
+
317
+ for module_id in module_ids:
318
+ if db_config.is_postgres():
319
+ DatabaseManagerV2.execute_commit('''
320
+ INSERT INTO role_modules (role_name, module_id, is_active)
321
+ VALUES (:role_name, :module_id, TRUE)
322
+ ON CONFLICT (role_name, module_id)
323
+ DO UPDATE SET is_active = TRUE
324
+ ''', {"role_name": role_name, "module_id": module_id})
325
+ else:
326
+ DatabaseManagerV2.execute_commit('''
327
+ INSERT OR REPLACE INTO role_modules (role_name, module_id, is_active)
328
+ VALUES (:role_name, :module_id, TRUE)
329
+ ''', {"role_name": role_name, "module_id": module_id})
330
+ _sections_changed()
331
+
332
+
333
+ def delete_module(module_id: str):
334
+ """Delete a module (soft delete)."""
335
+ from keepup_db import DatabaseManagerV2
336
+ DatabaseManagerV2.execute_commit('''
337
+ UPDATE frontend_modules
338
+ SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
339
+ WHERE module_id = :module_id
340
+ ''', {"module_id": module_id})
341
+ _sections_changed()
342
+
343
+
344
+ def import_modules_from_json():
345
+ """Import the modules from the JSON file into the database."""
346
+ try:
347
+ with open(MODULES_CONFIG_PATH, 'r', encoding='utf-8') as f:
348
+ config = json.load(f)
349
+
350
+ for module_config in config.get('modules', []):
351
+ create_or_update_module(module_config)
352
+
353
+ for role_config in config.get('roles', []):
354
+ role_name = role_config.get('name')
355
+ module_ids = role_config.get('modules', [])
356
+ update_role_modules(role_name, module_ids)
357
+
358
+ logger.info("Modules imported successfully from JSON")
359
+ return True
360
+ except Exception as e:
361
+ logger.error(f"Error importing modules from JSON: {str(e)}")
362
+ return False
363
+
364
+
365
+ #: The catalogue the framework ships. Resolved from this file's location, like
366
+ #: the panel shell: it is data of the package, and the process may have been
367
+ #: started anywhere.
368
+ #: The framework's own declaration of its sections ships with the package the
369
+ #: capability is built on, and there is one such file, not two (keepup-113).
370
+ FRAMEWORK_SECTIONS = str(Path(keepup.__file__).resolve().parent / "sections.json")
371
+
372
+
373
+ _INSERT_SECTION_IF_NEW = (
374
+ "INSERT INTO frontend_modules (module_id, name, description, js_path, css_path, "
375
+ "init_function, icon, handler_function, route_path, section_id, admin_only, "
376
+ "nav_order, declared_by, version, config) "
377
+ "VALUES (:id, :name, :description, :js, :css, :init, :icon, :handler, :route, "
378
+ ":section_id, :admin_only, :nav_order, :declared_by, :version, :config) "
379
+ "ON CONFLICT (module_id) DO NOTHING")
380
+
381
+ _INSERT_GRANT_IF_NEW = (
382
+ "INSERT INTO role_modules (role_name, module_id, is_active) "
383
+ "VALUES (:role, :module_id, TRUE) ON CONFLICT (role_name, module_id) DO NOTHING")
384
+
385
+
386
+ #: The fields the declaring plugin owns: they are code, and the code knows
387
+ #: where its files are. An administrator's name, description, is_active and
388
+ #: grants are never in this list (doc/plugin_constructor.md, 9.1).
389
+ _CODE_OWNED = ("js_path", "css_path", "init_function", "icon", "handler_function",
390
+ "route_path", "section_id", "admin_only", "nav_order")
391
+
392
+
393
+ def _repoint_statement() -> str:
394
+ """The update that gives the declaring plugin its own fields back.
395
+
396
+ Nothing an administrator owns is touched: not `name`, not `description`,
397
+ not `is_active`, not a grant. A deployment that has been running since
398
+ before a move keeps `/static/modules/js/users.js` otherwise, the file is
399
+ not there any more, and the section breaks silently.
400
+ """
401
+ from keepup_db import db_config
402
+
403
+ changed = " OR ".join(f"{field} IS DISTINCT FROM :{field}" for field in _CODE_OWNED)
404
+ if db_config.is_postgres():
405
+ return (f"UPDATE frontend_modules SET {_assignment()} WHERE module_id = :id "
406
+ f"AND ({changed})")
407
+ not_is = " OR ".join(f"{field} IS NOT :{field}" for field in _CODE_OWNED)
408
+ return f"UPDATE frontend_modules SET {_assignment()} WHERE module_id = :id AND ({not_is})"
409
+
410
+
411
+ def _assignment() -> str:
412
+ """`field = :field` for every field the declaring plugin owns."""
413
+ return ", ".join(f"{field} = :{field}" for field in _CODE_OWNED)
414
+
415
+
416
+ def _repoint_params(section: Dict[str, Any]) -> Dict[str, Any]:
417
+ """The values of those fields, as the catalogue entry declares them."""
418
+ row = _section_row(section)
419
+ return {"id": section["id"], "js_path": row["js"], "css_path": row["css"],
420
+ "init_function": row["init"],
421
+ "icon": row["icon"], "handler_function": row["handler"],
422
+ "route_path": row["route"], "section_id": row["section_id"],
423
+ "admin_only": row["admin_only"], "nav_order": row["nav_order"]}
424
+
425
+
426
+ def _section_row(section: Dict[str, Any], declared_by: str = "sections.json") -> Dict[str, Any]:
427
+ """A catalogue entry as the parameters of _INSERT_SECTION_IF_NEW."""
428
+ return {"id": section["id"], "name": section["name"],
429
+ "description": section.get("description"), "js": section.get("js"),
430
+ "css": section.get("css"), "init": section.get("initFunction"),
431
+ "icon": section.get("icon"),
432
+ "handler": section.get("handlerFunction"),
433
+ "route": section.get("routePath"),
434
+ "section_id": section.get("sectionId"),
435
+ "admin_only": bool(section.get("adminOnly", False)),
436
+ "nav_order": section.get("order"),
437
+ "declared_by": declared_by,
438
+ "version": section.get("version", "1.0.0"),
439
+ "config": json.dumps(section.get("config", {}))}
440
+
441
+
442
+ def sync_framework_sections(path: str = FRAMEWORK_SECTIONS) -> Dict[str, int]:
443
+ """Bring the framework's own panel sections into the catalogue.
444
+
445
+ The application does not declare them: eight sections -- users, the section
446
+ catalogue itself, the cluster, metrics, themes, events, background tasks
447
+ and the integration log -- belong to the framework, and an application that
448
+ had to copy eight entries into its own file would get them wrong one at a
449
+ time (task keepup-4).
450
+
451
+ One rule here differs from sync_new_modules_from_json, and it is worth
452
+ saying plainly:
453
+
454
+ the framework owns *where the files of its sections are*;
455
+ the administrator owns *whether they are shown and to whom*.
456
+
457
+ So `js_path`, `css_path` and `init_function` are updated on rows that
458
+ already exist, while `is_active` and the grants are only ever inserted when
459
+ missing. Without the update, a deployment that has been running since
460
+ before the move keeps `/static/modules/js/users.js` in its database, the
461
+ file is not there any more, and the section breaks silently -- the panel
462
+ fetches a 404 and shows nothing.
463
+
464
+ `name` and `description` are left alone on an existing row: they are what a
465
+ person reads in the menu, and an administrator may have changed them on
466
+ purpose.
467
+
468
+ Args:
469
+ path: the catalogue to read; the package's own by default.
470
+
471
+ Returns:
472
+ How many rows were added and how many had their paths corrected.
473
+ """
474
+ from keepup_db import DatabaseManagerV2
475
+ counted = {"modules": 0, "repointed": 0, "grants": 0}
476
+ try:
477
+ with open(path, "r", encoding="utf-8") as catalogue:
478
+ declared = json.load(catalogue)
479
+ except Exception as error:
480
+ logger.error("Framework panel sections not synchronised: cannot read %s: %s",
481
+ path, error)
482
+ return counted
483
+
484
+ for section in declared.get("modules", []):
485
+ try:
486
+ counted["modules"] += max(0, DatabaseManagerV2.execute_commit(
487
+ _INSERT_SECTION_IF_NEW, _section_row(section)))
488
+
489
+ counted["repointed"] += max(0, DatabaseManagerV2.execute_commit(
490
+ _repoint_statement(), _repoint_params(section)))
491
+ except Exception as error:
492
+ logger.warning("Framework section %s not synchronised: %s",
493
+ section.get("id"), error)
494
+
495
+ for role in declared.get("roles", []):
496
+ for module_id in role.get("modules", []):
497
+ try:
498
+ counted["grants"] += max(0, DatabaseManagerV2.execute_commit(
499
+ _INSERT_GRANT_IF_NEW, {"role": role.get("name"), "module_id": module_id}))
500
+ except Exception as error:
501
+ logger.warning("Framework grant %s/%s not added: %s",
502
+ role.get("name"), module_id, error)
503
+
504
+ if any(counted.values()):
505
+ _sections_changed()
506
+ if counted["modules"] or counted["repointed"]:
507
+ logger.info("Framework panel sections: %s added, %s repointed",
508
+ counted["modules"], counted["repointed"])
509
+ return counted
510
+
511
+
512
+ def import_declared_sections(declared: Dict[str, List[Dict[str, Any]]],
513
+ declared_by: str) -> Dict[str, int]:
514
+ """Import what one declarer says, with the same ownership rule as the file.
515
+
516
+ A plugin declares its sections through the `sections` contribution
517
+ (`get_panel_sections()`); they are imported here exactly as the framework's
518
+ own file is, and `declared_by` is what lets the read leave out the sections
519
+ of a plugin that is not running (keepup-113).
520
+ """
521
+ from keepup_db import DatabaseManagerV2
522
+
523
+ counted = {"modules": 0, "repointed": 0}
524
+ for section in declared.get("modules", []):
525
+ try:
526
+ row = _section_row(section, declared_by)
527
+ counted["modules"] += max(0, DatabaseManagerV2.execute_commit(
528
+ _INSERT_SECTION_IF_NEW, row))
529
+ counted["repointed"] += max(0, DatabaseManagerV2.execute_commit(
530
+ _repoint_statement(), _repoint_params(section)))
531
+ except Exception as error:
532
+ logger.warning("Section %s of %s not imported: %s",
533
+ section.get("id"), declared_by, error)
534
+ if counted["modules"] or counted["repointed"]:
535
+ set_declared_plugins(_declaring_plugins | {declared_by})
536
+ return counted
537
+
538
+
539
+ def sync_new_modules_from_json(path: str = None) -> Dict[str, int]:
540
+ """Add what the file declares and the database has never seen; change nothing else.
541
+
542
+ The catalogue of panel sections lives in the database, and the file is only its
543
+ seed. Until this ran at start-up, a section added to the file stayed invisible
544
+ after deployment until somebody remembered to call the import by hand. The full
545
+ import cannot run on its own: it rewrites every role's grants from the file and
546
+ so undoes what an administrator changed in the panel.
547
+
548
+ So only rows that do not exist at all are written: a module whose id has no row,
549
+ a grant whose (role, module) pair has no row. A section an administrator switched
550
+ off or took away from a role keeps its row, and stays the way he left it.
551
+
552
+ Args:
553
+ path: the catalogue to read; the deployment's own by default, which is
554
+ the path the application named (configure()).
555
+ """
556
+ from keepup_db import DatabaseManagerV2
557
+ path = path or MODULES_CONFIG_PATH
558
+ added = {"modules": 0, "grants": 0}
559
+ try:
560
+ with open(path, 'r', encoding='utf-8') as f:
561
+ config = json.load(f)
562
+ except Exception as e:
563
+ logger.warning(f"Panel sections not synchronised: cannot read {path}: {e}")
564
+ return added
565
+
566
+ for module in config.get('modules', []):
567
+ try:
568
+ added["modules"] += max(0, DatabaseManagerV2.execute_commit(
569
+ _INSERT_SECTION_IF_NEW,
570
+ _section_row(module, path or MODULES_CONFIG_PATH)))
571
+ except Exception as e:
572
+ logger.warning(f"Panel section {module.get('id')} not added: {e}")
573
+
574
+ for role in config.get('roles', []):
575
+ for module_id in role.get('modules', []):
576
+ try:
577
+ added["grants"] += max(0, DatabaseManagerV2.execute_commit(
578
+ _INSERT_GRANT_IF_NEW, {"role": role.get('name'), "module_id": module_id}))
579
+ except Exception as e:
580
+ logger.warning(f"Panel section {module_id} not granted to {role.get('name')}: {e}")
581
+
582
+ if added["modules"] or added["grants"]:
583
+ _sections_changed()
584
+ logger.info(f"Panel sections from {path}: added {added['modules']} section(s) and "
585
+ f"{added['grants']} role grant(s) the database did not have")
586
+ return added
587
+
588
+
589
+ async def get_modules_from_json_fallback(current_user: dict):
590
+ """Load the modules from the JSON file when the database read fails."""
591
+ try:
592
+ with open(MODULES_CONFIG_PATH, 'r', encoding='utf-8') as f:
593
+ config = json.load(f)
594
+
595
+ held = set(user_roles.held_by(current_user))
596
+ allowed_module_names = set()
597
+ for role in config.get('roles', []):
598
+ if role.get('name') in held:
599
+ allowed_module_names.update(role.get('modules', []))
600
+
601
+ if not allowed_module_names:
602
+ return {"modules": []}
603
+
604
+ # The file's own order, and each module once: the same answer the
605
+ # database path gives, with the roles glued rather than one of them
606
+ # chosen (keepup-51).
607
+ available_modules = [
608
+ module for module in config.get('modules', [])
609
+ if module.get('id') in allowed_module_names
610
+ ]
611
+
612
+ return {"modules": available_modules}
613
+
614
+ except Exception as e:
615
+ logger.error(f"Error getting modules from JSON fallback: {str(e)}")
616
+ return {"modules": []}
617
+
618
+
619
+ def register_module_routes(app):
620
+ """Register the panel-section endpoints on the application."""
621
+
622
+ @app.get("/api/admin/modules")
623
+ def get_all_modules_admin(admin: dict = Depends(get_current_admin)):
624
+ """Return every module (administrators only)."""
625
+ modules = get_all_modules_from_db()
626
+ return {"modules": modules}
627
+ @app.get("/api/admin/modules/{module_id}")
628
+ def get_module_admin(module_id: str, admin: dict = Depends(get_current_admin)):
629
+ """Return a module by id (administrators only)."""
630
+ module = get_module_by_id(module_id)
631
+ if not module:
632
+ raise HTTPException(status_code=404, detail="Module not found")
633
+ return module
634
+ @app.post("/api/admin/modules")
635
+ async def create_module(
636
+ module_data: ModuleCreate,
637
+ admin: dict = Depends(get_current_admin)
638
+ ):
639
+ """Create a module."""
640
+ try:
641
+ await asyncio.to_thread(create_or_update_module,
642
+ _catalogue_entry(module_data.module_id,
643
+ module_data.model_dump()))
644
+ await admin_trail.record(admin_trail.SECTIONS_CHANGED, admin,
645
+ f"Section {module_data.module_id} created",
646
+ action="create", module_id=module_data.module_id)
647
+ return {"success": True, "message": "Module created successfully"}
648
+ except Exception as e:
649
+ logger.error("Could not save panel sections: %s", for_log(e))
650
+ raise HTTPException(status_code=400, detail="Could not save the panel sections; "
651
+ "see the application log")
652
+ @app.put("/api/admin/modules/{module_id}")
653
+ async def update_module(
654
+ module_id: str,
655
+ module_data: ModuleUpdate,
656
+ admin: dict = Depends(get_current_admin)
657
+ ):
658
+ """Update a module."""
659
+ existing = await asyncio.to_thread(get_module_by_id, module_id)
660
+ if not existing:
661
+ raise HTTPException(status_code=404, detail="Module not found")
662
+
663
+ try:
664
+ update_data = module_data.model_dump(exclude_unset=True)
665
+ # Switching a section off is taking it away: the catalogue keeps the
666
+ # row and reads only active ones, which is what delete does.
667
+ if update_data.pop("is_active", None) is False:
668
+ await asyncio.to_thread(delete_module, module_id)
669
+ if update_data:
670
+ await asyncio.to_thread(create_or_update_module,
671
+ _changed_entry(existing, update_data))
672
+ await admin_trail.record(admin_trail.SECTIONS_CHANGED, admin,
673
+ f"Section {module_id} updated", action="update",
674
+ module_id=module_id,
675
+ fields=sorted(k for k in update_data if k != "module_id"))
676
+ return {"success": True, "message": "Module updated successfully"}
677
+ except Exception as e:
678
+ logger.error("Could not save panel sections: %s", for_log(e))
679
+ raise HTTPException(status_code=400, detail="Could not save the panel sections; "
680
+ "see the application log")
681
+ @app.delete("/api/admin/modules/{module_id}")
682
+ async def delete_module_endpoint(
683
+ module_id: str,
684
+ admin: dict = Depends(get_current_admin)
685
+ ):
686
+ """Delete a module."""
687
+ existing = await asyncio.to_thread(get_module_by_id, module_id)
688
+ if not existing:
689
+ raise HTTPException(status_code=404, detail="Module not found")
690
+
691
+ await asyncio.to_thread(delete_module, module_id)
692
+ await admin_trail.record(admin_trail.SECTIONS_CHANGED, admin,
693
+ f"Section {module_id} deleted", action="delete",
694
+ module_id=module_id)
695
+ return {"success": True, "message": "Module deleted successfully"}
696
+ @app.get("/api/admin/role-modules")
697
+ def get_role_modules(admin: dict = Depends(get_current_admin)):
698
+ """Return the modules granted to each role."""
699
+ from keepup_db import DatabaseManagerV2
700
+ roles_modules = {}
701
+ roles = DatabaseManagerV2.execute('''
702
+ SELECT DISTINCT role_name FROM role_modules WHERE is_active = TRUE
703
+ ''')
704
+
705
+ for role in roles:
706
+ modules = get_modules_for_role(role['role_name'])
707
+ roles_modules[role['role_name']] = modules
708
+
709
+ return roles_modules
710
+ @app.post("/api/admin/role-modules")
711
+ async def update_role_modules_endpoint(
712
+ role_data: RoleModulesUpdate,
713
+ admin: dict = Depends(get_current_admin)
714
+ ):
715
+ """Update the modules granted to a role."""
716
+ try:
717
+ await asyncio.to_thread(update_role_modules, role_data.role_name,
718
+ role_data.module_ids)
719
+ await admin_trail.record(admin_trail.SECTIONS_CHANGED, admin,
720
+ f"Sections of role {role_data.role_name} set",
721
+ action="grant", role_name=role_data.role_name,
722
+ module_ids=list(role_data.module_ids))
723
+ return {"success": True, "message": "Role modules updated successfully"}
724
+ except Exception as e:
725
+ logger.error("Could not save panel sections: %s", for_log(e))
726
+ raise HTTPException(status_code=400, detail="Could not save the panel sections; "
727
+ "see the application log")
728
+ @app.post("/api/admin/modules/import-from-json")
729
+ async def import_modules_from_json_endpoint(admin: dict = Depends(get_current_admin)):
730
+ """Import the modules from the JSON file."""
731
+ success = await asyncio.to_thread(import_modules_from_json)
732
+ if success:
733
+ await admin_trail.record(admin_trail.SECTIONS_CHANGED, admin,
734
+ "Sections and grants reset from the file",
735
+ action="import")
736
+ return {"success": True, "message": "Modules imported successfully"}
737
+ else:
738
+ raise HTTPException(status_code=500, detail="Error importing modules")
739
+ @app.post("/api/admin/modules/export-to-json")
740
+ def export_modules_to_json_endpoint(admin: dict = Depends(get_current_admin)):
741
+ """Export the modules to the JSON file."""
742
+ from keepup_db import DatabaseManagerV2
743
+ try:
744
+ modules = get_all_modules_from_db()
745
+ roles_modules = {}
746
+ roles = DatabaseManagerV2.execute('SELECT DISTINCT role_name FROM role_modules WHERE is_active = TRUE')
747
+
748
+ for role in roles:
749
+ role_modules = get_modules_for_role(role['role_name'])
750
+ roles_modules[role['role_name']] = [module['module_id'] for module in role_modules]
751
+
752
+ export_data = {
753
+ "roles": [
754
+ {
755
+ "name": role_name,
756
+ "modules": module_ids
757
+ }
758
+ for role_name, module_ids in roles_modules.items()
759
+ ],
760
+ "modules": [
761
+ {
762
+ "id": module['module_id'],
763
+ "name": module['name'],
764
+ "description": module['description'],
765
+ "js": module['js_path'],
766
+ "css": module['css_path'],
767
+ "initFunction": module['init_function'],
768
+ "version": module['version'],
769
+ "config": json.loads(module['config']) if module['config'] else {}
770
+ }
771
+ for module in modules
772
+ ]
773
+ }
774
+
775
+ with open(MODULES_CONFIG_PATH, 'w', encoding='utf-8') as f:
776
+ json.dump(export_data, f, ensure_ascii=False, indent=2)
777
+
778
+ return {"success": True, "message": "Modules exported successfully"}
779
+ except Exception as e:
780
+ logger.error(f"Error exporting modules to JSON: {str(e)}")
781
+ raise HTTPException(status_code=500, detail="Error exporting modules")
782
+ @app.get("/api/modules")
783
+ async def get_modules(current_user: dict = Depends(get_current_user)):
784
+ """Return the modules available to the current user, from the database."""
785
+ try:
786
+ # Every page of the panel asks for this; the read leaves the loop.
787
+ modules = await asyncio.to_thread(get_modules_for_roles, user_roles.held_by(current_user))
788
+
789
+ if modules:
790
+ formatted_modules = []
791
+ for module in modules:
792
+ formatted_module = {
793
+ "id": module['module_id'],
794
+ "name": module['name'],
795
+ "description": module['description'],
796
+ "js": module['js_path'],
797
+ "css": module['css_path'],
798
+ "initFunction": module['init_function'],
799
+ "version": module['version']
800
+ }
801
+ if module['config']:
802
+ formatted_module["config"] = json.loads(module['config'])
803
+ formatted_modules.append(formatted_module)
804
+
805
+ return {"modules": formatted_modules}
806
+ else:
807
+ logger.warning("No modules found in DB, falling back to JSON file")
808
+ return await get_modules_from_json_fallback(current_user)
809
+
810
+ except Exception as e:
811
+ logger.error(f"Error getting modules from DB: {str(e)}")
812
+ return await get_modules_from_json_fallback(current_user)