Phase 1 - Analytics y Reportes: - PostMetrics and AnalyticsReport models for tracking engagement - Analytics service with dashboard stats, top posts, optimal times - 8 API endpoints at /api/analytics/* - Interactive dashboard with Chart.js charts - Celery tasks for metrics fetch (15min) and weekly reports Phase 2 - Integración Odoo: - Lead and OdooSyncLog models for CRM integration - Odoo fields added to Product and Service models - XML-RPC service for bidirectional sync - Lead management API at /api/leads/* - Leads dashboard template - Celery tasks for product/service sync and lead export Phase 3 - A/B Testing y Recycling: - ABTest, ABTestVariant, RecycledPost models - Statistical winner analysis using chi-square test - Content recycling with engagement-based scoring - APIs at /api/ab-tests/* and /api/recycling/* - Automated test evaluation and content recycling tasks Phase 4 - Thread Series y Templates: - ThreadSeries and ThreadPost models for multi-post threads - AI-powered thread generation - Enhanced ImageTemplate with HTML template support - APIs at /api/threads/* and /api/templates/* - Thread scheduling with reply chain support Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
256 lines
6.8 KiB
Python
256 lines
6.8 KiB
Python
"""
|
|
API Routes for Thread Series Management.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from typing import List, Optional
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.orm import Session
|
|
from pydantic import BaseModel
|
|
|
|
from app.core.database import get_db
|
|
from app.services.thread_service import thread_service
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class ThreadPostCreate(BaseModel):
|
|
"""Schema for a post in a thread."""
|
|
content: str
|
|
image_url: Optional[str] = None
|
|
|
|
|
|
class ThreadSeriesCreate(BaseModel):
|
|
"""Schema for creating a thread series."""
|
|
name: str
|
|
platform: str
|
|
posts: List[ThreadPostCreate]
|
|
description: Optional[str] = None
|
|
topic: Optional[str] = None
|
|
schedule_type: str = "sequential"
|
|
interval_minutes: int = 5
|
|
hashtags: Optional[List[str]] = None
|
|
|
|
|
|
class ThreadGenerateRequest(BaseModel):
|
|
"""Schema for AI thread generation."""
|
|
topic: str
|
|
platform: str
|
|
num_posts: int = 5
|
|
style: str = "educational"
|
|
name: Optional[str] = None
|
|
|
|
|
|
class ScheduleRequest(BaseModel):
|
|
"""Schema for scheduling a series."""
|
|
start_time: Optional[datetime] = None
|
|
|
|
|
|
@router.get("/")
|
|
async def list_thread_series(
|
|
status: Optional[str] = None,
|
|
platform: Optional[str] = None,
|
|
limit: int = Query(20, ge=1, le=100),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
List all thread series.
|
|
|
|
- **status**: Filter by status (draft, scheduled, publishing, completed)
|
|
- **platform**: Filter by platform
|
|
"""
|
|
series_list = await thread_service.get_series_list(
|
|
status=status,
|
|
platform=platform,
|
|
limit=limit
|
|
)
|
|
return {"series": series_list, "count": len(series_list)}
|
|
|
|
|
|
@router.get("/{series_id}")
|
|
async def get_thread_series(
|
|
series_id: int,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Get a specific thread series with all its posts.
|
|
"""
|
|
series = await thread_service.get_series(series_id)
|
|
if not series:
|
|
raise HTTPException(status_code=404, detail="Series not found")
|
|
return series
|
|
|
|
|
|
@router.post("/")
|
|
async def create_thread_series(
|
|
series_data: ThreadSeriesCreate,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Create a new thread series manually.
|
|
|
|
- **name**: Series name
|
|
- **platform**: Target platform (x, threads)
|
|
- **posts**: List of posts with content and optional image_url
|
|
- **schedule_type**: "sequential" (posts one after another) or "timed"
|
|
- **interval_minutes**: Minutes between posts (default 5)
|
|
"""
|
|
if len(series_data.posts) < 2:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Thread series requires at least 2 posts"
|
|
)
|
|
|
|
if len(series_data.posts) > 20:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Maximum 20 posts per thread series"
|
|
)
|
|
|
|
try:
|
|
series = await thread_service.create_series(
|
|
name=series_data.name,
|
|
platform=series_data.platform,
|
|
posts_content=[p.dict() for p in series_data.posts],
|
|
description=series_data.description,
|
|
topic=series_data.topic,
|
|
schedule_type=series_data.schedule_type,
|
|
interval_minutes=series_data.interval_minutes,
|
|
hashtags=series_data.hashtags
|
|
)
|
|
|
|
return {
|
|
"message": "Thread series created successfully",
|
|
"series": series.to_dict()
|
|
}
|
|
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
@router.post("/generate")
|
|
async def generate_thread_with_ai(
|
|
gen_data: ThreadGenerateRequest,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Generate a thread series using AI.
|
|
|
|
- **topic**: The topic to generate content about
|
|
- **platform**: Target platform
|
|
- **num_posts**: Number of posts (2-10)
|
|
- **style**: Content style (educational, storytelling, tips)
|
|
"""
|
|
if gen_data.num_posts < 2 or gen_data.num_posts > 10:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Number of posts must be between 2 and 10"
|
|
)
|
|
|
|
result = await thread_service.generate_thread_with_ai(
|
|
topic=gen_data.topic,
|
|
platform=gen_data.platform,
|
|
num_posts=gen_data.num_posts,
|
|
style=gen_data.style
|
|
)
|
|
|
|
if not result.get("success"):
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=result.get("error", "AI generation failed")
|
|
)
|
|
|
|
# If a name was provided, also create the series
|
|
if gen_data.name and result.get("posts"):
|
|
posts_content = [{"content": p["content"]} for p in result["posts"]]
|
|
|
|
series = await thread_service.create_series(
|
|
name=gen_data.name,
|
|
platform=gen_data.platform,
|
|
posts_content=posts_content,
|
|
topic=gen_data.topic,
|
|
ai_generated=True,
|
|
generation_prompt=f"Topic: {gen_data.topic}, Style: {gen_data.style}"
|
|
)
|
|
|
|
return {
|
|
"message": "Thread generated and saved",
|
|
"series": series.to_dict(),
|
|
"generated_posts": result["posts"]
|
|
}
|
|
|
|
return {
|
|
"message": "Thread generated (not saved)",
|
|
"generated_posts": result["posts"],
|
|
"count": result["count"]
|
|
}
|
|
|
|
|
|
@router.post("/{series_id}/schedule")
|
|
async def schedule_series(
|
|
series_id: int,
|
|
schedule_data: ScheduleRequest = None,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Schedule a thread series for publishing.
|
|
|
|
- **start_time**: When to start publishing (optional, defaults to now + 5 min)
|
|
"""
|
|
start_time = schedule_data.start_time if schedule_data else None
|
|
|
|
result = await thread_service.schedule_series(
|
|
series_id=series_id,
|
|
start_time=start_time
|
|
)
|
|
|
|
if not result.get("success"):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=result.get("error", "Failed to schedule")
|
|
)
|
|
|
|
return {
|
|
"message": "Series scheduled successfully",
|
|
**result
|
|
}
|
|
|
|
|
|
@router.post("/{series_id}/publish-next")
|
|
async def publish_next_post(
|
|
series_id: int,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Manually trigger publishing the next post in a series.
|
|
"""
|
|
result = await thread_service.publish_next_post(series_id)
|
|
|
|
if not result.get("success"):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=result.get("error", "Failed to publish")
|
|
)
|
|
|
|
return result
|
|
|
|
|
|
@router.post("/{series_id}/cancel")
|
|
async def cancel_series(
|
|
series_id: int,
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""
|
|
Cancel a thread series and its scheduled posts.
|
|
"""
|
|
result = await thread_service.cancel_series(series_id)
|
|
|
|
if not result.get("success"):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=result.get("error", "Failed to cancel")
|
|
)
|
|
|
|
return {"message": "Series cancelled successfully"}
|