2026-06-29 00:10:10 +02:00
|
|
|
"""FastAPI application - LeoCRM backend."""
|
2026-06-03 23:52:09 +00:00
|
|
|
|
|
|
|
|
from __future__ import annotations
|
2026-07-02 00:20:15 +02:00
|
|
|
|
2026-07-01 23:15:35 +02:00
|
|
|
import time
|
|
|
|
|
import traceback
|
2026-06-03 23:52:09 +00:00
|
|
|
from contextlib import asynccontextmanager
|
|
|
|
|
|
2026-07-02 09:55:50 +02:00
|
|
|
from fastapi import FastAPI, HTTPException, Request
|
2026-06-03 23:52:09 +00:00
|
|
|
from fastapi.middleware.cors import CORSMiddleware
|
2026-07-02 09:55:50 +02:00
|
|
|
from fastapi.responses import FileResponse
|
|
|
|
|
from fastapi.staticfiles import StaticFiles
|
2026-07-01 23:15:35 +02:00
|
|
|
from starlette.middleware.base import BaseHTTPMiddleware
|
2026-07-02 13:02:38 +02:00
|
|
|
import importlib
|
|
|
|
|
import logging
|
2026-07-02 09:55:50 +02:00
|
|
|
import os
|
2026-06-03 23:52:09 +00:00
|
|
|
|
2026-07-02 13:02:38 +02:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
from app.config import get_settings
|
2026-06-29 01:18:46 +02:00
|
|
|
from app.core.db import close_engine, get_engine
|
2026-06-29 17:43:56 +02:00
|
|
|
from app.core.middleware import CSRFMiddleware
|
2026-07-01 23:15:35 +02:00
|
|
|
from app.core.monitoring import record_error, record_request
|
2026-06-29 01:18:46 +02:00
|
|
|
from app.core.service_container import get_container
|
|
|
|
|
from app.plugins.registry import get_registry
|
2026-06-29 17:43:56 +02:00
|
|
|
from app.routes import (
|
2026-07-04 01:30:08 +00:00
|
|
|
addresses,
|
2026-06-29 17:43:56 +02:00
|
|
|
ai_copilot,
|
2026-07-04 01:30:08 +00:00
|
|
|
audit,
|
2026-06-29 17:43:56 +02:00
|
|
|
auth,
|
|
|
|
|
companies,
|
|
|
|
|
contacts,
|
|
|
|
|
health,
|
|
|
|
|
import_export,
|
2026-07-01 23:15:35 +02:00
|
|
|
metrics,
|
2026-06-29 17:43:56 +02:00
|
|
|
notifications,
|
|
|
|
|
plugins,
|
|
|
|
|
roles,
|
|
|
|
|
tenants,
|
|
|
|
|
users,
|
|
|
|
|
workflows,
|
2026-07-04 00:22:08 +00:00
|
|
|
currencies,
|
2026-07-04 00:23:36 +00:00
|
|
|
taxes,
|
2026-07-04 00:24:41 +00:00
|
|
|
sequences,
|
2026-07-04 00:26:22 +00:00
|
|
|
system_settings,
|
2026-07-04 00:29:39 +00:00
|
|
|
attachments,
|
2026-06-29 17:43:56 +02:00
|
|
|
)
|
2026-06-03 23:52:09 +00:00
|
|
|
|
|
|
|
|
|
2026-07-01 23:15:35 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 23:52:09 +00:00
|
|
|
@asynccontextmanager
|
|
|
|
|
async def lifespan(app: FastAPI):
|
2026-06-29 00:10:10 +02:00
|
|
|
"""Application lifespan: startup and shutdown."""
|
2026-06-29 01:18:46 +02:00
|
|
|
# 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()
|
|
|
|
|
|
2026-07-02 13:32:48 +02:00
|
|
|
# Auto-install and activate all discovered builtin plugins
|
2026-07-02 13:02:38 +02:00
|
|
|
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:
|
2026-07-07 07:44:02 +02:00
|
|
|
for name in registry.resolve_load_order():
|
2026-07-02 13:32:48 +02:00
|
|
|
plugin = registry.get_plugin(name)
|
2026-07-02 13:02:38 +02:00
|
|
|
if plugin is None:
|
|
|
|
|
continue
|
2026-07-02 13:32:48 +02:00
|
|
|
|
|
|
|
|
# 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,
|
2026-07-03 00:12:43 +00:00
|
|
|
display_name=plugin.manifest.display_name,
|
2026-07-02 13:32:48 +02:00
|
|
|
version=plugin.manifest.version,
|
|
|
|
|
status="installed",
|
|
|
|
|
active=True,
|
2026-07-07 07:44:02 +02:00
|
|
|
is_core=plugin.manifest.is_core,
|
2026-07-02 13:32:48 +02:00
|
|
|
)
|
|
|
|
|
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
|
2026-07-02 13:02:38 +02:00
|
|
|
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)
|
2026-07-02 13:32:48 +02:00
|
|
|
plugin_record.active = True
|
|
|
|
|
logger.info(f"Activated plugin: {name} ({len(plugin.manifest.routes)} routes)")
|
2026-07-02 13:02:38 +02:00
|
|
|
except Exception as exc:
|
2026-07-02 13:32:48 +02:00
|
|
|
logger.warning(f"Failed to activate plugin {name}: {exc}")
|
2026-07-02 13:02:38 +02:00
|
|
|
|
|
|
|
|
await db.commit()
|
|
|
|
|
|
2026-07-04 01:30:08 +00:00
|
|
|
# Seed default data (EUR currency, 19%/7% tax rates) for all tenants
|
|
|
|
|
from app.core.seeds import seed_default_data
|
|
|
|
|
|
|
|
|
|
async with async_session() as db:
|
|
|
|
|
try:
|
|
|
|
|
await seed_default_data(db)
|
|
|
|
|
await db.commit()
|
|
|
|
|
logger.info("Default data seeding completed")
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
logger.warning(f"Default data seeding failed: {exc}")
|
|
|
|
|
await db.rollback()
|
|
|
|
|
|
2026-06-03 23:52:09 +00:00
|
|
|
yield
|
2026-06-29 01:18:46 +02:00
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
await close_engine()
|
2026-06-03 23:52:09 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def create_app() -> FastAPI:
|
2026-06-29 00:10:10 +02:00
|
|
|
"""Create and configure the FastAPI application."""
|
2026-06-03 23:52:09 +00:00
|
|
|
settings = get_settings()
|
2026-06-29 00:10:10 +02:00
|
|
|
app = FastAPI(title="LeoCRM", version="1.0.0", lifespan=lifespan)
|
2026-06-03 23:52:09 +00:00
|
|
|
|
|
|
|
|
app.add_middleware(
|
|
|
|
|
CORSMiddleware,
|
2026-06-29 00:10:10 +02:00
|
|
|
allow_origins=settings.cors_origin_list,
|
2026-06-03 23:52:09 +00:00
|
|
|
allow_credentials=True,
|
2026-06-29 00:10:10 +02:00
|
|
|
allow_methods=["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
|
|
|
|
|
allow_headers=["Authorization", "Content-Type", "X-CSRF-Token", "X-Tenant-ID"],
|
|
|
|
|
max_age=3600,
|
2026-06-03 23:52:09 +00:00
|
|
|
)
|
2026-06-29 00:10:10 +02:00
|
|
|
app.add_middleware(CSRFMiddleware)
|
2026-07-01 23:15:35 +02:00
|
|
|
app.add_middleware(RequestLoggingMiddleware)
|
2026-06-03 23:52:09 +00:00
|
|
|
|
|
|
|
|
app.include_router(health.router)
|
2026-07-01 23:15:35 +02:00
|
|
|
app.include_router(metrics.router)
|
2026-06-29 00:10:10 +02:00
|
|
|
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)
|
2026-06-29 00:44:34 +02:00
|
|
|
app.include_router(contacts.router)
|
|
|
|
|
app.include_router(import_export.router)
|
2026-06-29 01:18:46 +02:00
|
|
|
app.include_router(plugins.router)
|
2026-06-29 02:44:13 +02:00
|
|
|
app.include_router(ai_copilot.router)
|
|
|
|
|
app.include_router(workflows.router)
|
2026-07-04 00:22:08 +00:00
|
|
|
app.include_router(currencies.router)
|
2026-07-04 00:23:36 +00:00
|
|
|
app.include_router(taxes.router)
|
2026-07-04 00:24:41 +00:00
|
|
|
app.include_router(sequences.router)
|
2026-07-04 00:26:22 +00:00
|
|
|
app.include_router(system_settings.router)
|
2026-07-04 00:29:39 +00:00
|
|
|
app.include_router(attachments.router)
|
2026-07-04 01:30:08 +00:00
|
|
|
app.include_router(addresses.router)
|
|
|
|
|
app.include_router(audit.router)
|
2026-06-03 23:52:09 +00:00
|
|
|
|
2026-07-04 01:30:08 +00:00
|
|
|
# ── Register plugin routes (before SPA catch-all) ──────────────────
|
2026-07-02 14:37:30 +02:00
|
|
|
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}")
|
|
|
|
|
|
2026-07-04 01:30:08 +00:00
|
|
|
# ── Serve frontend static files (SPA) ──────────────────────────────
|
2026-07-02 09:55:50 +02:00
|
|
|
# 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")
|
|
|
|
|
|
2026-06-03 23:52:09 +00:00
|
|
|
return app
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app = create_app()
|