Files
leopost-full/backend/app/main.py
Michele 77ca70cd48 feat: multi-user SaaS, piani Freemium/Pro, Google OAuth, admin panel
BLOCCO 1 - Multi-user data model:
- User: email, display_name, avatar_url, auth_provider, google_id
- User: subscription_plan, subscription_expires_at, is_admin, post counters
- SubscriptionCode table per redeem codes
- user_id FK su Character, Post, AffiliateLink, EditorialPlan, SocialAccount, SystemSetting
- Migrazione SQLite-safe (ALTER TABLE) + preserva dati esistenti

BLOCCO 2 - Auth completo:
- Registrazione email/password + login multi-user
- Google OAuth 2.0 (httpx, no deps esterne)
- Callback flow: Google -> /auth/callback?token=JWT -> frontend
- Backward compat login admin con username

BLOCCO 3 - Piani e abbonamenti:
- Freemium: 1 character, 15 post/mese, FB+IG only, no auto-plans
- Pro: illimitato, tutte le piattaforme, tutte le feature
- Enforcement automatico in tutti i router
- Redeem codes con durate 1/3/6/12 mesi
- Admin panel: genera codici, lista utenti

BLOCCO 4 - Frontend completo:
- Login page design Leopost (split coral/cream, Google, social coming soon)
- AuthCallback per OAuth redirect
- PlanBanner, UpgradeModal con pricing
- AdminSettings per generazione codici
- CharacterForm con tab Account Social + guide setup

Deploy:
- Dockerfile con ARG VITE_BASE_PATH/VITE_API_BASE
- docker-compose.prod.yml per leopost.it (no subpath)
- docker-compose.yml aggiornato per lab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 20:01:07 +02:00

185 lines
5.8 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, run_migrations
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)
# Run migrations FIRST (add new columns to existing tables)
run_migrations(engine)
# Create tables (for new tables like subscription_codes)
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")