feat: Add Analytics, Odoo Integration, A/B Testing, and Content features
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>
This commit is contained in:
255
app/api/routes/threads.py
Normal file
255
app/api/routes/threads.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
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"}
|
||||
Reference in New Issue
Block a user