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

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

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

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

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

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

306 lines
10 KiB
Python

"""Comment management router.
Handles fetching, reviewing, and replying to comments on published posts.
"""
from datetime import 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 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
router = APIRouter(
prefix="/api/comments",
tags=["comments"],
)
@router.get("/", response_model=list[CommentResponse])
def list_comments(
platform: str | None = Query(None),
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)
if platform is not None:
query = query.filter(Comment.platform == platform)
if reply_status is not None:
query = query.filter(Comment.reply_status == reply_status)
if scheduled_post_id is not None:
query = query.filter(Comment.scheduled_post_id == scheduled_post_id)
return query.order_by(Comment.created_at.desc()).all()
@router.get("/pending", response_model=list[CommentResponse])
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")
.order_by(Comment.created_at.desc())
.all()
)
@router.get("/{comment_id}", response_model=CommentResponse)
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:
raise HTTPException(status_code=404, detail="Comment not found")
return comment
@router.post("/{comment_id}/action", response_model=CommentResponse)
def action_on_comment(
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")
if data.action == "approve":
comment.approved_reply = comment.ai_suggested_reply
comment.reply_status = "approved"
elif data.action == "edit":
if not data.reply_text:
raise HTTPException(status_code=400, detail="reply_text required for edit action")
comment.approved_reply = data.reply_text
comment.reply_status = "approved"
elif data.action == "ignore":
comment.reply_status = "ignored"
else:
raise HTTPException(status_code=400, detail=f"Unknown action '{data.action}'. Use: approve, edit, ignore")
db.commit()
db.refresh(comment)
return comment
@router.post("/{comment_id}/reply", response_model=CommentResponse)
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:
raise HTTPException(status_code=404, detail="Comment not found")
if not comment.approved_reply:
raise HTTPException(status_code=400, detail="No approved reply to send. Use /action first.")
if not comment.external_comment_id:
raise HTTPException(status_code=400, detail="No external comment ID available for reply")
if not comment.scheduled_post_id:
raise HTTPException(status_code=400, detail="Comment is not linked to a scheduled post")
scheduled = (
db.query(ScheduledPost)
.filter(ScheduledPost.id == comment.scheduled_post_id)
.first()
)
if not scheduled:
raise HTTPException(status_code=404, detail="Associated scheduled post not found")
post = db.query(Post).filter(Post.id == scheduled.post_id).first()
if not post:
raise HTTPException(status_code=404, detail="Associated post not found")
account = (
db.query(SocialAccount)
.filter(
SocialAccount.character_id == post.character_id,
SocialAccount.platform == comment.platform,
SocialAccount.is_active == True,
)
.first()
)
if not account:
raise HTTPException(
status_code=400,
detail=f"No active {comment.platform} account found for this character",
)
kwargs: dict = {}
if account.platform == "facebook":
kwargs["page_id"] = account.page_id
elif account.platform == "instagram":
kwargs["ig_user_id"] = account.account_id or (account.extra_data or {}).get("ig_user_id")
try:
publisher = get_publisher(account.platform, account.access_token, **kwargs)
success = publisher.reply_to_comment(comment.external_comment_id, comment.approved_reply)
if not success:
raise RuntimeError("Platform returned failure for reply")
comment.reply_status = "replied"
comment.replied_at = datetime.utcnow()
db.commit()
db.refresh(comment)
return comment
except (RuntimeError, ValueError) as e:
raise HTTPException(status_code=502, detail=f"Failed to send reply: {e}")
@router.post("/fetch/{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(
ScheduledPost.platform == platform,
ScheduledPost.status == "published",
ScheduledPost.external_post_id != None,
)
.all()
)
if not published_posts:
return {"new_comments": 0, "message": f"No published posts found for {platform}"}
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, 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
elif key == "llm_api_key":
llm_api_key = setting.value
elif key == "llm_model":
llm_model = setting.value
llm = None
if llm_provider_name and llm_api_key:
try:
llm = get_llm_provider(llm_provider_name, llm_api_key, llm_model)
except ValueError:
pass
new_comment_count = 0
for scheduled in published_posts:
post = db.query(Post).filter(Post.id == scheduled.post_id).first()
if not post:
continue
account = (
db.query(SocialAccount)
.filter(
SocialAccount.character_id == post.character_id,
SocialAccount.platform == platform,
SocialAccount.is_active == True,
)
.first()
)
if not account or not account.access_token:
continue
kwargs: dict = {}
if account.platform == "facebook":
kwargs["page_id"] = account.page_id
elif account.platform == "instagram":
kwargs["ig_user_id"] = account.account_id or (account.extra_data or {}).get("ig_user_id")
try:
publisher = get_publisher(account.platform, account.access_token, **kwargs)
comments = publisher.get_comments(scheduled.external_post_id)
except (RuntimeError, ValueError):
continue
for ext_comment in comments:
ext_id = ext_comment.get("id", "")
if not ext_id:
continue
existing = (
db.query(Comment)
.filter(Comment.external_comment_id == ext_id)
.first()
)
if existing:
continue
ai_reply = None
if llm:
try:
system_prompt = (
f"You are managing social media comments for a content creator. "
f"Write a friendly, on-brand reply to this comment. "
f"Keep it concise (1-2 sentences). Be authentic and engaging."
)
prompt = (
f"Comment by {ext_comment.get('author', 'someone')}: "
f"\"{ext_comment.get('text', '')}\"\n\n"
f"Write a reply:"
)
ai_reply = llm.generate(prompt, system=system_prompt)
except RuntimeError:
pass
comment = Comment(
scheduled_post_id=scheduled.id,
platform=platform,
external_comment_id=ext_id,
author_name=ext_comment.get("author", "Unknown"),
author_id=ext_comment.get("id", ""),
content=ext_comment.get("text", ""),
ai_suggested_reply=ai_reply,
reply_status="pending",
)
db.add(comment)
new_comment_count += 1
db.commit()
return {"new_comments": new_comment_count, "message": f"Fetched {new_comment_count} new comments from {platform}"}