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

160
app/models/thread_series.py Normal file
View File

@@ -0,0 +1,160 @@
"""
Thread Series Models - Multi-post thread content scheduling.
"""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Boolean
from sqlalchemy.orm import relationship
import enum
from app.core.database import Base
class ThreadSeriesStatus(enum.Enum):
"""Status options for thread series."""
DRAFT = "draft"
SCHEDULED = "scheduled"
PUBLISHING = "publishing"
COMPLETED = "completed"
PAUSED = "paused"
CANCELLED = "cancelled"
class ThreadSeries(Base):
"""
A series of related posts published as a thread.
E.g., Educational threads, story threads, tip series.
"""
__tablename__ = "thread_series"
id = Column(Integer, primary_key=True, index=True)
# Series info
name = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
topic = Column(String(100), nullable=True)
# Platform (threads work best on X and Threads)
platform = Column(String(50), nullable=False, index=True)
# Schedule configuration
schedule_type = Column(String(20), default="sequential")
# sequential: posts one after another
# timed: posts at specific intervals
interval_minutes = Column(Integer, default=5) # Time between posts
start_time = Column(DateTime, nullable=True) # When to start publishing
# Thread structure
total_posts = Column(Integer, default=0)
posts_published = Column(Integer, default=0)
# Status
status = Column(String(20), default="draft", index=True)
# First post in chain (for reply chain)
first_platform_post_id = Column(String(100), nullable=True)
# AI generation settings
ai_generated = Column(Boolean, default=False)
generation_prompt = Column(Text, nullable=True)
# Metadata
hashtags = Column(JSON, nullable=True) # Common hashtags for the series
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
completed_at = Column(DateTime, nullable=True)
# Relationships
posts = relationship("ThreadPost", back_populates="series", order_by="ThreadPost.sequence_number")
def __repr__(self):
return f"<ThreadSeries {self.id} - {self.name}>"
def to_dict(self, include_posts: bool = True):
"""Convert to dictionary."""
result = {
"id": self.id,
"name": self.name,
"description": self.description,
"topic": self.topic,
"platform": self.platform,
"schedule_type": self.schedule_type,
"interval_minutes": self.interval_minutes,
"start_time": self.start_time.isoformat() if self.start_time else None,
"total_posts": self.total_posts,
"posts_published": self.posts_published,
"status": self.status,
"ai_generated": self.ai_generated,
"hashtags": self.hashtags,
"created_at": self.created_at.isoformat() if self.created_at else None,
"completed_at": self.completed_at.isoformat() if self.completed_at else None
}
if include_posts and self.posts:
result["posts"] = [p.to_dict() for p in self.posts]
return result
class ThreadPost(Base):
"""
Individual post within a thread series.
"""
__tablename__ = "thread_posts"
id = Column(Integer, primary_key=True, index=True)
# Parent series
series_id = Column(Integer, ForeignKey("thread_series.id", ondelete="CASCADE"), nullable=False)
# Position in thread
sequence_number = Column(Integer, nullable=False) # 1, 2, 3, ...
# Content
content = Column(Text, nullable=False)
image_url = Column(String(500), nullable=True)
# Associated post (once created)
post_id = Column(Integer, ForeignKey("posts.id"), nullable=True)
# Platform post ID (for reply chain)
platform_post_id = Column(String(100), nullable=True)
reply_to_platform_id = Column(String(100), nullable=True) # ID of post to reply to
# Schedule
scheduled_at = Column(DateTime, nullable=True)
# Status
status = Column(String(20), default="pending")
# pending, scheduled, published, failed
error_message = Column(Text, nullable=True)
published_at = Column(DateTime, nullable=True)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
series = relationship("ThreadSeries", back_populates="posts")
post = relationship("Post", backref="thread_post")
def __repr__(self):
return f"<ThreadPost {self.id} - Series {self.series_id} #{self.sequence_number}>"
def to_dict(self):
"""Convert to dictionary."""
return {
"id": self.id,
"series_id": self.series_id,
"sequence_number": self.sequence_number,
"content": self.content[:100] + "..." if len(self.content) > 100 else self.content,
"full_content": self.content,
"image_url": self.image_url,
"post_id": self.post_id,
"platform_post_id": self.platform_post_id,
"scheduled_at": self.scheduled_at.isoformat() if self.scheduled_at else None,
"status": self.status,
"error_message": self.error_message,
"published_at": self.published_at.isoformat() if self.published_at else None
}