initial commit after split

This commit is contained in:
2026-06-24 01:43:10 +02:00
parent b1d6c0150f
commit 30c11a6dcf
173 changed files with 25380 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""GovOPlaN server runner package."""

View File

@@ -0,0 +1,56 @@
from __future__ import annotations
from fastapi import APIRouter
from govoplan_core.core.modules import ModuleContext
from govoplan_core.db.session import configure_database
from govoplan_core.server.config import GovoplanServerConfig, load_server_config
from govoplan_core.server.fastapi import create_govoplan_app
from govoplan_core.server.platform import create_platform_router
from govoplan_core.server.registry import build_platform_registry
def create_app(config: GovoplanServerConfig | str | None = None):
if isinstance(config, str) or config is None:
server_config = load_server_config(config)
else:
server_config = config
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
if database_url:
configure_database(str(database_url))
registry = build_platform_registry(server_config.enabled_modules, manifest_factories=server_config.manifest_factories)
api_router = APIRouter(prefix=server_config.api_prefix)
for router in server_config.base_routers:
api_router.include_router(router)
module_context = ModuleContext(registry=registry, settings=server_config.settings, data=server_config.module_context_data)
for manifest in registry.manifests():
if manifest.route_factory is not None:
api_router.include_router(manifest.route_factory(module_context))
api_router.include_router(create_platform_router())
for router in server_config.post_module_routers:
api_router.include_router(router)
for contribution in server_config.extra_routers:
if contribution.should_include(server_config.settings, registry):
api_router.include_router(contribution.router)
app = create_govoplan_app(
title=server_config.title,
version=server_config.version,
registry=registry,
api_router=api_router,
lifespan=server_config.lifespan,
cors_origins=server_config.cors_origins,
)
for configurator in server_config.app_configurators:
configurator(app, registry, server_config.settings)
return app
app = create_app()

View File

@@ -0,0 +1,73 @@
from __future__ import annotations
import os
from collections.abc import Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass, field
from importlib import import_module
from typing import Any
from fastapi import APIRouter, FastAPI
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.server.fastapi import LifespanFactory
ManifestFactory = Callable[[], ModuleManifest]
RouterEnabled = Callable[[object | None, PlatformRegistry], bool]
AppConfigurator = Callable[[FastAPI, PlatformRegistry, object | None], None]
@dataclass(frozen=True, slots=True)
class RouterContribution:
router: APIRouter
required_module: str | None = None
enabled: RouterEnabled | None = None
def should_include(self, settings: object | None, registry: PlatformRegistry) -> bool:
if self.required_module and not registry.has_module(self.required_module):
return False
if self.enabled is not None and not self.enabled(settings, registry):
return False
return True
@dataclass(frozen=True, slots=True)
class GovoplanServerConfig:
title: str = "GovOPlaN Server"
version: str = "0.1.0"
settings: object | None = None
enabled_modules: str | Iterable[str] = ("access",)
api_prefix: str = "/api/v1"
base_routers: Sequence[APIRouter] = ()
post_module_routers: Sequence[APIRouter] = ()
extra_routers: Sequence[RouterContribution] = ()
lifespan: LifespanFactory | None = None
cors_origins: Iterable[str] = ()
manifest_factories: Sequence[ManifestFactory] = ()
module_context_data: Mapping[str, Any] = field(default_factory=dict)
app_configurators: Sequence[AppConfigurator] = ()
def import_object(path: str) -> Any:
module_name, separator, attribute = path.partition(":")
if not separator:
raise ValueError(f"Object path must use module:attribute syntax: {path!r}")
module = import_module(module_name)
value: Any = module
for part in attribute.split("."):
value = getattr(value, part)
return value
def load_server_config(path: str | None = None) -> GovoplanServerConfig:
config_path = path or os.getenv("GOVOPLAN_SERVER_CONFIG") or "govoplan_core.server.default_config:get_server_config"
try:
loaded = import_object(config_path)
except ModuleNotFoundError:
if path or os.getenv("GOVOPLAN_SERVER_CONFIG"):
raise
return GovoplanServerConfig()
config = loaded() if callable(loaded) else loaded
if not isinstance(config, GovoplanServerConfig):
raise TypeError(f"{config_path!r} did not return a GovoplanServerConfig")
return config

View File

@@ -0,0 +1,89 @@
from __future__ import annotations
from contextlib import asynccontextmanager
from importlib import import_module
from fastapi import Depends, FastAPI
from govoplan_core.auth.dependencies import ApiPrincipal, require_scope
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.db.bootstrap import bootstrap_dev_data, create_all_tables
from govoplan_core.db.session import get_database
from govoplan_core.server.config import GovoplanServerConfig, RouterContribution
from govoplan_core.settings import Settings, settings
@asynccontextmanager
async def lifespan(app: FastAPI):
if settings.app_env.lower() == "dev" and settings.dev_bootstrap_enabled:
create_all_tables()
with get_database().SessionLocal() as session:
bootstrap_dev_data(
session,
api_key_secret=settings.dev_bootstrap_api_key,
user_password=settings.dev_bootstrap_password,
)
yield
def _cors_origins(value: str) -> list[str]:
return [item.strip() for item in value.split(",") if item.strip()]
def _settings_module_enabled(module_id: str) -> bool:
return module_id in {item.strip() for item in settings.enabled_modules.split(",") if item.strip()}
def _dev_mail_enabled(config_settings: object | None, registry: PlatformRegistry) -> bool:
active_settings = config_settings if isinstance(config_settings, Settings) else settings
return active_settings.app_env == "dev" and active_settings.dev_mailbox_api_enabled and registry.has_module("mail")
def _dev_mail_router_contributions() -> tuple[RouterContribution, ...]:
if not (settings.app_env == "dev" and settings.dev_mailbox_api_enabled and _settings_module_enabled("mail")):
return ()
module = import_module("govoplan_mail.backend.dev_router")
return (RouterContribution(module.router, required_module="mail", enabled=_dev_mail_enabled),)
def register_health_details(app: FastAPI, registry: PlatformRegistry, config_settings: object | None) -> None:
active_settings = config_settings if isinstance(config_settings, Settings) else settings
@app.get("/health/details")
def health_details(
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
):
del principal
return {
"status": "ok",
"env": active_settings.app_env,
"api": {"version": "v1", "auth": "api-key-or-session"},
"modules": [manifest.id for manifest in registry.manifests()],
"storage": {
"backend": active_settings.file_storage_backend,
"local_root": active_settings.file_storage_local_root if active_settings.file_storage_backend == "local" else None,
"endpoint": active_settings.file_storage_s3_endpoint_url or active_settings.s3_endpoint_url,
"bucket": active_settings.file_storage_s3_bucket or active_settings.s3_bucket,
"region": active_settings.file_storage_s3_region or active_settings.s3_region,
},
}
def get_server_config() -> GovoplanServerConfig:
from govoplan_core.api.v1.admin import router as admin_router
from govoplan_core.api.v1.audit import router as audit_router
from govoplan_core.api.v1.auth import router as auth_router
from govoplan_core.api.v1.system import router as system_router
return GovoplanServerConfig(
title="GovOPlaN Server",
version="0.3.0",
settings=settings,
enabled_modules=settings.enabled_modules,
api_prefix="/api/v1",
base_routers=(auth_router, admin_router, audit_router, system_router),
extra_routers=_dev_mail_router_contributions(),
lifespan=lifespan,
cors_origins=_cors_origins(settings.cors_origins),
app_configurators=(register_health_details,),
)

View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from collections.abc import AsyncIterator, Callable, Iterable
from contextlib import AbstractAsyncContextManager
from typing import Any
from fastapi import APIRouter, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from govoplan_core.core.registry import PlatformRegistry
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
def create_govoplan_app(
*,
title: str,
version: str,
registry: PlatformRegistry,
api_router: APIRouter | None = None,
lifespan: LifespanFactory | None = None,
cors_origins: Iterable[str] = (),
health_payload: dict[str, Any] | None = None,
) -> FastAPI:
app = FastAPI(title=title, version=version, lifespan=lifespan)
app.state.govoplan_registry = registry
origins = [item.strip() for item in cors_origins if item.strip()]
if origins:
app.add_middleware(
CORSMiddleware,
allow_origins=["*"] if "*" in origins else origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
if api_router is not None:
app.include_router(api_router)
@app.get("/health")
def health():
payload = {"status": "ok"}
if health_payload:
payload.update(health_payload)
return payload
return app

View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem
from govoplan_core.core.registry import PlatformRegistry
def _registry(request: Request) -> PlatformRegistry:
registry = getattr(request.app.state, "govoplan_registry", None)
if not isinstance(registry, PlatformRegistry):
raise HTTPException(status_code=500, detail="GovOPlaN module registry is not configured")
return registry
def _nav_item_payload(item: NavItem) -> dict[str, object]:
return {
"path": item.path,
"label": item.label,
"icon": item.icon,
"section": item.section,
"required_all": list(item.required_all),
"required_any": list(item.required_any),
"order": item.order,
}
def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
return {
"path": route.path,
"component": route.component,
"required_all": list(route.required_all),
"required_any": list(route.required_any),
"order": route.order,
}
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
if frontend is None:
return None
return {
"module_id": frontend.module_id,
"package_name": frontend.package_name,
"asset_manifest": frontend.asset_manifest,
"routes": [_frontend_route_payload(route) for route in frontend.routes],
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
}
def create_platform_router() -> APIRouter:
router = APIRouter(prefix="/platform", tags=["platform"])
@router.get("/modules")
def modules(request: Request):
registry = _registry(request)
return {
"modules": [
{
"id": manifest.id,
"name": manifest.name,
"version": manifest.version,
"dependencies": list(manifest.dependencies),
"optional_dependencies": list(manifest.optional_dependencies),
"enabled": True,
"nav": [_nav_item_payload(item) for item in manifest.nav_items],
"frontend": _frontend_payload(manifest.frontend),
}
for manifest in registry.manifests()
]
}
@router.get("/permissions")
def permissions(request: Request):
registry = _registry(request)
return {
"permissions": [
{
"scope": item.scope,
"module_id": item.module_id,
"resource": item.resource,
"action": item.action,
"label": item.label,
"description": item.description,
"category": item.category,
"level": item.level,
"deprecated": item.deprecated,
}
for item in registry.permissions()
]
}
@router.get("/navigation")
def navigation(request: Request):
registry = _registry(request)
return {
"items": [_nav_item_payload(item) for item in registry.nav_items()]
}
return router

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
from collections.abc import Callable, Iterable, Sequence
from govoplan_core.access.manifest import get_manifest as get_access_manifest
from govoplan_core.core.discovery import discover_module_manifests
from govoplan_core.core.modules import ModuleManifest
from govoplan_core.core.registry import PlatformRegistry, RegistryError
ManifestFactory = Callable[[], ModuleManifest]
def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
if isinstance(value, str):
items = [item.strip() for item in value.split(",")]
else:
items = [str(item).strip() for item in value]
return [item for item in items if item]
def available_module_manifests(manifest_factories: Sequence[ManifestFactory] = ()) -> dict[str, ModuleManifest]:
manifests = {manifest.id: manifest for manifest in discover_module_manifests()}
manifests.setdefault("access", get_access_manifest())
for factory in manifest_factories:
manifest = factory()
manifests[manifest.id] = manifest
return manifests
def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_factories: Sequence[ManifestFactory] = ()) -> PlatformRegistry:
requested = parse_enabled_modules(enabled_modules)
if "access" not in requested:
requested.insert(0, "access")
available = available_module_manifests(manifest_factories)
registry = PlatformRegistry()
for module_id in requested:
manifest = available.get(module_id)
if manifest is None:
raise RegistryError(f"Enabled module is not available: {module_id}")
registry.register(manifest)
registry.validate()
return registry