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:
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
49
backend/app/plan_limits.py
Normal file
49
backend/app/plan_limits.py
Normal 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, ""
|
||||
113
backend/app/routers/admin.py
Normal file
113
backend/app/routers/admin.py
Normal 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
|
||||
@@ -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
277
backend/app/routers/auth.py
Normal 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(),
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class LoginRequest(BaseModel):
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
user: Optional[dict] = None
|
||||
|
||||
|
||||
# === Characters ===
|
||||
|
||||
Reference in New Issue
Block a user