Files
leopost-full/backend/app/main.py
Michele cc1cb2d02a feat(db): migrate to PostgreSQL 16 standalone
- docker-compose.prod.yml: add postgres:16-alpine service with health check,
  dedicated prod_leopost_net, backup volume mount, connection pool
- requirements.txt: add psycopg2-binary==2.9.9
- database.py: remove SQLite-specific run_migrations(), add PG pool_size/
  max_overflow/pool_pre_ping, keep sqlite compat for dev
- main.py: remove run_migrations call, rely on create_all for PG
- scripts/migrate_sqlite_to_pg.py: one-shot data migration script

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 17:11:35 +02:00

182 lines
5.7 KiB
Python

"""Leopost Full — FastAPI application.
IMPORTANT: root_path is intentionally NOT set in the FastAPI() constructor.
It is passed only via Uvicorn's --root-path flag at runtime.
"""
import logging
import os
from contextlib import asynccontextmanager
from pathlib import Path
from threading import Thread
from time import sleep
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from .auth import hash_password
from .config import settings
from .database import Base, SessionLocal, engine
from .models import User
from .routers.admin import router as admin_router
from .routers.auth import router as auth_router
from .routers.affiliates import router as affiliates_router
from .routers.characters import router as characters_router
from .routers.comments import router as comments_router
from .routers.content import router as content_router
from .routers.editorial import router as editorial_router
from .routers.plans import router as plans_router
from .routers.settings import router as settings_router
from .routers.social import router as social_router
logger = logging.getLogger("leopost")
# ---------------------------------------------------------------------------
# SPA static files with catch-all fallback
# ---------------------------------------------------------------------------
class SPAStaticFiles(StaticFiles):
"""Serve a React SPA: return index.html for any 404 so the client-side
router handles unknown paths instead of returning a 404 from the server."""
async def get_response(self, path: str, scope):
try:
return await super().get_response(path, scope)
except Exception:
return await super().get_response("index.html", scope)
# ---------------------------------------------------------------------------
# Background scheduler
# ---------------------------------------------------------------------------
_scheduler_running = False
def _scheduler_loop():
"""Simple scheduler that checks for posts to publish every 60 seconds."""
from .scheduler import check_and_publish
global _scheduler_running
while _scheduler_running:
try:
check_and_publish()
except Exception as e:
logger.error(f"Scheduler error: {e}")
sleep(60)
# ---------------------------------------------------------------------------
# Lifespan
# ---------------------------------------------------------------------------
@asynccontextmanager
async def lifespan(app: FastAPI):
global _scheduler_running
# Ensure data directory exists
data_dir = Path("./data")
data_dir.mkdir(parents=True, exist_ok=True)
# Create all tables (PostgreSQL: idempotent, safe to run on every startup)
Base.metadata.create_all(bind=engine)
# Create or update admin user
db = SessionLocal()
try:
existing = db.query(User).filter(User.username == settings.admin_username).first()
if not existing:
admin = User(
username=settings.admin_username,
hashed_password=hash_password(settings.admin_password),
email="admin@leopost.it",
display_name="Admin",
auth_provider="local",
subscription_plan="pro",
is_admin=True,
)
db.add(admin)
db.commit()
else:
# Update existing admin to ensure proper flags
updated = False
if not existing.is_admin:
existing.is_admin = True
updated = True
if existing.subscription_plan != "pro":
existing.subscription_plan = "pro"
updated = True
if not existing.email:
existing.email = "admin@leopost.it"
updated = True
if updated:
db.commit()
finally:
db.close()
# Start background scheduler
_scheduler_running = True
scheduler_thread = Thread(target=_scheduler_loop, daemon=True)
scheduler_thread.start()
logger.info("Background scheduler started")
yield
# Shutdown scheduler
_scheduler_running = False
# ---------------------------------------------------------------------------
# Application
# ---------------------------------------------------------------------------
# CRITICAL: Do NOT pass root_path here — use Uvicorn --root-path instead.
app = FastAPI(
title="Leopost Full",
version="0.2.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"https://leopost.it",
"https://www.leopost.it",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ---------------------------------------------------------------------------
# API routes (must be registered BEFORE the SPA catch-all mount)
# ---------------------------------------------------------------------------
app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(characters_router)
app.include_router(content_router)
app.include_router(affiliates_router)
app.include_router(plans_router)
app.include_router(social_router)
app.include_router(comments_router)
app.include_router(settings_router)
app.include_router(editorial_router)
@app.get("/api/health")
def health():
return {"status": "ok", "version": "0.2.0"}
# ---------------------------------------------------------------------------
# SPA catch-all mount (MUST be last)
# ---------------------------------------------------------------------------
_STATIC_DIR = Path(__file__).parent.parent / "static"
if _STATIC_DIR.exists():
app.mount("/", SPAStaticFiles(directory=str(_STATIC_DIR), html=True), name="spa")