293 lines
11 KiB
Python
293 lines
11 KiB
Python
|
|
"""
|
||
|
|
Module Loader
|
||
|
|
Dynamisk loading af moduler med hot-reload support
|
||
|
|
"""
|
||
|
|
|
||
|
|
import json
|
||
|
|
import logging
|
||
|
|
import importlib
|
||
|
|
import importlib.util
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Dict, List, Optional
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from fastapi import FastAPI, HTTPException
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class ModuleMetadata:
|
||
|
|
"""Metadata for et modul"""
|
||
|
|
name: str
|
||
|
|
version: str
|
||
|
|
description: str
|
||
|
|
enabled: bool
|
||
|
|
author: str
|
||
|
|
dependencies: List[str]
|
||
|
|
table_prefix: str
|
||
|
|
api_prefix: str
|
||
|
|
tags: List[str]
|
||
|
|
module_path: Path
|
||
|
|
|
||
|
|
|
||
|
|
class ModuleLoader:
|
||
|
|
"""
|
||
|
|
Dynamisk modul loader med hot-reload support
|
||
|
|
|
||
|
|
Moduler ligger i app/modules/{module_name}/
|
||
|
|
Hver modul har en module.json med metadata
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self, modules_dir: str = "app/modules"):
|
||
|
|
self.modules_dir = Path(modules_dir)
|
||
|
|
self.loaded_modules: Dict[str, ModuleMetadata] = {}
|
||
|
|
self.module_routers: Dict[str, tuple] = {} # name -> (api_router, frontend_router)
|
||
|
|
|
||
|
|
def discover_modules(self) -> List[ModuleMetadata]:
|
||
|
|
"""
|
||
|
|
Find alle moduler i modules directory
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Liste af ModuleMetadata objekter
|
||
|
|
"""
|
||
|
|
modules = []
|
||
|
|
|
||
|
|
if not self.modules_dir.exists():
|
||
|
|
logger.warning(f"⚠️ Modules directory ikke fundet: {self.modules_dir}")
|
||
|
|
return modules
|
||
|
|
|
||
|
|
for module_dir in self.modules_dir.iterdir():
|
||
|
|
if not module_dir.is_dir() or module_dir.name.startswith("_"):
|
||
|
|
continue
|
||
|
|
|
||
|
|
manifest_path = module_dir / "module.json"
|
||
|
|
if not manifest_path.exists():
|
||
|
|
logger.warning(f"⚠️ Ingen module.json i {module_dir.name}")
|
||
|
|
continue
|
||
|
|
|
||
|
|
try:
|
||
|
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||
|
|
manifest = json.load(f)
|
||
|
|
|
||
|
|
metadata = ModuleMetadata(
|
||
|
|
name=manifest.get("name", module_dir.name),
|
||
|
|
version=manifest.get("version", "1.0.0"),
|
||
|
|
description=manifest.get("description", ""),
|
||
|
|
enabled=manifest.get("enabled", False),
|
||
|
|
author=manifest.get("author", "Unknown"),
|
||
|
|
dependencies=manifest.get("dependencies", []),
|
||
|
|
table_prefix=manifest.get("table_prefix", f"{module_dir.name}_"),
|
||
|
|
api_prefix=manifest.get("api_prefix", f"/api/v1/{module_dir.name}"),
|
||
|
|
tags=manifest.get("tags", [module_dir.name.title()]),
|
||
|
|
module_path=module_dir
|
||
|
|
)
|
||
|
|
|
||
|
|
modules.append(metadata)
|
||
|
|
logger.info(f"📦 Fundet modul: {metadata.name} v{metadata.version} (enabled={metadata.enabled})")
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"❌ Kunne ikke læse manifest for {module_dir.name}: {e}")
|
||
|
|
|
||
|
|
return modules
|
||
|
|
|
||
|
|
def load_module(self, metadata: ModuleMetadata) -> Optional[tuple]:
|
||
|
|
"""
|
||
|
|
Load et enkelt modul (backend + frontend routers)
|
||
|
|
|
||
|
|
Args:
|
||
|
|
metadata: Modul metadata
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Tuple af (api_router, frontend_router) eller None ved fejl
|
||
|
|
"""
|
||
|
|
if not metadata.enabled:
|
||
|
|
logger.info(f"⏭️ Springer over disabled modul: {metadata.name}")
|
||
|
|
return None
|
||
|
|
|
||
|
|
try:
|
||
|
|
# Check dependencies
|
||
|
|
for dep in metadata.dependencies:
|
||
|
|
if dep not in self.loaded_modules:
|
||
|
|
logger.warning(f"⚠️ Modul {metadata.name} kræver {dep} (mangler)")
|
||
|
|
return None
|
||
|
|
|
||
|
|
# Import backend router
|
||
|
|
backend_path = metadata.module_path / "backend" / "router.py"
|
||
|
|
api_router = None
|
||
|
|
if backend_path.exists():
|
||
|
|
spec = importlib.util.spec_from_file_location(
|
||
|
|
f"app.modules.{metadata.name}.backend.router",
|
||
|
|
backend_path
|
||
|
|
)
|
||
|
|
if spec and spec.loader:
|
||
|
|
module = importlib.util.module_from_spec(spec)
|
||
|
|
spec.loader.exec_module(module)
|
||
|
|
|
||
|
|
if hasattr(module, 'router'):
|
||
|
|
api_router = module.router
|
||
|
|
logger.info(f"✅ Loaded API router for {metadata.name}")
|
||
|
|
|
||
|
|
# Import frontend views
|
||
|
|
frontend_path = metadata.module_path / "frontend" / "views.py"
|
||
|
|
frontend_router = None
|
||
|
|
if frontend_path.exists():
|
||
|
|
spec = importlib.util.spec_from_file_location(
|
||
|
|
f"app.modules.{metadata.name}.frontend.views",
|
||
|
|
frontend_path
|
||
|
|
)
|
||
|
|
if spec and spec.loader:
|
||
|
|
module = importlib.util.module_from_spec(spec)
|
||
|
|
spec.loader.exec_module(module)
|
||
|
|
|
||
|
|
if hasattr(module, 'router'):
|
||
|
|
frontend_router = module.router
|
||
|
|
logger.info(f"✅ Loaded frontend router for {metadata.name}")
|
||
|
|
|
||
|
|
if api_router is None and frontend_router is None:
|
||
|
|
logger.warning(f"⚠️ Ingen routers fundet for {metadata.name}")
|
||
|
|
return None
|
||
|
|
|
||
|
|
self.loaded_modules[metadata.name] = metadata
|
||
|
|
self.module_routers[metadata.name] = (api_router, frontend_router)
|
||
|
|
|
||
|
|
logger.info(f"🎉 Modul {metadata.name} loaded successfully")
|
||
|
|
return (api_router, frontend_router)
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"❌ Kunne ikke loade modul {metadata.name}: {e}", exc_info=True)
|
||
|
|
return None
|
||
|
|
|
||
|
|
def register_modules(self, app: FastAPI):
|
||
|
|
"""
|
||
|
|
Registrer alle enabled moduler i FastAPI app
|
||
|
|
|
||
|
|
Args:
|
||
|
|
app: FastAPI application instance
|
||
|
|
"""
|
||
|
|
modules = self.discover_modules()
|
||
|
|
|
||
|
|
for metadata in modules:
|
||
|
|
if not metadata.enabled:
|
||
|
|
continue
|
||
|
|
|
||
|
|
routers = self.load_module(metadata)
|
||
|
|
if routers is None:
|
||
|
|
continue
|
||
|
|
|
||
|
|
api_router, frontend_router = routers
|
||
|
|
|
||
|
|
# Registrer API router
|
||
|
|
if api_router:
|
||
|
|
try:
|
||
|
|
app.include_router(
|
||
|
|
api_router,
|
||
|
|
prefix=metadata.api_prefix,
|
||
|
|
tags=list(metadata.tags) # type: ignore
|
||
|
|
)
|
||
|
|
logger.info(f"🔌 Registered API: {metadata.api_prefix}")
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"❌ Kunne ikke registrere API router for {metadata.name}: {e}")
|
||
|
|
|
||
|
|
# Registrer frontend router
|
||
|
|
if frontend_router:
|
||
|
|
try:
|
||
|
|
from typing import cast, List
|
||
|
|
frontend_tags: List[str] = ["Frontend"] + list(metadata.tags)
|
||
|
|
app.include_router(
|
||
|
|
frontend_router,
|
||
|
|
tags=frontend_tags # type: ignore
|
||
|
|
)
|
||
|
|
logger.info(f"🔌 Registered Frontend for {metadata.name}")
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"❌ Kunne ikke registrere frontend router for {metadata.name}: {e}")
|
||
|
|
|
||
|
|
def get_module_status(self) -> Dict[str, dict]:
|
||
|
|
"""
|
||
|
|
Hent status for alle loaded moduler
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Dict med modul navn -> status info
|
||
|
|
"""
|
||
|
|
status = {}
|
||
|
|
for name, metadata in self.loaded_modules.items():
|
||
|
|
status[name] = {
|
||
|
|
"name": metadata.name,
|
||
|
|
"version": metadata.version,
|
||
|
|
"description": metadata.description,
|
||
|
|
"enabled": metadata.enabled,
|
||
|
|
"author": metadata.author,
|
||
|
|
"table_prefix": metadata.table_prefix,
|
||
|
|
"api_prefix": metadata.api_prefix,
|
||
|
|
"has_api": self.module_routers[name][0] is not None,
|
||
|
|
"has_frontend": self.module_routers[name][1] is not None
|
||
|
|
}
|
||
|
|
return status
|
||
|
|
|
||
|
|
def enable_module(self, module_name: str) -> bool:
|
||
|
|
"""
|
||
|
|
Aktiver et modul (kræver app restart)
|
||
|
|
|
||
|
|
Args:
|
||
|
|
module_name: Navn på modul
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
True hvis success
|
||
|
|
"""
|
||
|
|
module_dir = self.modules_dir / module_name
|
||
|
|
manifest_path = module_dir / "module.json"
|
||
|
|
|
||
|
|
if not manifest_path.exists():
|
||
|
|
raise HTTPException(status_code=404, detail=f"Modul {module_name} ikke fundet")
|
||
|
|
|
||
|
|
try:
|
||
|
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||
|
|
manifest = json.load(f)
|
||
|
|
|
||
|
|
manifest["enabled"] = True
|
||
|
|
|
||
|
|
with open(manifest_path, 'w', encoding='utf-8') as f:
|
||
|
|
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||
|
|
|
||
|
|
logger.info(f"✅ Modul {module_name} enabled (restart required)")
|
||
|
|
return True
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"❌ Kunne ikke enable {module_name}: {e}")
|
||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
||
|
|
|
||
|
|
def disable_module(self, module_name: str) -> bool:
|
||
|
|
"""
|
||
|
|
Deaktiver et modul (kræver app restart)
|
||
|
|
|
||
|
|
Args:
|
||
|
|
module_name: Navn på modul
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
True hvis success
|
||
|
|
"""
|
||
|
|
module_dir = self.modules_dir / module_name
|
||
|
|
manifest_path = module_dir / "module.json"
|
||
|
|
|
||
|
|
if not manifest_path.exists():
|
||
|
|
raise HTTPException(status_code=404, detail=f"Modul {module_name} ikke fundet")
|
||
|
|
|
||
|
|
try:
|
||
|
|
with open(manifest_path, 'r', encoding='utf-8') as f:
|
||
|
|
manifest = json.load(f)
|
||
|
|
|
||
|
|
manifest["enabled"] = False
|
||
|
|
|
||
|
|
with open(manifest_path, 'w', encoding='utf-8') as f:
|
||
|
|
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||
|
|
|
||
|
|
logger.info(f"⏸️ Modul {module_name} disabled (restart required)")
|
||
|
|
return True
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
logger.error(f"❌ Kunne ikke disable {module_name}: {e}")
|
||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
||
|
|
|
||
|
|
|
||
|
|
# Global module loader instance
|
||
|
|
module_loader = ModuleLoader()
|