Files
leocrm/app/main.py
T
Leopoldadmin 812ccdeaf0 fix: add missing display_name to PluginModel creation in lifespan
The PluginModel.display_name column is NOT NULL but was not set when
auto-installing builtin plugins during app startup, causing IntegrityError.
Fix mirrors the pattern used in registry.install().
2026-07-03 00:12:43 +00:00

234 lines
8.2 KiB
Python

"""FastAPI application - LeoCRM backend."""
from __future__ import annotations
import time
import traceback
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
import importlib
import logging
import os
logger = logging.getLogger(__name__)
from app.config import get_settings
from app.core.db import close_engine, get_engine
from app.core.middleware import CSRFMiddleware
from app.core.monitoring import record_error, record_request
from app.core.service_container import get_container
from app.plugins.registry import get_registry
from app.routes import (
ai_copilot,
auth,
companies,
contacts,
health,
import_export,
metrics,
notifications,
plugins,
roles,
tenants,
users,
workflows,
)
class RequestLoggingMiddleware(BaseHTTPMiddleware):
"""Structured logging + Prometheus metrics for every HTTP request."""
async def dispatch(self, request: Request, call_next):
start_time = time.perf_counter()
method = request.method
path = request.url.path
# Extract tenant_id from session cookie if available (best-effort)
tenant_id = None
try:
response = await call_next(request)
except Exception as exc:
duration_ms = (time.perf_counter() - start_time) * 1000
tb_str = traceback.format_exc()
record_error(
event="unhandled_exception",
method=method,
path=path,
status_code=500,
error=str(exc),
traceback_str=tb_str,
tenant_id=tenant_id,
)
raise
duration_ms = (time.perf_counter() - start_time) * 1000
status_code = response.status_code
# Try to get tenant_id from response headers or request state
# (set by auth middleware/dependency — best-effort, never log credentials)
record_request(
method=method,
path=path,
status_code=status_code,
duration_ms=duration_ms,
tenant_id=tenant_id,
)
return response
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan: startup and shutdown."""
# Initialize service container
container = get_container()
await container.initialize()
# Initialize plugin registry and discover built-in plugins
registry = get_registry()
registry.initialize(get_engine(), app)
registry.discover_builtins()
# Auto-install and activate all discovered builtin plugins
from sqlalchemy import select as sa_select
from sqlalchemy.ext.asyncio import async_sessionmaker
from app.models.plugin import Plugin as PluginModel
from app.core.event_bus import get_event_bus
event_bus = get_event_bus()
async_session = async_sessionmaker(get_engine(), expire_on_commit=False)
async with async_session() as db:
for name in registry._plugins:
plugin = registry.get_plugin(name)
if plugin is None:
continue
# Check if plugin already in DB
result = await db.execute(
sa_select(PluginModel).where(PluginModel.name == name)
)
plugin_record = result.scalar_one_or_none()
if plugin_record is None:
# Create DB record for this builtin plugin
plugin_record = PluginModel(
name=name,
display_name=plugin.manifest.display_name,
version=plugin.manifest.version,
status="installed",
active=True,
)
db.add(plugin_record)
await db.flush()
logger.info(f"Created plugin record: {name}")
# Run migrations if not yet applied
if plugin.manifest.migrations:
try:
await registry.migration_runner.run_all_migrations(
db, name, plugin.manifest.migrations
)
except Exception as exc:
logger.warning(f"Migration for {name}: {exc}")
# Activate plugin and register routes
try:
await plugin.on_activate(db, container, event_bus)
for route_def in plugin.manifest.routes:
router_module = importlib.import_module(route_def.module)
router = getattr(router_module, route_def.router_attr)
app.include_router(router)
plugin_record.active = True
logger.info(f"Activated plugin: {name} ({len(plugin.manifest.routes)} routes)")
except Exception as exc:
logger.warning(f"Failed to activate plugin {name}: {exc}")
await db.commit()
yield
await close_engine()
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
settings = get_settings()
app = FastAPI(title="LeoCRM", version="1.0.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "X-Tenant-ID"],
max_age=3600,
)
app.add_middleware(CSRFMiddleware)
app.add_middleware(RequestLoggingMiddleware)
app.include_router(health.router)
app.include_router(metrics.router)
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(roles.router)
app.include_router(tenants.router)
app.include_router(notifications.router)
app.include_router(companies.router)
app.include_router(contacts.router)
app.include_router(import_export.router)
app.include_router(plugins.router)
app.include_router(ai_copilot.router)
app.include_router(workflows.router)
# ── Register plugin routes (before SPA catch-all) ────────────────
registry = get_registry()
try:
registry.discover_builtins()
for name in registry._plugins:
plugin = registry.get_plugin(name)
if plugin is None:
continue
for route_def in plugin.manifest.routes:
try:
router_module = importlib.import_module(route_def.module)
router = getattr(router_module, route_def.router_attr)
app.include_router(router)
logger.info(f"Registered plugin routes: {name}")
except Exception as exc:
logger.warning(f"Failed to register routes for plugin {name}: {exc}")
except Exception as exc:
logger.warning(f"Plugin discovery failed: {exc}")
# ── Serve frontend static files (SPA) ────────────────────────────
# Mount built frontend assets (JS, CSS, images)
frontend_dist = os.path.join(os.path.dirname(__file__), "..", "frontend", "dist")
if os.path.isdir(frontend_dist):
# Serve static assets at /assets
assets_path = os.path.join(frontend_dist, "assets")
if os.path.isdir(assets_path):
app.mount("/assets", StaticFiles(directory=assets_path), name="assets")
# SPA catch-all: serve index.html for all non-API routes
@app.get("/{full_path:path}")
async def spa_spa(full_path: str):
"""Serve index.html for all non-API routes (SPA fallback)."""
# Don't intercept API routes
if full_path.startswith(("api/", "docs", "openapi", "redoc")):
raise HTTPException(status_code=404, detail="Not Found")
index_path = os.path.join(frontend_dist, "index.html")
if os.path.isfile(index_path):
return FileResponse(index_path)
raise HTTPException(status_code=404, detail="Frontend not built")
return app
app = create_app()