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>
This commit is contained in:
Michele
2026-03-31 20:01:07 +02:00
parent 2c16407f96
commit 77ca70cd48
31 changed files with 2818 additions and 449 deletions

View File

@@ -1,7 +1,9 @@
"""Core authentication utilities used throughout the application."""
from datetime import datetime, timedelta
import bcrypt
from fastapi import APIRouter, Depends, HTTPException
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm import Session
@@ -9,9 +11,7 @@ from sqlalchemy.orm import Session
from .config import settings
from .database import get_db
from .models import User
from .schemas import LoginRequest, Token
router = APIRouter(prefix="/api/auth", tags=["auth"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
@@ -30,31 +30,29 @@ def create_access_token(data: dict) -> str:
return jwt.encode(to_encode, settings.secret_key, algorithm="HS256")
def get_user_by_email(db: Session, email: str) -> User | None:
return db.query(User).filter(User.email == email).first()
def get_current_user(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
) -> User:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
username: str = payload.get("sub")
if username is None:
# Support both sub (user_id) and legacy username
user_id = payload.get("user_id")
username = payload.get("sub")
if user_id is None and username is None:
raise HTTPException(status_code=401, detail="Invalid token")
except JWTError:
raise HTTPException(status_code=401, detail="Invalid token")
user = db.query(User).filter(User.username == username).first()
if user_id is not None:
user = db.query(User).filter(User.id == user_id).first()
else:
# Legacy: username-based token
user = db.query(User).filter(User.username == username).first()
if user is None:
raise HTTPException(status_code=401, detail="User not found")
return user
@router.post("/login", response_model=Token)
def login(request: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == request.username).first()
if not user or not verify_password(request.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid credentials")
token = create_access_token({"sub": user.username})
return Token(access_token=token)
@router.get("/me")
def me(user: User = Depends(get_current_user)):
return {"username": user.username}

View File

@@ -3,13 +3,23 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "sqlite:///./data/leopost.db"
secret_key: str = "change-me-to-a-random-secret-key"
secret_key: str = "leopost-secret-change-in-production-2026"
admin_username: str = "admin"
admin_password: str = "changeme"
access_token_expire_minutes: int = 1440 # 24h
# Google OAuth
# Reads from env vars: GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
google_client_id: str = ""
google_client_secret: str = ""
# App base URL (used for OAuth redirects)
# Reads from env var: APP_URL
app_url: str = "https://leopost.it"
class Config:
env_file = ".env"
extra = "ignore"
settings = Settings()

View File

@@ -1,4 +1,4 @@
from sqlalchemy import create_engine
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker, declarative_base
from .config import settings
@@ -18,3 +18,69 @@ def get_db():
yield db
finally:
db.close()
def run_migrations(engine):
"""SQLite-safe migration: add new columns if they don't exist."""
migrations = {
"users": [
("email", "VARCHAR"),
("display_name", "VARCHAR"),
("avatar_url", "VARCHAR"),
("auth_provider", "VARCHAR DEFAULT 'local'"),
("google_id", "VARCHAR"),
("subscription_plan", "VARCHAR DEFAULT 'freemium'"),
("subscription_expires_at", "DATETIME"),
("is_admin", "BOOLEAN DEFAULT 0"),
("posts_generated_this_month", "INTEGER DEFAULT 0"),
("posts_reset_date", "DATE"),
],
"characters": [("user_id", "INTEGER")],
"posts": [("user_id", "INTEGER")],
"affiliate_links": [("user_id", "INTEGER")],
"editorial_plans": [("user_id", "INTEGER")],
"social_accounts": [("user_id", "INTEGER")],
"system_settings": [("user_id", "INTEGER")],
}
with engine.connect() as conn:
for table, cols in migrations.items():
try:
existing = {row[1] for row in conn.execute(text(f"PRAGMA table_info({table})"))}
for col_name, col_def in cols:
if col_name not in existing:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col_name} {col_def}"))
conn.commit()
except Exception as e:
print(f"Migration warning for {table}: {e}")
# Fix system_settings: remove UNIQUE constraint on 'key' by recreating the table
# This allows per-user settings (same key, different user_id)
try:
indexes = list(conn.execute(text("PRAGMA index_list(system_settings)")))
has_unique_key = any(
row[1].lower().startswith("ix_") or "key" in row[1].lower()
for row in indexes
if row[2] == 1 # unique=1
)
# Check via table creation SQL
create_sql_row = conn.execute(text(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='system_settings'"
)).fetchone()
if create_sql_row and "UNIQUE" in (create_sql_row[0] or "").upper():
# Recreate without UNIQUE on key
conn.execute(text("ALTER TABLE system_settings RENAME TO system_settings_old"))
conn.execute(text("""
CREATE TABLE system_settings (
id INTEGER PRIMARY KEY,
key VARCHAR(100) NOT NULL,
value JSON,
updated_at DATETIME,
user_id INTEGER REFERENCES users(id)
)
"""))
conn.execute(text("INSERT INTO system_settings SELECT id, key, value, updated_at, user_id FROM system_settings_old"))
conn.execute(text("DROP TABLE system_settings_old"))
conn.commit()
print("Migration: system_settings UNIQUE constraint on key removed.")
except Exception as e:
print(f"Migration warning for system_settings UNIQUE fix: {e}")

View File

@@ -16,10 +16,11 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from .auth import hash_password
from .auth import router as auth_router
from .config import settings
from .database import Base, SessionLocal, engine
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
@@ -78,10 +79,13 @@ async def lifespan(app: FastAPI):
data_dir = Path("./data")
data_dir.mkdir(parents=True, exist_ok=True)
# Create tables
# 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 admin user if not exists
# Create or update admin user
db = SessionLocal()
try:
existing = db.query(User).filter(User.username == settings.admin_username).first()
@@ -89,9 +93,28 @@ async def lifespan(app: FastAPI):
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()
@@ -114,13 +137,17 @@ async def lifespan(app: FastAPI):
# CRITICAL: Do NOT pass root_path here — use Uvicorn --root-path instead.
app = FastAPI(
title="Leopost Full",
version="0.1.0",
version="0.2.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173"],
allow_origins=[
"http://localhost:5173",
"https://leopost.it",
"https://www.leopost.it",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
@@ -131,6 +158,7 @@ app.add_middleware(
# ---------------------------------------------------------------------------
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)
@@ -143,7 +171,7 @@ app.include_router(editorial_router)
@app.get("/api/health")
def health():
return {"status": "ok", "version": "0.1.0"}
return {"status": "ok", "version": "0.2.0"}
# ---------------------------------------------------------------------------

View File

@@ -1,6 +1,6 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, JSON, String, Text
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Integer, JSON, String, Text
from .database import Base
@@ -15,6 +15,30 @@ class User(Base):
hashed_password = Column(String(255), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow)
# Multi-user SaaS fields
email = Column(String(255), unique=True, nullable=True)
display_name = Column(String(100), nullable=True)
avatar_url = Column(String(500), nullable=True)
auth_provider = Column(String(50), default="local")
google_id = Column(String(200), unique=True, nullable=True)
subscription_plan = Column(String(50), default="freemium")
subscription_expires_at = Column(DateTime, nullable=True)
is_admin = Column(Boolean, default=False)
posts_generated_this_month = Column(Integer, default=0)
posts_reset_date = Column(Date, nullable=True)
class SubscriptionCode(Base):
__tablename__ = "subscription_codes"
id = Column(Integer, primary_key=True)
code = Column(String(100), unique=True, nullable=False)
duration_months = Column(Integer, nullable=False) # 1, 3, 6, 12
created_by_admin_id = Column(Integer, ForeignKey("users.id"))
used_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
class Character(Base):
__tablename__ = "characters"
@@ -31,6 +55,7 @@ class Character(Base):
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# === Phase 2: Content Generation ===
@@ -53,6 +78,7 @@ class Post(Base):
status = Column(String(20), default="draft") # draft, approved, scheduled, published, failed
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# === Phase 4: Affiliate Links ===
@@ -70,6 +96,7 @@ class AffiliateLink(Base):
is_active = Column(Boolean, default=True)
click_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# === Phase 5: Scheduling ===
@@ -90,6 +117,7 @@ class EditorialPlan(Base):
is_active = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
class ScheduledPost(Base):
@@ -124,6 +152,7 @@ class SocialAccount(Base):
extra_data = Column(JSON, default=dict) # platform-specific data
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# === Phase 11: Comment Management ===
@@ -151,6 +180,7 @@ class SystemSetting(Base):
__tablename__ = "system_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, nullable=False)
key = Column(String(100), nullable=False)
value = Column(JSON)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)

View File

@@ -0,0 +1,49 @@
"""Piano di abbonamento e limiti per feature."""
PLAN_LIMITS = {
"freemium": {
"characters_max": 1,
"posts_per_month": 15,
"platforms": ["facebook", "instagram"],
"auto_plans": False,
"comments_management": False,
"affiliate_links": False,
"editorial_calendar_max": 5,
},
"pro": {
"characters_max": None,
"posts_per_month": None,
"platforms": ["facebook", "instagram", "youtube", "tiktok"],
"auto_plans": True,
"comments_management": True,
"affiliate_links": True,
"editorial_calendar_max": None,
},
}
def get_plan(user) -> dict:
"""Returns the effective plan for a user (checks expiry for pro)."""
from datetime import datetime
plan = getattr(user, "subscription_plan", "freemium") or "freemium"
if plan == "pro":
expires = getattr(user, "subscription_expires_at", None)
if expires and expires < datetime.utcnow():
return PLAN_LIMITS["freemium"]
return PLAN_LIMITS.get(plan, PLAN_LIMITS["freemium"])
def check_limit(user, feature: str, current_count: int = 0) -> tuple[bool, str]:
"""Returns (allowed, error_message)."""
limits = get_plan(user)
limit = limits.get(feature)
if limit is None:
return True, ""
if isinstance(limit, bool):
if not limit:
return False, f"Feature '{feature}' non disponibile nel piano Freemium. Passa a Pro."
return True, ""
if isinstance(limit, int):
if current_count >= limit:
return False, f"Hai raggiunto il limite del piano Freemium ({limit} {feature}). Passa a Pro."
return True, ""

View File

@@ -0,0 +1,113 @@
"""Admin router — user management and subscription code generation."""
import secrets
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import SubscriptionCode, User
router = APIRouter(prefix="/api/admin", tags=["admin"])
def _require_admin(current_user: User = Depends(get_current_user)) -> User:
if not current_user.is_admin:
raise HTTPException(status_code=403, detail="Accesso riservato agli amministratori.")
return current_user
class GenerateCodeRequest(BaseModel):
duration_months: int # 1, 3, 6, 12
@router.get("/users")
def list_users(
db: Session = Depends(get_db),
admin: User = Depends(_require_admin),
):
"""List all users (admin only)."""
users = db.query(User).order_by(User.created_at.desc()).all()
return [
{
"id": u.id,
"email": u.email,
"username": u.username,
"display_name": u.display_name,
"subscription_plan": u.subscription_plan or "freemium",
"subscription_expires_at": u.subscription_expires_at.isoformat() if u.subscription_expires_at else None,
"is_admin": bool(u.is_admin),
"auth_provider": u.auth_provider or "local",
"created_at": u.created_at.isoformat() if u.created_at else None,
}
for u in users
]
@router.post("/codes/generate")
def generate_code(
request: GenerateCodeRequest,
db: Session = Depends(get_db),
admin: User = Depends(_require_admin),
):
"""Generate a new Pro subscription code (admin only)."""
if request.duration_months not in (1, 3, 6, 12):
raise HTTPException(status_code=400, detail="duration_months deve essere 1, 3, 6 o 12.")
raw = secrets.token_urlsafe(12).upper()[:12]
code_str = f"LP-{raw}"
# Ensure uniqueness
attempts = 0
while db.query(SubscriptionCode).filter(SubscriptionCode.code == code_str).first():
raw = secrets.token_urlsafe(12).upper()[:12]
code_str = f"LP-{raw}"
attempts += 1
if attempts > 10:
raise HTTPException(status_code=500, detail="Impossibile generare codice univoco.")
code = SubscriptionCode(
code=code_str,
duration_months=request.duration_months,
created_by_admin_id=admin.id,
)
db.add(code)
db.commit()
db.refresh(code)
return {
"code": code.code,
"duration_months": code.duration_months,
"created_at": code.created_at.isoformat(),
}
@router.get("/codes")
def list_codes(
db: Session = Depends(get_db),
admin: User = Depends(_require_admin),
):
"""List all subscription codes (admin only)."""
codes = db.query(SubscriptionCode).order_by(SubscriptionCode.created_at.desc()).all()
result = []
for c in codes:
used_by_email = None
if c.used_by_user_id:
used_user = db.query(User).filter(User.id == c.used_by_user_id).first()
if used_user:
used_by_email = used_user.email or used_user.username
result.append({
"id": c.id,
"code": c.code,
"duration_months": c.duration_months,
"status": "used" if c.used_by_user_id else "active",
"used_by": used_by_email,
"used_at": c.used_at.isoformat() if c.used_at else None,
"created_at": c.created_at.isoformat() if c.created_at else None,
})
return result

View File

@@ -8,13 +8,13 @@ from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import AffiliateLink
from ..models import AffiliateLink, User
from ..plan_limits import get_plan
from ..schemas import AffiliateLinkCreate, AffiliateLinkResponse, AffiliateLinkUpdate
router = APIRouter(
prefix="/api/affiliates",
tags=["affiliates"],
dependencies=[Depends(get_current_user)],
)
@@ -22,27 +22,48 @@ router = APIRouter(
def list_affiliate_links(
character_id: int | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all affiliate links, optionally filtered by character."""
query = db.query(AffiliateLink)
query = db.query(AffiliateLink).filter(AffiliateLink.user_id == current_user.id)
if character_id is not None:
query = query.filter(AffiliateLink.character_id == character_id)
return query.order_by(AffiliateLink.created_at.desc()).all()
@router.get("/{link_id}", response_model=AffiliateLinkResponse)
def get_affiliate_link(link_id: int, db: Session = Depends(get_db)):
def get_affiliate_link(
link_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single affiliate link by ID."""
link = db.query(AffiliateLink).filter(AffiliateLink.id == link_id).first()
link = (
db.query(AffiliateLink)
.filter(AffiliateLink.id == link_id, AffiliateLink.user_id == current_user.id)
.first()
)
if not link:
raise HTTPException(status_code=404, detail="Affiliate link not found")
return link
@router.post("/", response_model=AffiliateLinkResponse, status_code=201)
def create_affiliate_link(data: AffiliateLinkCreate, db: Session = Depends(get_db)):
def create_affiliate_link(
data: AffiliateLinkCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new affiliate link."""
plan = get_plan(current_user)
if not plan.get("affiliate_links"):
raise HTTPException(
status_code=403,
detail={"message": "Affiliate links disponibili solo con Pro.", "upgrade_required": True},
)
link = AffiliateLink(**data.model_dump())
link.user_id = current_user.id
db.add(link)
db.commit()
db.refresh(link)
@@ -51,10 +72,17 @@ def create_affiliate_link(data: AffiliateLinkCreate, db: Session = Depends(get_d
@router.put("/{link_id}", response_model=AffiliateLinkResponse)
def update_affiliate_link(
link_id: int, data: AffiliateLinkUpdate, db: Session = Depends(get_db)
link_id: int,
data: AffiliateLinkUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update an affiliate link."""
link = db.query(AffiliateLink).filter(AffiliateLink.id == link_id).first()
link = (
db.query(AffiliateLink)
.filter(AffiliateLink.id == link_id, AffiliateLink.user_id == current_user.id)
.first()
)
if not link:
raise HTTPException(status_code=404, detail="Affiliate link not found")
update_data = data.model_dump(exclude_unset=True)
@@ -66,9 +94,17 @@ def update_affiliate_link(
@router.delete("/{link_id}", status_code=204)
def delete_affiliate_link(link_id: int, db: Session = Depends(get_db)):
def delete_affiliate_link(
link_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete an affiliate link."""
link = db.query(AffiliateLink).filter(AffiliateLink.id == link_id).first()
link = (
db.query(AffiliateLink)
.filter(AffiliateLink.id == link_id, AffiliateLink.user_id == current_user.id)
.first()
)
if not link:
raise HTTPException(status_code=404, detail="Affiliate link not found")
db.delete(link)

277
backend/app/routers/auth.py Normal file
View File

@@ -0,0 +1,277 @@
"""Authentication router — multi-user SaaS with local + Google OAuth."""
import secrets
import uuid
from datetime import datetime, timedelta
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from ..auth import (
create_access_token,
get_current_user,
get_user_by_email,
hash_password,
verify_password,
)
from ..config import settings
from ..database import get_db
from ..models import SubscriptionCode, User
router = APIRouter(prefix="/api/auth", tags=["auth"])
# === Schemas ===
class RegisterRequest(BaseModel):
email: str
password: str
display_name: Optional[str] = None
class LoginRequest(BaseModel):
email: Optional[str] = None
username: Optional[str] = None # backward compat
password: str
class RedeemCodeRequest(BaseModel):
code: str
def _user_response(user: User) -> dict:
return {
"id": user.id,
"email": user.email,
"username": user.username,
"display_name": user.display_name,
"avatar_url": user.avatar_url,
"subscription_plan": user.subscription_plan or "freemium",
"subscription_expires_at": user.subscription_expires_at.isoformat() if user.subscription_expires_at else None,
"is_admin": bool(user.is_admin),
"posts_generated_this_month": user.posts_generated_this_month or 0,
}
# === Endpoints ===
@router.post("/register")
def register(request: RegisterRequest, db: Session = Depends(get_db)):
"""Register a new user with email + password."""
# Check email uniqueness
existing = get_user_by_email(db, request.email)
if existing:
raise HTTPException(status_code=400, detail="Email già registrata.")
# Build username from email for backward compat
username_base = request.email.split("@")[0]
username = username_base
counter = 1
while db.query(User).filter(User.username == username).first():
username = f"{username_base}{counter}"
counter += 1
user = User(
username=username,
hashed_password=hash_password(request.password),
email=request.email,
display_name=request.display_name or username,
auth_provider="local",
subscription_plan="freemium",
is_admin=False,
)
db.add(user)
db.commit()
db.refresh(user)
token = create_access_token({"sub": user.username, "user_id": user.id})
return {
"access_token": token,
"token_type": "bearer",
"user": _user_response(user),
}
@router.post("/login")
def login(request: LoginRequest, db: Session = Depends(get_db)):
"""Login with email (or legacy username) + password."""
user = None
if request.email:
user = get_user_by_email(db, request.email)
elif request.username:
# Backward compat: check username OR email
user = db.query(User).filter(User.username == request.username).first()
if not user:
user = get_user_by_email(db, request.username)
if not user or not verify_password(request.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Credenziali non valide.")
token = create_access_token({"sub": user.username, "user_id": user.id})
return {
"access_token": token,
"token_type": "bearer",
"user": _user_response(user),
}
@router.get("/me")
def me(user: User = Depends(get_current_user)):
"""Get current authenticated user info."""
return _user_response(user)
@router.post("/logout")
def logout():
"""Logout — client should remove the token."""
return {"message": "ok"}
# === Google OAuth ===
@router.get("/oauth/google")
def oauth_google_start():
"""Redirect to Google OAuth consent screen."""
if not settings.google_client_id:
raise HTTPException(status_code=501, detail="Google OAuth non configurato.")
state_token = str(uuid.uuid4())
params = {
"client_id": settings.google_client_id,
"redirect_uri": f"{settings.app_url}/api/auth/oauth/google/callback",
"response_type": "code",
"scope": "openid email profile",
"state": state_token,
"access_type": "offline",
}
query = "&".join(f"{k}={v}" for k, v in params.items())
auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{query}"
return RedirectResponse(url=auth_url)
@router.get("/oauth/google/callback")
async def oauth_google_callback(code: str, state: Optional[str] = None, db: Session = Depends(get_db)):
"""Exchange Google OAuth code for token, create/find user, redirect with JWT."""
if not settings.google_client_id or not settings.google_client_secret:
raise HTTPException(status_code=501, detail="Google OAuth non configurato.")
# Exchange code for tokens
async with httpx.AsyncClient() as client:
token_resp = await client.post(
"https://oauth2.googleapis.com/token",
data={
"code": code,
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"redirect_uri": f"{settings.app_url}/api/auth/oauth/google/callback",
"grant_type": "authorization_code",
},
)
if token_resp.status_code != 200:
raise HTTPException(status_code=400, detail="Errore scambio token Google.")
token_data = token_resp.json()
# Get user info
userinfo_resp = await client.get(
"https://www.googleapis.com/oauth2/v3/userinfo",
headers={"Authorization": f"Bearer {token_data['access_token']}"},
)
if userinfo_resp.status_code != 200:
raise HTTPException(status_code=400, detail="Errore recupero profilo Google.")
google_user = userinfo_resp.json()
google_id = google_user.get("sub")
email = google_user.get("email")
name = google_user.get("name")
picture = google_user.get("picture")
# Find existing user by google_id or email
user = db.query(User).filter(User.google_id == google_id).first()
if not user and email:
user = get_user_by_email(db, email)
if user:
# Update google_id and avatar if missing
if not user.google_id:
user.google_id = google_id
if not user.avatar_url and picture:
user.avatar_url = picture
db.commit()
else:
# Create new user
username_base = (email or google_id).split("@")[0]
username = username_base
counter = 1
while db.query(User).filter(User.username == username).first():
username = f"{username_base}{counter}"
counter += 1
user = User(
username=username,
hashed_password=hash_password(secrets.token_urlsafe(32)),
email=email,
display_name=name or username,
avatar_url=picture,
auth_provider="google",
google_id=google_id,
subscription_plan="freemium",
is_admin=False,
)
db.add(user)
db.commit()
db.refresh(user)
jwt_token = create_access_token({"sub": user.username, "user_id": user.id})
redirect_url = f"{settings.app_url}/auth/callback?token={jwt_token}"
return RedirectResponse(url=redirect_url)
# === Subscription code redemption ===
@router.post("/redeem")
def redeem_code(
request: RedeemCodeRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Redeem a Pro subscription code."""
code = db.query(SubscriptionCode).filter(
SubscriptionCode.code == request.code.upper().strip()
).first()
if not code:
raise HTTPException(status_code=404, detail="Codice non trovato.")
if code.used_by_user_id is not None:
raise HTTPException(status_code=400, detail="Codice già utilizzato.")
# Calculate new expiry
now = datetime.utcnow()
current_expiry = current_user.subscription_expires_at
if current_user.subscription_plan == "pro" and current_expiry and current_expiry > now:
# Extend existing pro subscription
base_date = current_expiry
else:
base_date = now
new_expiry = base_date + timedelta(days=30 * code.duration_months)
# Update user
current_user.subscription_plan = "pro"
current_user.subscription_expires_at = new_expiry
# Mark code as used
code.used_by_user_id = current_user.id
code.used_at = now
db.commit()
db.refresh(current_user)
return {
"subscription_plan": current_user.subscription_plan,
"subscription_expires_at": current_user.subscription_expires_at.isoformat(),
}

View File

@@ -5,32 +5,59 @@ from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import Character
from ..models import Character, User
from ..plan_limits import check_limit
from ..schemas import CharacterCreate, CharacterResponse, CharacterUpdate
router = APIRouter(
prefix="/api/characters",
tags=["characters"],
dependencies=[Depends(get_current_user)],
)
@router.get("/", response_model=list[CharacterResponse])
def list_characters(db: Session = Depends(get_db)):
return db.query(Character).order_by(Character.created_at.desc()).all()
def list_characters(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
return (
db.query(Character)
.filter(Character.user_id == current_user.id)
.order_by(Character.created_at.desc())
.all()
)
@router.get("/{character_id}", response_model=CharacterResponse)
def get_character(character_id: int, db: Session = Depends(get_db)):
character = db.query(Character).filter(Character.id == character_id).first()
def get_character(
character_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
character = (
db.query(Character)
.filter(Character.id == character_id, Character.user_id == current_user.id)
.first()
)
if not character:
raise HTTPException(status_code=404, detail="Character not found")
return character
@router.post("/", response_model=CharacterResponse, status_code=201)
def create_character(data: CharacterCreate, db: Session = Depends(get_db)):
def create_character(
data: CharacterCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Check plan limit for characters
count = db.query(Character).filter(Character.user_id == current_user.id).count()
allowed, msg = check_limit(current_user, "characters_max", count)
if not allowed:
raise HTTPException(status_code=403, detail={"message": msg, "upgrade_required": True})
character = Character(**data.model_dump())
character.user_id = current_user.id
db.add(character)
db.commit()
db.refresh(character)
@@ -39,9 +66,16 @@ def create_character(data: CharacterCreate, db: Session = Depends(get_db)):
@router.put("/{character_id}", response_model=CharacterResponse)
def update_character(
character_id: int, data: CharacterUpdate, db: Session = Depends(get_db)
character_id: int,
data: CharacterUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
character = db.query(Character).filter(Character.id == character_id).first()
character = (
db.query(Character)
.filter(Character.id == character_id, Character.user_id == current_user.id)
.first()
)
if not character:
raise HTTPException(status_code=404, detail="Character not found")
update_data = data.model_dump(exclude_unset=True)
@@ -54,8 +88,16 @@ def update_character(
@router.delete("/{character_id}", status_code=204)
def delete_character(character_id: int, db: Session = Depends(get_db)):
character = db.query(Character).filter(Character.id == character_id).first()
def delete_character(
character_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
character = (
db.query(Character)
.filter(Character.id == character_id, Character.user_id == current_user.id)
.first()
)
if not character:
raise HTTPException(status_code=404, detail="Character not found")
db.delete(character)

View File

@@ -10,7 +10,8 @@ from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import Comment, Post, ScheduledPost, SocialAccount, SystemSetting
from ..models import Comment, Post, ScheduledPost, SocialAccount, SystemSetting, User
from ..plan_limits import get_plan
from ..schemas import CommentAction, CommentResponse
from ..services.llm import get_llm_provider
from ..services.social import get_publisher
@@ -18,7 +19,6 @@ from ..services.social import get_publisher
router = APIRouter(
prefix="/api/comments",
tags=["comments"],
dependencies=[Depends(get_current_user)],
)
@@ -28,6 +28,7 @@ def list_comments(
reply_status: str | None = Query(None),
scheduled_post_id: int | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List comments with optional filters."""
query = db.query(Comment)
@@ -41,8 +42,17 @@ def list_comments(
@router.get("/pending", response_model=list[CommentResponse])
def list_pending_comments(db: Session = Depends(get_db)):
def list_pending_comments(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get only pending comments (reply_status='pending')."""
plan = get_plan(current_user)
if not plan.get("comments_management"):
raise HTTPException(
status_code=403,
detail={"message": "Gestione commenti disponibile solo con Pro.", "upgrade_required": True},
)
return (
db.query(Comment)
.filter(Comment.reply_status == "pending")
@@ -52,7 +62,11 @@ def list_pending_comments(db: Session = Depends(get_db)):
@router.get("/{comment_id}", response_model=CommentResponse)
def get_comment(comment_id: int, db: Session = Depends(get_db)):
def get_comment(
comment_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single comment by ID."""
comment = db.query(Comment).filter(Comment.id == comment_id).first()
if not comment:
@@ -62,9 +76,19 @@ def get_comment(comment_id: int, db: Session = Depends(get_db)):
@router.post("/{comment_id}/action", response_model=CommentResponse)
def action_on_comment(
comment_id: int, data: CommentAction, db: Session = Depends(get_db)
comment_id: int,
data: CommentAction,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Take action on a comment: approve, edit, or ignore."""
plan = get_plan(current_user)
if not plan.get("comments_management"):
raise HTTPException(
status_code=403,
detail={"message": "Gestione commenti disponibile solo con Pro.", "upgrade_required": True},
)
comment = db.query(Comment).filter(Comment.id == comment_id).first()
if not comment:
raise HTTPException(status_code=404, detail="Comment not found")
@@ -88,7 +112,11 @@ def action_on_comment(
@router.post("/{comment_id}/reply", response_model=CommentResponse)
def reply_to_comment(comment_id: int, db: Session = Depends(get_db)):
def reply_to_comment(
comment_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Send the approved reply via the social platform API."""
comment = db.query(Comment).filter(Comment.id == comment_id).first()
if not comment:
@@ -100,7 +128,6 @@ def reply_to_comment(comment_id: int, db: Session = Depends(get_db)):
if not comment.external_comment_id:
raise HTTPException(status_code=400, detail="No external comment ID available for reply")
# Find the social account for this platform via the scheduled post
if not comment.scheduled_post_id:
raise HTTPException(status_code=400, detail="Comment is not linked to a scheduled post")
@@ -131,7 +158,6 @@ def reply_to_comment(comment_id: int, db: Session = Depends(get_db)):
detail=f"No active {comment.platform} account found for this character",
)
# Build publisher kwargs
kwargs: dict = {}
if account.platform == "facebook":
kwargs["page_id"] = account.page_id
@@ -156,13 +182,12 @@ def reply_to_comment(comment_id: int, db: Session = Depends(get_db)):
@router.post("/fetch/{platform}")
def fetch_comments(platform: str, db: Session = Depends(get_db)):
"""Fetch new comments from a platform for all published posts.
Creates Comment records for any new comments not already in the database.
Uses LLM to generate AI-suggested replies for each new comment.
"""
# Get all published scheduled posts for this platform
def fetch_comments(
platform: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Fetch new comments from a platform for all published posts."""
published_posts = (
db.query(ScheduledPost)
.filter(
@@ -176,12 +201,17 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
if not published_posts:
return {"new_comments": 0, "message": f"No published posts found for {platform}"}
# Get LLM settings for AI reply generation
llm_provider_name = None
llm_api_key = None
llm_model = None
for key in ("llm_provider", "llm_api_key", "llm_model"):
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
setting = (
db.query(SystemSetting)
.filter(SystemSetting.key == key, SystemSetting.user_id == current_user.id)
.first()
)
if not setting:
setting = db.query(SystemSetting).filter(SystemSetting.key == key, SystemSetting.user_id == None).first()
if setting:
if key == "llm_provider":
llm_provider_name = setting.value
@@ -195,17 +225,15 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
try:
llm = get_llm_provider(llm_provider_name, llm_api_key, llm_model)
except ValueError:
pass # LLM not available, skip AI replies
pass
new_comment_count = 0
for scheduled in published_posts:
# Get the post to find the character
post = db.query(Post).filter(Post.id == scheduled.post_id).first()
if not post:
continue
# Find the social account
account = (
db.query(SocialAccount)
.filter(
@@ -218,7 +246,6 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
if not account or not account.access_token:
continue
# Build publisher kwargs
kwargs: dict = {}
if account.platform == "facebook":
kwargs["page_id"] = account.page_id
@@ -229,14 +256,13 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
publisher = get_publisher(account.platform, account.access_token, **kwargs)
comments = publisher.get_comments(scheduled.external_post_id)
except (RuntimeError, ValueError):
continue # Skip this post if API call fails
continue
for ext_comment in comments:
ext_id = ext_comment.get("id", "")
if not ext_id:
continue
# Check if comment already exists
existing = (
db.query(Comment)
.filter(Comment.external_comment_id == ext_id)
@@ -245,7 +271,6 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
if existing:
continue
# Generate AI suggested reply if LLM is available
ai_reply = None
if llm:
try:
@@ -261,9 +286,8 @@ def fetch_comments(platform: str, db: Session = Depends(get_db)):
)
ai_reply = llm.generate(prompt, system=system_prompt)
except RuntimeError:
pass # Skip AI reply if generation fails
pass
# Create comment record
comment = Comment(
scheduled_post_id=scheduled.id,
platform=platform,

View File

@@ -3,14 +3,15 @@
Handles post generation via LLM, image generation, and CRUD operations on posts.
"""
from datetime import datetime
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import AffiliateLink, Character, Post, SystemSetting
from ..models import AffiliateLink, Character, Post, SystemSetting, User
from ..plan_limits import check_limit
from ..schemas import (
GenerateContentRequest,
GenerateImageRequest,
@@ -24,30 +25,57 @@ from ..services.llm import get_llm_provider
router = APIRouter(
prefix="/api/content",
tags=["content"],
dependencies=[Depends(get_current_user)],
)
def _get_setting(db: Session, key: str) -> str | None:
"""Retrieve a system setting value by key."""
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
def _get_setting(db: Session, key: str, user_id: int = None) -> str | None:
"""Retrieve a system setting value by key, preferring user-specific over global."""
if user_id is not None:
setting = (
db.query(SystemSetting)
.filter(SystemSetting.key == key, SystemSetting.user_id == user_id)
.first()
)
if setting is not None:
return setting.value
# Fallback to global (no user_id)
setting = db.query(SystemSetting).filter(SystemSetting.key == key, SystemSetting.user_id == None).first()
if setting is None:
return None
return setting.value
@router.post("/generate", response_model=PostResponse)
def generate_content(request: GenerateContentRequest, db: Session = Depends(get_db)):
def generate_content(
request: GenerateContentRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Generate content for a character using LLM."""
# Validate character exists
character = db.query(Character).filter(Character.id == request.character_id).first()
# Validate character belongs to user
character = (
db.query(Character)
.filter(Character.id == request.character_id, Character.user_id == current_user.id)
.first()
)
if not character:
raise HTTPException(status_code=404, detail="Character not found")
# Get LLM settings
provider_name = request.provider or _get_setting(db, "llm_provider")
api_key = _get_setting(db, "llm_api_key")
model = request.model or _get_setting(db, "llm_model")
# Check monthly post limit
first_of_month = date.today().replace(day=1)
if current_user.posts_reset_date != first_of_month:
current_user.posts_generated_this_month = 0
current_user.posts_reset_date = first_of_month
db.commit()
allowed, msg = check_limit(current_user, "posts_per_month", current_user.posts_generated_this_month or 0)
if not allowed:
raise HTTPException(status_code=403, detail={"message": msg, "upgrade_required": True})
# Get LLM settings (user-specific first, then global)
provider_name = request.provider or _get_setting(db, "llm_provider", current_user.id)
api_key = _get_setting(db, "llm_api_key", current_user.id)
model = request.model or _get_setting(db, "llm_model", current_user.id)
if not provider_name:
raise HTTPException(status_code=400, detail="LLM provider not configured. Set 'llm_provider' in settings.")
@@ -63,7 +91,7 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_
}
# Create LLM provider and generate text
base_url = _get_setting(db, "llm_base_url")
base_url = _get_setting(db, "llm_base_url", current_user.id)
llm = get_llm_provider(provider_name, api_key, model, base_url=base_url)
text = generate_post_text(
character=char_dict,
@@ -82,6 +110,7 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_
db.query(AffiliateLink)
.filter(
AffiliateLink.is_active == True,
AffiliateLink.user_id == current_user.id,
(AffiliateLink.character_id == character.id) | (AffiliateLink.character_id == None),
)
.all()
@@ -102,6 +131,7 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_
# Create post record
post = Post(
character_id=character.id,
user_id=current_user.id,
content_type=request.content_type,
text_content=text,
hashtags=hashtags,
@@ -112,29 +142,37 @@ def generate_content(request: GenerateContentRequest, db: Session = Depends(get_
status="draft",
)
db.add(post)
# Increment monthly counter
current_user.posts_generated_this_month = (current_user.posts_generated_this_month or 0) + 1
db.commit()
db.refresh(post)
return post
@router.post("/generate-image", response_model=PostResponse)
def generate_image(request: GenerateImageRequest, db: Session = Depends(get_db)):
def generate_image(
request: GenerateImageRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Generate an image for a character and attach to a post."""
# Validate character exists
character = db.query(Character).filter(Character.id == request.character_id).first()
character = (
db.query(Character)
.filter(Character.id == request.character_id, Character.user_id == current_user.id)
.first()
)
if not character:
raise HTTPException(status_code=404, detail="Character not found")
# Get image settings
provider_name = request.provider or _get_setting(db, "image_provider")
api_key = _get_setting(db, "image_api_key")
provider_name = request.provider or _get_setting(db, "image_provider", current_user.id)
api_key = _get_setting(db, "image_api_key", current_user.id)
if not provider_name:
raise HTTPException(status_code=400, detail="Image provider not configured. Set 'image_provider' in settings.")
if not api_key:
raise HTTPException(status_code=400, detail="Image API key not configured. Set 'image_api_key' in settings.")
# Build prompt from character if not provided
prompt = request.prompt
if not prompt:
style_hint = request.style_hint or ""
@@ -146,13 +184,12 @@ def generate_image(request: GenerateImageRequest, db: Session = Depends(get_db))
f"Style: {style_desc} {style_hint}".strip()
)
# Generate image
image_provider = get_image_provider(provider_name, api_key)
image_url = image_provider.generate(prompt, size=request.size)
# Create a new post with the image
post = Post(
character_id=character.id,
user_id=current_user.id,
content_type="image",
image_url=image_url,
platform_hint="instagram",
@@ -169,9 +206,10 @@ def list_posts(
character_id: int | None = Query(None),
status: str | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all posts with optional filters."""
query = db.query(Post)
query = db.query(Post).filter(Post.user_id == current_user.id)
if character_id is not None:
query = query.filter(Post.character_id == character_id)
if status is not None:
@@ -180,18 +218,27 @@ def list_posts(
@router.get("/posts/{post_id}", response_model=PostResponse)
def get_post(post_id: int, db: Session = Depends(get_db)):
def get_post(
post_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single post by ID."""
post = db.query(Post).filter(Post.id == post_id).first()
post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
return post
@router.put("/posts/{post_id}", response_model=PostResponse)
def update_post(post_id: int, data: PostUpdate, db: Session = Depends(get_db)):
def update_post(
post_id: int,
data: PostUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update a post."""
post = db.query(Post).filter(Post.id == post_id).first()
post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
update_data = data.model_dump(exclude_unset=True)
@@ -204,9 +251,13 @@ def update_post(post_id: int, data: PostUpdate, db: Session = Depends(get_db)):
@router.delete("/posts/{post_id}", status_code=204)
def delete_post(post_id: int, db: Session = Depends(get_db)):
def delete_post(
post_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a post."""
post = db.query(Post).filter(Post.id == post_id).first()
post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
db.delete(post)
@@ -214,9 +265,13 @@ def delete_post(post_id: int, db: Session = Depends(get_db)):
@router.post("/posts/{post_id}/approve", response_model=PostResponse)
def approve_post(post_id: int, db: Session = Depends(get_db)):
def approve_post(
post_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Approve a post (set status to 'approved')."""
post = db.query(Post).filter(Post.id == post_id).first()
post = db.query(Post).filter(Post.id == post_id, Post.user_id == current_user.id).first()
if not post:
raise HTTPException(status_code=404, detail="Post not found")
post.status = "approved"

View File

@@ -10,7 +10,8 @@ from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import EditorialPlan, ScheduledPost
from ..models import EditorialPlan, ScheduledPost, User
from ..plan_limits import get_plan
from ..schemas import (
EditorialPlanCreate,
EditorialPlanResponse,
@@ -22,7 +23,6 @@ from ..schemas import (
router = APIRouter(
prefix="/api/plans",
tags=["plans"],
dependencies=[Depends(get_current_user)],
)
@@ -33,9 +33,10 @@ router = APIRouter(
def list_plans(
character_id: int | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all editorial plans, optionally filtered by character."""
query = db.query(EditorialPlan)
query = db.query(EditorialPlan).filter(EditorialPlan.user_id == current_user.id)
if character_id is not None:
query = query.filter(EditorialPlan.character_id == character_id)
return query.order_by(EditorialPlan.created_at.desc()).all()
@@ -48,9 +49,16 @@ def list_all_scheduled_posts(
date_from: datetime | None = Query(None),
date_after: datetime | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get all scheduled posts across all plans with optional filters."""
query = db.query(ScheduledPost)
# Join with plans to filter by user
user_plan_ids = [
p.id for p in db.query(EditorialPlan.id).filter(EditorialPlan.user_id == current_user.id).all()
]
query = db.query(ScheduledPost).filter(
(ScheduledPost.plan_id.in_(user_plan_ids)) | (ScheduledPost.plan_id == None)
)
if platform is not None:
query = query.filter(ScheduledPost.platform == platform)
if status is not None:
@@ -63,18 +71,38 @@ def list_all_scheduled_posts(
@router.get("/{plan_id}", response_model=EditorialPlanResponse)
def get_plan(plan_id: int, db: Session = Depends(get_db)):
def get_plan(
plan_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single editorial plan by ID."""
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first()
plan = (
db.query(EditorialPlan)
.filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found")
return plan
@router.post("/", response_model=EditorialPlanResponse, status_code=201)
def create_plan(data: EditorialPlanCreate, db: Session = Depends(get_db)):
def create_plan(
data: EditorialPlanCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create a new editorial plan."""
plan_limits = get_plan(current_user)
if not plan_limits.get("auto_plans"):
raise HTTPException(
status_code=403,
detail={"message": "Piani automatici disponibili solo con Pro.", "upgrade_required": True},
)
plan = EditorialPlan(**data.model_dump())
plan.user_id = current_user.id
db.add(plan)
db.commit()
db.refresh(plan)
@@ -83,10 +111,17 @@ def create_plan(data: EditorialPlanCreate, db: Session = Depends(get_db)):
@router.put("/{plan_id}", response_model=EditorialPlanResponse)
def update_plan(
plan_id: int, data: EditorialPlanUpdate, db: Session = Depends(get_db)
plan_id: int,
data: EditorialPlanUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update an editorial plan."""
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first()
plan = (
db.query(EditorialPlan)
.filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found")
update_data = data.model_dump(exclude_unset=True)
@@ -99,21 +134,36 @@ def update_plan(
@router.delete("/{plan_id}", status_code=204)
def delete_plan(plan_id: int, db: Session = Depends(get_db)):
def delete_plan(
plan_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete an editorial plan and its associated scheduled posts."""
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first()
plan = (
db.query(EditorialPlan)
.filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found")
# Delete associated scheduled posts first
db.query(ScheduledPost).filter(ScheduledPost.plan_id == plan_id).delete()
db.delete(plan)
db.commit()
@router.post("/{plan_id}/toggle", response_model=EditorialPlanResponse)
def toggle_plan(plan_id: int, db: Session = Depends(get_db)):
def toggle_plan(
plan_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Toggle the is_active status of an editorial plan."""
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first()
plan = (
db.query(EditorialPlan)
.filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found")
plan.is_active = not plan.is_active
@@ -127,9 +177,17 @@ def toggle_plan(plan_id: int, db: Session = Depends(get_db)):
@router.get("/{plan_id}/schedule", response_model=list[ScheduledPostResponse])
def get_plan_scheduled_posts(plan_id: int, db: Session = Depends(get_db)):
def get_plan_scheduled_posts(
plan_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get all scheduled posts for a specific plan."""
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first()
plan = (
db.query(EditorialPlan)
.filter(EditorialPlan.id == plan_id, EditorialPlan.user_id == current_user.id)
.first()
)
if not plan:
raise HTTPException(status_code=404, detail="Editorial plan not found")
return (
@@ -141,7 +199,11 @@ def get_plan_scheduled_posts(plan_id: int, db: Session = Depends(get_db)):
@router.post("/schedule", response_model=ScheduledPostResponse, status_code=201)
def schedule_post(data: ScheduledPostCreate, db: Session = Depends(get_db)):
def schedule_post(
data: ScheduledPostCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Manually schedule a post."""
scheduled = ScheduledPost(**data.model_dump())
db.add(scheduled)

View File

@@ -1,6 +1,7 @@
"""System settings router.
Manages key-value system settings including API provider configuration.
Each user has their own private settings.
"""
from datetime import datetime
@@ -10,51 +11,60 @@ from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import SystemSetting
from ..models import SystemSetting, User
from ..schemas import SettingResponse, SettingUpdate
router = APIRouter(
prefix="/api/settings",
tags=["settings"],
dependencies=[Depends(get_current_user)],
)
@router.get("/", response_model=list[SettingResponse])
def list_settings(db: Session = Depends(get_db)):
"""Get all system settings."""
settings = db.query(SystemSetting).order_by(SystemSetting.key).all()
def list_settings(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get all system settings for the current user."""
settings = (
db.query(SystemSetting)
.filter(SystemSetting.user_id == current_user.id)
.order_by(SystemSetting.key)
.all()
)
return settings
@router.get("/providers/status")
def get_providers_status(db: Session = Depends(get_db)):
"""Check which API providers are configured (have API keys set).
Returns a dict indicating configuration status for each provider category.
"""
# Helper to check if a setting exists and has a truthy value
def get_providers_status(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Check which API providers are configured (have API keys set)."""
def _has_setting(key: str) -> str | None:
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
# User-specific first
setting = (
db.query(SystemSetting)
.filter(SystemSetting.key == key, SystemSetting.user_id == current_user.id)
.first()
)
if not setting:
# Global fallback
setting = db.query(SystemSetting).filter(
SystemSetting.key == key, SystemSetting.user_id == None
).first()
if setting and setting.value:
return setting.value if isinstance(setting.value, str) else str(setting.value)
return None
# LLM provider
llm_provider = _has_setting("llm_provider")
llm_key = _has_setting("llm_api_key")
# Image provider
image_provider = _has_setting("image_provider")
image_key = _has_setting("image_api_key")
# Voice provider (future)
voice_provider = _has_setting("voice_provider")
voice_key = _has_setting("voice_api_key")
# Social platforms - check for any active social accounts
from ..models import SocialAccount
social_platforms = {}
for platform in ("facebook", "instagram", "youtube", "tiktok"):
has_account = (
@@ -63,6 +73,7 @@ def get_providers_status(db: Session = Depends(get_db)):
SocialAccount.platform == platform,
SocialAccount.is_active == True,
SocialAccount.access_token != None,
SocialAccount.user_id == current_user.id,
)
.first()
)
@@ -86,26 +97,40 @@ def get_providers_status(db: Session = Depends(get_db)):
@router.get("/{key}", response_model=SettingResponse)
def get_setting(key: str, db: Session = Depends(get_db)):
"""Get a single setting by key."""
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
def get_setting(
key: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single setting by key (user-specific)."""
setting = (
db.query(SystemSetting)
.filter(SystemSetting.key == key, SystemSetting.user_id == current_user.id)
.first()
)
if not setting:
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
return setting
@router.put("/{key}", response_model=SettingResponse)
def upsert_setting(key: str, data: SettingUpdate, db: Session = Depends(get_db)):
"""Create or update a setting by key.
If the setting exists, update its value. If not, create it.
"""
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
def upsert_setting(
key: str,
data: SettingUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create or update a setting by key (user-specific)."""
setting = (
db.query(SystemSetting)
.filter(SystemSetting.key == key, SystemSetting.user_id == current_user.id)
.first()
)
if setting:
setting.value = data.value
setting.updated_at = datetime.utcnow()
else:
setting = SystemSetting(key=key, value=data.value)
setting = SystemSetting(key=key, value=data.value, user_id=current_user.id)
db.add(setting)
db.commit()
db.refresh(setting)
@@ -113,9 +138,17 @@ def upsert_setting(key: str, data: SettingUpdate, db: Session = Depends(get_db))
@router.delete("/{key}", status_code=204)
def delete_setting(key: str, db: Session = Depends(get_db)):
"""Delete a setting by key."""
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
def delete_setting(
key: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a setting by key (user-specific)."""
setting = (
db.query(SystemSetting)
.filter(SystemSetting.key == key, SystemSetting.user_id == current_user.id)
.first()
)
if not setting:
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
db.delete(setting)

View File

@@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
from ..auth import get_current_user
from ..database import get_db
from ..models import Post, ScheduledPost, SocialAccount
from ..models import Post, ScheduledPost, SocialAccount, User
from ..schemas import (
ScheduledPostResponse,
SocialAccountCreate,
@@ -22,7 +22,6 @@ from ..services.social import get_publisher
router = APIRouter(
prefix="/api/social",
tags=["social"],
dependencies=[Depends(get_current_user)],
)
@@ -33,27 +32,41 @@ router = APIRouter(
def list_social_accounts(
character_id: int | None = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all social accounts, optionally filtered by character."""
query = db.query(SocialAccount)
query = db.query(SocialAccount).filter(SocialAccount.user_id == current_user.id)
if character_id is not None:
query = query.filter(SocialAccount.character_id == character_id)
return query.order_by(SocialAccount.created_at.desc()).all()
@router.get("/accounts/{account_id}", response_model=SocialAccountResponse)
def get_social_account(account_id: int, db: Session = Depends(get_db)):
def get_social_account(
account_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get a single social account by ID."""
account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first()
account = (
db.query(SocialAccount)
.filter(SocialAccount.id == account_id, SocialAccount.user_id == current_user.id)
.first()
)
if not account:
raise HTTPException(status_code=404, detail="Social account not found")
return account
@router.post("/accounts", response_model=SocialAccountResponse, status_code=201)
def create_social_account(data: SocialAccountCreate, db: Session = Depends(get_db)):
def create_social_account(
data: SocialAccountCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Create/connect a new social account."""
account = SocialAccount(**data.model_dump())
account.user_id = current_user.id
db.add(account)
db.commit()
db.refresh(account)
@@ -62,10 +75,17 @@ def create_social_account(data: SocialAccountCreate, db: Session = Depends(get_d
@router.put("/accounts/{account_id}", response_model=SocialAccountResponse)
def update_social_account(
account_id: int, data: SocialAccountUpdate, db: Session = Depends(get_db)
account_id: int,
data: SocialAccountUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update a social account."""
account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first()
account = (
db.query(SocialAccount)
.filter(SocialAccount.id == account_id, SocialAccount.user_id == current_user.id)
.first()
)
if not account:
raise HTTPException(status_code=404, detail="Social account not found")
update_data = data.model_dump(exclude_unset=True)
@@ -77,9 +97,17 @@ def update_social_account(
@router.delete("/accounts/{account_id}", status_code=204)
def delete_social_account(account_id: int, db: Session = Depends(get_db)):
def delete_social_account(
account_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Delete a social account."""
account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first()
account = (
db.query(SocialAccount)
.filter(SocialAccount.id == account_id, SocialAccount.user_id == current_user.id)
.first()
)
if not account:
raise HTTPException(status_code=404, detail="Social account not found")
db.delete(account)
@@ -87,9 +115,17 @@ def delete_social_account(account_id: int, db: Session = Depends(get_db)):
@router.post("/accounts/{account_id}/test")
def test_social_account(account_id: int, db: Session = Depends(get_db)):
def test_social_account(
account_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Test connection to a social account by making a simple API call."""
account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first()
account = (
db.query(SocialAccount)
.filter(SocialAccount.id == account_id, SocialAccount.user_id == current_user.id)
.first()
)
if not account:
raise HTTPException(status_code=404, detail="Social account not found")
@@ -97,7 +133,6 @@ def test_social_account(account_id: int, db: Session = Depends(get_db)):
raise HTTPException(status_code=400, detail="No access token configured for this account")
try:
# Build kwargs based on platform
kwargs: dict = {}
if account.platform == "facebook":
if not account.page_id:
@@ -109,7 +144,6 @@ def test_social_account(account_id: int, db: Session = Depends(get_db)):
raise HTTPException(status_code=400, detail="Instagram account requires ig_user_id")
kwargs["ig_user_id"] = ig_user_id
# Try to instantiate the publisher (validates credentials format)
get_publisher(account.platform, account.access_token, **kwargs)
return {"status": "ok", "message": f"Connection to {account.platform} account is configured correctly"}
@@ -123,7 +157,11 @@ def test_social_account(account_id: int, db: Session = Depends(get_db)):
@router.post("/publish/{scheduled_post_id}", response_model=ScheduledPostResponse)
def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db)):
def publish_scheduled_post(
scheduled_post_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Manually trigger publishing of a scheduled post."""
scheduled = (
db.query(ScheduledPost)
@@ -133,18 +171,17 @@ def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db)
if not scheduled:
raise HTTPException(status_code=404, detail="Scheduled post not found")
# Get the post content
post = db.query(Post).filter(Post.id == scheduled.post_id).first()
post = db.query(Post).filter(Post.id == scheduled.post_id, Post.user_id == current_user.id).first()
if not post:
raise HTTPException(status_code=404, detail="Associated post not found")
# Find the social account for this platform and character
account = (
db.query(SocialAccount)
.filter(
SocialAccount.character_id == post.character_id,
SocialAccount.platform == scheduled.platform,
SocialAccount.is_active == True,
SocialAccount.user_id == current_user.id,
)
.first()
)
@@ -157,7 +194,6 @@ def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db)
if not account.access_token:
raise HTTPException(status_code=400, detail="Social account has no access token configured")
# Build publisher kwargs
kwargs: dict = {}
if account.platform == "facebook":
kwargs["page_id"] = account.page_id
@@ -170,7 +206,6 @@ def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db)
publisher = get_publisher(account.platform, account.access_token, **kwargs)
# Determine publish method based on content type
text = post.text_content or ""
if post.hashtags:
text = f"{text}\n\n{' '.join(post.hashtags)}"
@@ -182,12 +217,10 @@ def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db)
else:
external_id = publisher.publish_text(text)
# Update scheduled post
scheduled.status = "published"
scheduled.published_at = datetime.utcnow()
scheduled.external_post_id = external_id
# Update post status
post.status = "published"
post.updated_at = datetime.utcnow()

View File

@@ -14,6 +14,7 @@ class LoginRequest(BaseModel):
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
user: Optional[dict] = None
# === Characters ===