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:
2026-01-28 03:10:42 +00:00
parent 03b5f9f2e2
commit ecc2ca73ea
31 changed files with 6067 additions and 6 deletions

255
app/api/routes/threads.py Normal file
View 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"}