"""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()