"""Module system — base class + discovery.""" from __future__ import annotations import importlib.util, inspect, json, sys from pathlib import Path from flask import Blueprint CORE = frozenset({ # Core feature IDs "stacks", "app_store", "docker_images", "audit", "settings", "wizard", "language", "i18n", "projects", "system_info", "updater", "git_browser", "store", "dashboard", "docker", "core", "modules", # Repo module IDs that are core features (not optional) "audit_log", "audit-log", "system-info", "system_info", "git-browser", "git_browser", }) class Module: ID = "" NAME = "" ICON = "🧩" DESC = "" VER = "1.0.0" # Old-style compat attributes MODULE_ID = "" MODULE_NAME = "" MODULE_ICON = "" MODULE_DESC = "" def __init_subclass__(cls, **kw): """Sync old MODULE_* attrs to new ID/NAME/ICON/DESC.""" super().__init_subclass__(**kw) # Old-style MODULE_ID → new ID (old takes priority if set) if cls.__dict__.get("MODULE_ID"): cls.ID = cls.MODULE_ID if cls.__dict__.get("MODULE_NAME"): cls.NAME = cls.MODULE_NAME if cls.__dict__.get("MODULE_ICON"): cls.ICON = cls.MODULE_ICON if cls.__dict__.get("MODULE_DESC"): cls.DESC = cls.MODULE_DESC def blueprint(self) -> Blueprint | None: return None def pages(self) -> list[dict]: return [] def on_load(self, app) -> None: pass def settings_html(self) -> str | None: return None def get_config(self, key=None, default=None): """Haal module-specifieke config op.""" import core as cfg mc = cfg.load().get("MODULE_SETTINGS", {}).get(self.ID, {}) if key is None: return mc return mc.get(key, default) def save_config(self, updates: dict): """Sla module-specifieke config op.""" import core as cfg c = cfg.load() ms = dict(c.get("MODULE_SETTINGS", {})) mc = dict(ms.get(self.ID, {})) mc.update(updates) ms[self.ID] = mc cfg.patch({"MODULE_SETTINGS": ms}) def info(self) -> dict: return {"id": self.ID, "name": self.NAME, "icon": self.ICON, "desc": self.DESC, "version": self.VER, "pages": self.pages(), "core": self.ID in CORE} def discover(dirs: list[Path]) -> list[tuple[str, type, Path]]: found = [] seen = set() for base in dirs: if not base.exists(): continue for d in sorted(base.iterdir()): if not d.is_dir() or d.name.startswith("_"): continue if not (d / "__init__.py").exists(): continue # Vereist module.json if not (d / "module.json").exists(): continue mid = d.name if mid in seen: continue # Skip CORE modules (dir name check) if mid in CORE: continue seen.add(mid) meta = _meta(d) # Skip CORE modules (meta ID check) meta_id = meta.get("id", mid) if meta_id in CORE: continue if meta.get("enabled") is False: continue cls = _load(mid, d) if cls: # Final check: skip if the class ID is CORE try: inst = cls() if inst.ID in CORE: continue except Exception: pass found.append((meta.get("order", 50), mid, cls, d.resolve())) found.sort(key=lambda x: x[0]) return [(m, c, p) for _, m, c, p in found] def _meta(d: Path) -> dict: f = d / "module.json" if f.exists(): try: return json.loads(f.read_text()) except Exception: pass return {} def _load(mid: str, d: Path) -> type | None: cls, _ = _load_detail(mid, d) return cls def _load_detail(mid: str, d: Path) -> tuple[type | None, str]: """Load module class, return (cls, error_msg).""" try: name = f"_mod_{mid}" init_file = d / "__init__.py" if not init_file.exists(): return None, f"__init__.py niet gevonden in {d}" spec = importlib.util.spec_from_file_location( name, str(init_file), submodule_search_locations=[str(d)]) if not spec or not spec.loader: return None, f"kon spec niet laden voor {init_file}" pkg = importlib.util.module_from_spec(spec) sys.modules[name] = pkg spec.loader.exec_module(pkg) for _, obj in inspect.getmembers(pkg, inspect.isclass): if obj is not Module and issubclass(obj, Module) and obj.__module__ == name: return obj, "" # Toon welke classes er WEL zijn classes = [n for n, o in inspect.getmembers(pkg, inspect.isclass) if o.__module__ == name] return None, f"geen Module subclass gevonden. Classes: {classes or 'geen'}" except Exception as e: print(f" ✖ {mid}: {e}") return None, f"{type(e).__name__}: {e}"