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>
161 lines
5.3 KiB
Python
161 lines
5.3 KiB
Python
"""
|
|
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
|
|
}
|