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:
181
app/api/routes/recycling.py
Normal file
181
app/api/routes/recycling.py
Normal file
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
API Routes for Content Recycling.
|
||||
"""
|
||||
|
||||
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.recycling_service import recycling_service
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class RecycleRequest(BaseModel):
|
||||
"""Schema for recycling a post."""
|
||||
content: Optional[str] = None
|
||||
hashtags: Optional[List[str]] = None
|
||||
image_url: Optional[str] = None
|
||||
scheduled_for: Optional[datetime] = None
|
||||
platforms: Optional[List[str]] = None
|
||||
reason: str = "manual"
|
||||
|
||||
|
||||
@router.get("/candidates")
|
||||
async def get_recyclable_candidates(
|
||||
platform: Optional[str] = None,
|
||||
content_type: Optional[str] = None,
|
||||
min_engagement_rate: float = Query(2.0, ge=0),
|
||||
min_days: int = Query(30, ge=7, le=365),
|
||||
limit: int = Query(20, ge=1, le=50),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get posts that are good candidates for recycling.
|
||||
|
||||
- **platform**: Filter by platform
|
||||
- **content_type**: Filter by content type
|
||||
- **min_engagement_rate**: Minimum engagement rate (default 2.0%)
|
||||
- **min_days**: Minimum days since original publish (default 30)
|
||||
"""
|
||||
candidates = await recycling_service.find_recyclable_posts(
|
||||
platform=platform,
|
||||
content_type=content_type,
|
||||
min_engagement_rate=min_engagement_rate,
|
||||
min_days_since_publish=min_days,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return {
|
||||
"candidates": candidates,
|
||||
"count": len(candidates),
|
||||
"filters": {
|
||||
"platform": platform,
|
||||
"content_type": content_type,
|
||||
"min_engagement_rate": min_engagement_rate,
|
||||
"min_days": min_days
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{post_id}")
|
||||
async def recycle_post(
|
||||
post_id: int,
|
||||
recycle_data: RecycleRequest = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Recycle a post by creating a new version.
|
||||
|
||||
You can optionally modify:
|
||||
- **content**: New content text
|
||||
- **hashtags**: New hashtag list
|
||||
- **image_url**: New image URL
|
||||
- **scheduled_for**: When to publish (default: 1 hour from now)
|
||||
- **platforms**: Override platforms
|
||||
- **reason**: Reason for recycling (manual, high_performer, evergreen, seasonal)
|
||||
"""
|
||||
modifications = None
|
||||
scheduled_for = None
|
||||
platforms = None
|
||||
reason = "manual"
|
||||
|
||||
if recycle_data:
|
||||
modifications = {}
|
||||
if recycle_data.content:
|
||||
modifications["content"] = recycle_data.content
|
||||
if recycle_data.hashtags:
|
||||
modifications["hashtags"] = recycle_data.hashtags
|
||||
if recycle_data.image_url:
|
||||
modifications["image_url"] = recycle_data.image_url
|
||||
|
||||
scheduled_for = recycle_data.scheduled_for
|
||||
platforms = recycle_data.platforms
|
||||
reason = recycle_data.reason
|
||||
|
||||
result = await recycling_service.recycle_post(
|
||||
post_id=post_id,
|
||||
modifications=modifications if modifications else None,
|
||||
scheduled_for=scheduled_for,
|
||||
platforms=platforms,
|
||||
reason=reason
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=result.get("error", "Failed to recycle post")
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Post recycled successfully",
|
||||
**result
|
||||
}
|
||||
|
||||
|
||||
@router.post("/auto")
|
||||
async def auto_recycle_posts(
|
||||
platform: Optional[str] = None,
|
||||
count: int = Query(1, ge=1, le=5),
|
||||
min_engagement_rate: float = Query(2.0, ge=0),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Automatically recycle top-performing posts.
|
||||
|
||||
- **platform**: Filter by platform
|
||||
- **count**: Number of posts to recycle (max 5)
|
||||
- **min_engagement_rate**: Minimum engagement rate threshold
|
||||
"""
|
||||
result = await recycling_service.auto_recycle(
|
||||
platform=platform,
|
||||
count=count,
|
||||
min_engagement_rate=min_engagement_rate
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_recycling_history(
|
||||
original_post_id: Optional[int] = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get recycling history.
|
||||
|
||||
- **original_post_id**: Filter by original post
|
||||
"""
|
||||
history = await recycling_service.get_recycling_history(
|
||||
original_post_id=original_post_id,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return {
|
||||
"history": history,
|
||||
"count": len(history)
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{post_id}/disable")
|
||||
async def disable_recycling(
|
||||
post_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Mark a post as not eligible for recycling.
|
||||
"""
|
||||
result = await recycling_service.mark_post_not_recyclable(post_id)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=result.get("error", "Failed to disable recycling")
|
||||
)
|
||||
|
||||
return result
|
||||
Reference in New Issue
Block a user