from __future__ import annotations import inspect from collections.abc import Awaitable, Callable from datetime import datetime from typing import Any from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel, ConfigDict, Field from .manager import UIBuilderManager DataProvider = Callable[[], dict[str, Any] | Awaitable[dict[str, Any]]] class ConfigPayload(BaseModel): model_config = ConfigDict(extra="allow") version: int = 20 project_name: str data_source: str canvas: dict[str, Any] = Field(default_factory=dict) tabs: list[dict[str, Any]] = Field(default_factory=list) components: list[dict[str, Any]] = Field(default_factory=list) triggers: list[dict[str, Any]] = Field(default_factory=list) shortcut_sequences: list[dict[str, Any]] = Field(default_factory=list) prematch_groups: list[dict[str, Any]] = Field(default_factory=list) prematch_buttons: list[dict[str, Any]] = Field(default_factory=list) quick_panel_selectors: list[dict[str, Any]] = Field(default_factory=list) class BackupPayload(BaseModel): name: str def create_ui_builder_router( manager: UIBuilderManager, data_providers: dict[str, DataProvider], *, source_labels: dict[str, str] | None = None, prefix: str = "/api/ui-builder", access_dependency: Callable[..., Any] | None = None, read_only: bool = False, publish_manager: UIBuilderManager | None = None, ) -> APIRouter: dependencies = [Depends(access_dependency)] if access_dependency else [] router = APIRouter( prefix=prefix, tags=["UI Builder"], dependencies=dependencies, ) source_labels = source_labels or {} @router.get("/config") def get_config() -> dict[str, Any]: return manager.load() if not read_only: @router.post("/config") def save_config(payload: ConfigPayload) -> dict[str, Any]: saved = manager.save(payload.model_dump()) return {"ok": True, "config": saved} @router.post("/import") def import_config(payload: ConfigPayload) -> dict[str, Any]: saved = manager.import_config(payload.model_dump()) return {"ok": True, "config": saved} @router.get("/export") def export_config() -> JSONResponse: return JSONResponse( manager.load(), headers={ "Content-Disposition": 'attachment; filename="ui_builder_draft.json"' }, ) @router.get("/backups") def list_backups() -> dict[str, Any]: return {"items": manager.list_backups()} @router.post("/backups/restore") def restore_backup(payload: BackupPayload) -> dict[str, Any]: try: restored = manager.restore_backup(payload.name) except (FileNotFoundError, ValueError, OSError): raise HTTPException(status_code=404, detail="Backup not found") return {"ok": True, "config": restored} if publish_manager is not None: @router.post("/publish") def publish_config() -> dict[str, Any]: published = publish_manager.save( manager.load(), create_backup=True, ) return { "ok": True, "config": published, "published_at": datetime.now().isoformat(timespec="seconds"), } @router.get("/sources") async def list_sources() -> dict[str, Any]: return { "items": [ {"id": key, "label": source_labels.get(key, key)} for key in data_providers ] } @router.get("/data/{source}") async def get_data(source: str) -> dict[str, Any]: provider = data_providers.get(source) if provider is None: raise HTTPException(status_code=404, detail="Unknown data source") try: result = provider() if inspect.isawaitable(result): result = await result if not isinstance(result, dict): raise TypeError("Data provider must return a dict") return result except HTTPException: raise except Exception as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc return router