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:
Michele
2026-03-31 17:23:16 +02:00
commit 519a580679
58 changed files with 8348 additions and 0 deletions

View File

View 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()

View 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()

View 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}"}

View 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

View 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"
},
)

View 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

View 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()

View 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}")