Initial commit: Leopost Full — merge di Leopost, Post Generator e Autopilot OS
- Backend FastAPI con multi-LLM (Claude/OpenAI/Gemini) - Publishing su Facebook, Instagram, YouTube, TikTok - Calendario editoriale con awareness levels (PAS, AIDA, BAB...) - Design system Editorial Fresh (Fraunces + DM Sans) - Scheduler automatico, gestione commenti AI, affiliate links Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
75
backend/app/routers/affiliates.py
Normal file
75
backend/app/routers/affiliates.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Affiliate links CRUD router.
|
||||
|
||||
Manages affiliate links that can be injected into generated content.
|
||||
"""
|
||||
|
||||
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
|
||||
from ..schemas import AffiliateLinkCreate, AffiliateLinkResponse, AffiliateLinkUpdate
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/affiliates",
|
||||
tags=["affiliates"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[AffiliateLinkResponse])
|
||||
def list_affiliate_links(
|
||||
character_id: int | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all affiliate links, optionally filtered by character."""
|
||||
query = db.query(AffiliateLink)
|
||||
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)):
|
||||
"""Get a single affiliate link by ID."""
|
||||
link = db.query(AffiliateLink).filter(AffiliateLink.id == link_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)):
|
||||
"""Create a new affiliate link."""
|
||||
link = AffiliateLink(**data.model_dump())
|
||||
db.add(link)
|
||||
db.commit()
|
||||
db.refresh(link)
|
||||
return link
|
||||
|
||||
|
||||
@router.put("/{link_id}", response_model=AffiliateLinkResponse)
|
||||
def update_affiliate_link(
|
||||
link_id: int, data: AffiliateLinkUpdate, db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update an affiliate link."""
|
||||
link = db.query(AffiliateLink).filter(AffiliateLink.id == link_id).first()
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail="Affiliate link not found")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(link, key, value)
|
||||
db.commit()
|
||||
db.refresh(link)
|
||||
return link
|
||||
|
||||
|
||||
@router.delete("/{link_id}", status_code=204)
|
||||
def delete_affiliate_link(link_id: int, db: Session = Depends(get_db)):
|
||||
"""Delete an affiliate link."""
|
||||
link = db.query(AffiliateLink).filter(AffiliateLink.id == link_id).first()
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail="Affiliate link not found")
|
||||
db.delete(link)
|
||||
db.commit()
|
||||
62
backend/app/routers/characters.py
Normal file
62
backend/app/routers/characters.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import Character
|
||||
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()
|
||||
|
||||
|
||||
@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()
|
||||
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)):
|
||||
character = Character(**data.model_dump())
|
||||
db.add(character)
|
||||
db.commit()
|
||||
db.refresh(character)
|
||||
return character
|
||||
|
||||
|
||||
@router.put("/{character_id}", response_model=CharacterResponse)
|
||||
def update_character(
|
||||
character_id: int, data: CharacterUpdate, db: Session = Depends(get_db)
|
||||
):
|
||||
character = db.query(Character).filter(Character.id == character_id).first()
|
||||
if not character:
|
||||
raise HTTPException(status_code=404, detail="Character not found")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(character, key, value)
|
||||
character.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(character)
|
||||
return 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()
|
||||
if not character:
|
||||
raise HTTPException(status_code=404, detail="Character not found")
|
||||
db.delete(character)
|
||||
db.commit()
|
||||
281
backend/app/routers/comments.py
Normal file
281
backend/app/routers/comments.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""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
|
||||
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"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
@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),
|
||||
):
|
||||
"""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)):
|
||||
"""Get only pending comments (reply_status='pending')."""
|
||||
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)):
|
||||
"""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)
|
||||
):
|
||||
"""Take action on a comment: approve, edit, or ignore."""
|
||||
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)):
|
||||
"""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")
|
||||
|
||||
# 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")
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
# Build publisher kwargs
|
||||
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)):
|
||||
"""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
|
||||
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}"}
|
||||
|
||||
# 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()
|
||||
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 # LLM not available, skip AI replies
|
||||
|
||||
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(
|
||||
SocialAccount.character_id == post.character_id,
|
||||
SocialAccount.platform == platform,
|
||||
SocialAccount.is_active == True,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not account or not account.access_token:
|
||||
continue
|
||||
|
||||
# Build publisher kwargs
|
||||
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 # Skip this post if API call fails
|
||||
|
||||
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)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
continue
|
||||
|
||||
# Generate AI suggested reply if LLM is available
|
||||
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 # Skip AI reply if generation fails
|
||||
|
||||
# Create comment record
|
||||
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}"}
|
||||
225
backend/app/routers/content.py
Normal file
225
backend/app/routers/content.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Content generation router.
|
||||
|
||||
Handles post generation via LLM, image generation, and CRUD operations on 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 AffiliateLink, Character, Post, SystemSetting
|
||||
from ..schemas import (
|
||||
GenerateContentRequest,
|
||||
GenerateImageRequest,
|
||||
PostResponse,
|
||||
PostUpdate,
|
||||
)
|
||||
from ..services.content import generate_hashtags, generate_post_text, inject_affiliate_links
|
||||
from ..services.images import get_image_provider
|
||||
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()
|
||||
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)):
|
||||
"""Generate content for a character using LLM."""
|
||||
# Validate character exists
|
||||
character = db.query(Character).filter(Character.id == request.character_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")
|
||||
|
||||
if not provider_name:
|
||||
raise HTTPException(status_code=400, detail="LLM provider not configured. Set 'llm_provider' in settings.")
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=400, detail="LLM API key not configured. Set 'llm_api_key' in settings.")
|
||||
|
||||
# Build character dict for content service
|
||||
char_dict = {
|
||||
"name": character.name,
|
||||
"niche": character.niche,
|
||||
"topics": character.topics or [],
|
||||
"tone": character.tone or "professional",
|
||||
}
|
||||
|
||||
# Create LLM provider and generate text
|
||||
llm = get_llm_provider(provider_name, api_key, model)
|
||||
text = generate_post_text(
|
||||
character=char_dict,
|
||||
llm_provider=llm,
|
||||
platform=request.platform,
|
||||
topic_hint=request.topic_hint,
|
||||
)
|
||||
|
||||
# Generate hashtags
|
||||
hashtags = generate_hashtags(text, llm, request.platform)
|
||||
|
||||
# Handle affiliate links
|
||||
affiliate_links_used: list[dict] = []
|
||||
if request.include_affiliates:
|
||||
links = (
|
||||
db.query(AffiliateLink)
|
||||
.filter(
|
||||
AffiliateLink.is_active == True,
|
||||
(AffiliateLink.character_id == character.id) | (AffiliateLink.character_id == None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if links:
|
||||
link_dicts = [
|
||||
{
|
||||
"url": link.url,
|
||||
"label": link.name,
|
||||
"keywords": link.topics or [],
|
||||
}
|
||||
for link in links
|
||||
]
|
||||
text, affiliate_links_used = inject_affiliate_links(
|
||||
text, link_dicts, character.topics or []
|
||||
)
|
||||
|
||||
# Create post record
|
||||
post = Post(
|
||||
character_id=character.id,
|
||||
content_type=request.content_type,
|
||||
text_content=text,
|
||||
hashtags=hashtags,
|
||||
affiliate_links_used=affiliate_links_used,
|
||||
llm_provider=provider_name,
|
||||
llm_model=model,
|
||||
platform_hint=request.platform,
|
||||
status="draft",
|
||||
)
|
||||
db.add(post)
|
||||
db.commit()
|
||||
db.refresh(post)
|
||||
return post
|
||||
|
||||
|
||||
@router.post("/generate-image", response_model=PostResponse)
|
||||
def generate_image(request: GenerateImageRequest, db: Session = Depends(get_db)):
|
||||
"""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()
|
||||
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")
|
||||
|
||||
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 ""
|
||||
visual_style = character.visual_style or {}
|
||||
style_desc = visual_style.get("description", "")
|
||||
prompt = (
|
||||
f"Create a social media image for {character.name}, "
|
||||
f"a content creator in the {character.niche} niche. "
|
||||
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,
|
||||
content_type="image",
|
||||
image_url=image_url,
|
||||
platform_hint="instagram",
|
||||
status="draft",
|
||||
)
|
||||
db.add(post)
|
||||
db.commit()
|
||||
db.refresh(post)
|
||||
return post
|
||||
|
||||
|
||||
@router.get("/posts", response_model=list[PostResponse])
|
||||
def list_posts(
|
||||
character_id: int | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all posts with optional filters."""
|
||||
query = db.query(Post)
|
||||
if character_id is not None:
|
||||
query = query.filter(Post.character_id == character_id)
|
||||
if status is not None:
|
||||
query = query.filter(Post.status == status)
|
||||
return query.order_by(Post.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}", response_model=PostResponse)
|
||||
def get_post(post_id: int, db: Session = Depends(get_db)):
|
||||
"""Get a single post by ID."""
|
||||
post = db.query(Post).filter(Post.id == post_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)):
|
||||
"""Update a post."""
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(post, key, value)
|
||||
post.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(post)
|
||||
return post
|
||||
|
||||
|
||||
@router.delete("/posts/{post_id}", status_code=204)
|
||||
def delete_post(post_id: int, db: Session = Depends(get_db)):
|
||||
"""Delete a post."""
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
db.delete(post)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/posts/{post_id}/approve", response_model=PostResponse)
|
||||
def approve_post(post_id: int, db: Session = Depends(get_db)):
|
||||
"""Approve a post (set status to 'approved')."""
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
post.status = "approved"
|
||||
post.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(post)
|
||||
return post
|
||||
112
backend/app/routers/editorial.py
Normal file
112
backend/app/routers/editorial.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Editorial Calendar router.
|
||||
|
||||
Espone endpoint per il calendario editoriale con awareness levels e formati narrativi.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import get_db
|
||||
from ..services.calendar_service import CalendarService, FORMATI_NARRATIVI, AWARENESS_LEVELS
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/editorial",
|
||||
tags=["editorial"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
_calendar_service = CalendarService()
|
||||
|
||||
|
||||
# === Schemas locali ===
|
||||
|
||||
class CalendarGenerateRequest(BaseModel):
|
||||
topics: list[str]
|
||||
format_narrativo: Optional[str] = None
|
||||
awareness_level: Optional[int] = None
|
||||
num_posts: int = 7
|
||||
start_date: Optional[str] = None
|
||||
character_id: Optional[int] = None
|
||||
|
||||
|
||||
class ExportCsvRequest(BaseModel):
|
||||
slots: list[dict]
|
||||
|
||||
|
||||
# === Endpoints ===
|
||||
|
||||
@router.get("/formats")
|
||||
def get_formats():
|
||||
"""Restituisce i format narrativi disponibili con i relativi awareness levels."""
|
||||
return {
|
||||
"formats": _calendar_service.get_formats(),
|
||||
"awareness_levels": [
|
||||
{"value": k, "label": v}
|
||||
for k, v in AWARENESS_LEVELS.items()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/generate-calendar")
|
||||
def generate_calendar(request: CalendarGenerateRequest, db: Session = Depends(get_db)):
|
||||
"""Genera un calendario editoriale con awareness levels."""
|
||||
if not request.topics:
|
||||
return {"slots": [], "totale_post": 0}
|
||||
|
||||
slots = _calendar_service.generate_calendar(
|
||||
topics=request.topics,
|
||||
num_posts=request.num_posts,
|
||||
format_narrativo=request.format_narrativo,
|
||||
awareness_level=request.awareness_level,
|
||||
start_date=request.start_date,
|
||||
)
|
||||
|
||||
return {
|
||||
"slots": slots,
|
||||
"totale_post": len(slots),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/export-csv")
|
||||
def export_csv(request: ExportCsvRequest):
|
||||
"""Esporta il calendario editoriale come CSV per Canva."""
|
||||
output = io.StringIO()
|
||||
fieldnames = [
|
||||
"indice",
|
||||
"data_pubblicazione",
|
||||
"topic",
|
||||
"formato_narrativo",
|
||||
"awareness_level",
|
||||
"awareness_label",
|
||||
"note",
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
|
||||
for slot in request.slots:
|
||||
writer.writerow({
|
||||
"indice": slot.get("indice", ""),
|
||||
"data_pubblicazione": slot.get("data_pubblicazione", ""),
|
||||
"topic": slot.get("topic", ""),
|
||||
"formato_narrativo": slot.get("formato_narrativo", ""),
|
||||
"awareness_level": slot.get("awareness_level", ""),
|
||||
"awareness_label": slot.get("awareness_label", ""),
|
||||
"note": slot.get("note", ""),
|
||||
})
|
||||
|
||||
output.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": "attachment; filename=calendario_editoriale.csv"
|
||||
},
|
||||
)
|
||||
150
backend/app/routers/plans.py
Normal file
150
backend/app/routers/plans.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""Editorial plans and scheduled posts router.
|
||||
|
||||
Manages editorial plans (posting schedules) and individual scheduled post entries.
|
||||
"""
|
||||
|
||||
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 EditorialPlan, ScheduledPost
|
||||
from ..schemas import (
|
||||
EditorialPlanCreate,
|
||||
EditorialPlanResponse,
|
||||
EditorialPlanUpdate,
|
||||
ScheduledPostCreate,
|
||||
ScheduledPostResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/plans",
|
||||
tags=["plans"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
# === Editorial Plans ===
|
||||
|
||||
|
||||
@router.get("/", response_model=list[EditorialPlanResponse])
|
||||
def list_plans(
|
||||
character_id: int | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all editorial plans, optionally filtered by character."""
|
||||
query = db.query(EditorialPlan)
|
||||
if character_id is not None:
|
||||
query = query.filter(EditorialPlan.character_id == character_id)
|
||||
return query.order_by(EditorialPlan.created_at.desc()).all()
|
||||
|
||||
|
||||
@router.get("/scheduled", response_model=list[ScheduledPostResponse])
|
||||
def list_all_scheduled_posts(
|
||||
platform: str | None = Query(None),
|
||||
status: str | None = Query(None),
|
||||
date_from: datetime | None = Query(None),
|
||||
date_after: datetime | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get all scheduled posts across all plans with optional filters."""
|
||||
query = db.query(ScheduledPost)
|
||||
if platform is not None:
|
||||
query = query.filter(ScheduledPost.platform == platform)
|
||||
if status is not None:
|
||||
query = query.filter(ScheduledPost.status == status)
|
||||
if date_from is not None:
|
||||
query = query.filter(ScheduledPost.scheduled_at >= date_from)
|
||||
if date_after is not None:
|
||||
query = query.filter(ScheduledPost.scheduled_at <= date_after)
|
||||
return query.order_by(ScheduledPost.scheduled_at).all()
|
||||
|
||||
|
||||
@router.get("/{plan_id}", response_model=EditorialPlanResponse)
|
||||
def get_plan(plan_id: int, db: Session = Depends(get_db)):
|
||||
"""Get a single editorial plan by ID."""
|
||||
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_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)):
|
||||
"""Create a new editorial plan."""
|
||||
plan = EditorialPlan(**data.model_dump())
|
||||
db.add(plan)
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return plan
|
||||
|
||||
|
||||
@router.put("/{plan_id}", response_model=EditorialPlanResponse)
|
||||
def update_plan(
|
||||
plan_id: int, data: EditorialPlanUpdate, db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update an editorial plan."""
|
||||
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="Editorial plan not found")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(plan, key, value)
|
||||
plan.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return plan
|
||||
|
||||
|
||||
@router.delete("/{plan_id}", status_code=204)
|
||||
def delete_plan(plan_id: int, db: Session = Depends(get_db)):
|
||||
"""Delete an editorial plan and its associated scheduled posts."""
|
||||
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_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)):
|
||||
"""Toggle the is_active status of an editorial plan."""
|
||||
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="Editorial plan not found")
|
||||
plan.is_active = not plan.is_active
|
||||
plan.updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(plan)
|
||||
return plan
|
||||
|
||||
|
||||
# === Scheduled Posts ===
|
||||
|
||||
|
||||
@router.get("/{plan_id}/schedule", response_model=list[ScheduledPostResponse])
|
||||
def get_plan_scheduled_posts(plan_id: int, db: Session = Depends(get_db)):
|
||||
"""Get all scheduled posts for a specific plan."""
|
||||
plan = db.query(EditorialPlan).filter(EditorialPlan.id == plan_id).first()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="Editorial plan not found")
|
||||
return (
|
||||
db.query(ScheduledPost)
|
||||
.filter(ScheduledPost.plan_id == plan_id)
|
||||
.order_by(ScheduledPost.scheduled_at)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.post("/schedule", response_model=ScheduledPostResponse, status_code=201)
|
||||
def schedule_post(data: ScheduledPostCreate, db: Session = Depends(get_db)):
|
||||
"""Manually schedule a post."""
|
||||
scheduled = ScheduledPost(**data.model_dump())
|
||||
db.add(scheduled)
|
||||
db.commit()
|
||||
db.refresh(scheduled)
|
||||
return scheduled
|
||||
122
backend/app/routers/settings.py
Normal file
122
backend/app/routers/settings.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""System settings router.
|
||||
|
||||
Manages key-value system settings including API provider configuration.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import SystemSetting
|
||||
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()
|
||||
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 _has_setting(key: str) -> str | None:
|
||||
setting = db.query(SystemSetting).filter(SystemSetting.key == key).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 = (
|
||||
db.query(SocialAccount)
|
||||
.filter(
|
||||
SocialAccount.platform == platform,
|
||||
SocialAccount.is_active == True,
|
||||
SocialAccount.access_token != None,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
social_platforms[platform] = has_account is not None
|
||||
|
||||
return {
|
||||
"llm": {
|
||||
"configured": bool(llm_provider and llm_key),
|
||||
"provider": llm_provider,
|
||||
},
|
||||
"image": {
|
||||
"configured": bool(image_provider and image_key),
|
||||
"provider": image_provider,
|
||||
},
|
||||
"voice": {
|
||||
"configured": bool(voice_provider and voice_key),
|
||||
"provider": voice_provider,
|
||||
},
|
||||
"social": social_platforms,
|
||||
}
|
||||
|
||||
|
||||
@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()
|
||||
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()
|
||||
if setting:
|
||||
setting.value = data.value
|
||||
setting.updated_at = datetime.utcnow()
|
||||
else:
|
||||
setting = SystemSetting(key=key, value=data.value)
|
||||
db.add(setting)
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
return setting
|
||||
|
||||
|
||||
@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()
|
||||
if not setting:
|
||||
raise HTTPException(status_code=404, detail=f"Setting '{key}' not found")
|
||||
db.delete(setting)
|
||||
db.commit()
|
||||
203
backend/app/routers/social.py
Normal file
203
backend/app/routers/social.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Social account management and publishing router.
|
||||
|
||||
Handles CRUD for social media accounts and manual publishing of scheduled 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 Post, ScheduledPost, SocialAccount
|
||||
from ..schemas import (
|
||||
ScheduledPostResponse,
|
||||
SocialAccountCreate,
|
||||
SocialAccountResponse,
|
||||
SocialAccountUpdate,
|
||||
)
|
||||
from ..services.social import get_publisher
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/social",
|
||||
tags=["social"],
|
||||
dependencies=[Depends(get_current_user)],
|
||||
)
|
||||
|
||||
|
||||
# === Social Accounts ===
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=list[SocialAccountResponse])
|
||||
def list_social_accounts(
|
||||
character_id: int | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all social accounts, optionally filtered by character."""
|
||||
query = db.query(SocialAccount)
|
||||
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)):
|
||||
"""Get a single social account by ID."""
|
||||
account = db.query(SocialAccount).filter(SocialAccount.id == account_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)):
|
||||
"""Create/connect a new social account."""
|
||||
account = SocialAccount(**data.model_dump())
|
||||
db.add(account)
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
@router.put("/accounts/{account_id}", response_model=SocialAccountResponse)
|
||||
def update_social_account(
|
||||
account_id: int, data: SocialAccountUpdate, db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a social account."""
|
||||
account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first()
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="Social account not found")
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(account, key, value)
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
@router.delete("/accounts/{account_id}", status_code=204)
|
||||
def delete_social_account(account_id: int, db: Session = Depends(get_db)):
|
||||
"""Delete a social account."""
|
||||
account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first()
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="Social account not found")
|
||||
db.delete(account)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/test")
|
||||
def test_social_account(account_id: int, db: Session = Depends(get_db)):
|
||||
"""Test connection to a social account by making a simple API call."""
|
||||
account = db.query(SocialAccount).filter(SocialAccount.id == account_id).first()
|
||||
if not account:
|
||||
raise HTTPException(status_code=404, detail="Social account not found")
|
||||
|
||||
if not account.access_token:
|
||||
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:
|
||||
raise HTTPException(status_code=400, detail="Facebook account requires page_id")
|
||||
kwargs["page_id"] = account.page_id
|
||||
elif account.platform == "instagram":
|
||||
ig_user_id = account.account_id or (account.extra_data or {}).get("ig_user_id")
|
||||
if not ig_user_id:
|
||||
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"}
|
||||
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=502, detail=f"Connection test failed: {e}")
|
||||
|
||||
|
||||
# === Publishing ===
|
||||
|
||||
|
||||
@router.post("/publish/{scheduled_post_id}", response_model=ScheduledPostResponse)
|
||||
def publish_scheduled_post(scheduled_post_id: int, db: Session = Depends(get_db)):
|
||||
"""Manually trigger publishing of a scheduled post."""
|
||||
scheduled = (
|
||||
db.query(ScheduledPost)
|
||||
.filter(ScheduledPost.id == scheduled_post_id)
|
||||
.first()
|
||||
)
|
||||
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()
|
||||
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,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not account:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"No active {scheduled.platform} account found for character {post.character_id}",
|
||||
)
|
||||
|
||||
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
|
||||
elif account.platform == "instagram":
|
||||
kwargs["ig_user_id"] = account.account_id or (account.extra_data or {}).get("ig_user_id")
|
||||
|
||||
try:
|
||||
scheduled.status = "publishing"
|
||||
db.commit()
|
||||
|
||||
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)}"
|
||||
|
||||
if post.video_url:
|
||||
external_id = publisher.publish_video(text, post.video_url)
|
||||
elif post.image_url:
|
||||
external_id = publisher.publish_image(text, post.image_url)
|
||||
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()
|
||||
|
||||
db.commit()
|
||||
db.refresh(scheduled)
|
||||
return scheduled
|
||||
|
||||
except (RuntimeError, ValueError) as e:
|
||||
scheduled.status = "failed"
|
||||
scheduled.error_message = str(e)
|
||||
db.commit()
|
||||
db.refresh(scheduled)
|
||||
raise HTTPException(status_code=502, detail=f"Publishing failed: {e}")
|
||||
Reference in New Issue
Block a user