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:
216
app/api/routes/ab_testing.py
Normal file
216
app/api/routes/ab_testing.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
API Routes for A/B Testing.
|
||||
"""
|
||||
|
||||
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.ab_testing_service import ab_testing_service
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class VariantCreate(BaseModel):
|
||||
"""Schema for creating a test variant."""
|
||||
name: str # A, B, C, etc.
|
||||
content: str
|
||||
hashtags: Optional[List[str]] = None
|
||||
image_url: Optional[str] = None
|
||||
|
||||
|
||||
class ABTestCreate(BaseModel):
|
||||
"""Schema for creating an A/B test."""
|
||||
name: str
|
||||
platform: str
|
||||
variants: List[VariantCreate]
|
||||
test_type: str = "content"
|
||||
duration_hours: int = 24
|
||||
min_sample_size: int = 100
|
||||
success_metric: str = "engagement_rate"
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_tests(
|
||||
status: Optional[str] = None,
|
||||
platform: Optional[str] = None,
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List all A/B tests.
|
||||
|
||||
- **status**: Filter by status (draft, running, completed, cancelled)
|
||||
- **platform**: Filter by platform
|
||||
"""
|
||||
tests = await ab_testing_service.get_tests(
|
||||
status=status,
|
||||
platform=platform,
|
||||
limit=limit
|
||||
)
|
||||
return {"tests": tests, "count": len(tests)}
|
||||
|
||||
|
||||
@router.get("/{test_id}")
|
||||
async def get_test(
|
||||
test_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get a specific A/B test with its variants.
|
||||
"""
|
||||
test = await ab_testing_service.get_test(test_id)
|
||||
if not test:
|
||||
raise HTTPException(status_code=404, detail="Test not found")
|
||||
return test
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_test(
|
||||
test_data: ABTestCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new A/B test.
|
||||
|
||||
Requires at least 2 variants. Variants should have:
|
||||
- **name**: Identifier (A, B, C, etc.)
|
||||
- **content**: The content to test
|
||||
- **hashtags**: Optional hashtag list
|
||||
- **image_url**: Optional image URL
|
||||
"""
|
||||
if len(test_data.variants) < 2:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="A/B test requires at least 2 variants"
|
||||
)
|
||||
|
||||
if len(test_data.variants) > 4:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Maximum 4 variants allowed per test"
|
||||
)
|
||||
|
||||
try:
|
||||
test = await ab_testing_service.create_test(
|
||||
name=test_data.name,
|
||||
platform=test_data.platform,
|
||||
variants=[v.dict() for v in test_data.variants],
|
||||
test_type=test_data.test_type,
|
||||
duration_hours=test_data.duration_hours,
|
||||
min_sample_size=test_data.min_sample_size,
|
||||
success_metric=test_data.success_metric,
|
||||
description=test_data.description
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "A/B test created successfully",
|
||||
"test": test.to_dict()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/{test_id}/start")
|
||||
async def start_test(
|
||||
test_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Start an A/B test.
|
||||
|
||||
This will create and schedule posts for each variant.
|
||||
"""
|
||||
result = await ab_testing_service.start_test(test_id)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=result.get("error", "Failed to start test")
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "A/B test started successfully",
|
||||
**result
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{test_id}/evaluate")
|
||||
async def evaluate_test(
|
||||
test_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Evaluate A/B test results and determine winner.
|
||||
|
||||
Updates metrics from posts and calculates statistical significance.
|
||||
"""
|
||||
result = await ab_testing_service.evaluate_test(test_id)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=result.get("error", "Failed to evaluate test")
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{test_id}/results")
|
||||
async def get_test_results(
|
||||
test_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get current results for an A/B test.
|
||||
"""
|
||||
# First update metrics
|
||||
await ab_testing_service.update_variant_metrics(test_id)
|
||||
|
||||
test = await ab_testing_service.get_test(test_id)
|
||||
if not test:
|
||||
raise HTTPException(status_code=404, detail="Test not found")
|
||||
|
||||
# Calculate current standings
|
||||
variants = test.get("variants", [])
|
||||
if variants:
|
||||
sorted_variants = sorted(
|
||||
variants,
|
||||
key=lambda v: v.get("engagement_rate", 0),
|
||||
reverse=True
|
||||
)
|
||||
else:
|
||||
sorted_variants = []
|
||||
|
||||
return {
|
||||
"test_id": test_id,
|
||||
"status": test.get("status"),
|
||||
"started_at": test.get("started_at"),
|
||||
"winning_variant_id": test.get("winning_variant_id"),
|
||||
"confidence_level": test.get("confidence_level"),
|
||||
"variants": sorted_variants
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{test_id}/cancel")
|
||||
async def cancel_test(
|
||||
test_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Cancel a running A/B test.
|
||||
"""
|
||||
result = await ab_testing_service.cancel_test(test_id)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=result.get("error", "Failed to cancel test")
|
||||
)
|
||||
|
||||
return {"message": "Test cancelled successfully"}
|
||||
257
app/api/routes/analytics.py
Normal file
257
app/api/routes/analytics.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
API Routes for Analytics.
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import 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.analytics_service import analytics_service
|
||||
from app.services.notifications import notification_service
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DashboardResponse(BaseModel):
|
||||
period_days: int
|
||||
total_posts: int
|
||||
total_impressions: int
|
||||
total_engagements: int
|
||||
total_likes: int
|
||||
total_comments: int
|
||||
total_shares: int
|
||||
avg_engagement_rate: float
|
||||
platform_breakdown: dict
|
||||
content_breakdown: dict
|
||||
pending_interactions: int
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def get_analytics_dashboard(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
platform: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get analytics dashboard data.
|
||||
|
||||
- **days**: Number of days to analyze (default 30)
|
||||
- **platform**: Filter by platform (optional)
|
||||
"""
|
||||
stats = await analytics_service.get_dashboard_stats(days=days, platform=platform)
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/top-posts")
|
||||
async def get_top_posts(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
platform: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get top performing posts by engagement rate.
|
||||
|
||||
- **days**: Number of days to analyze
|
||||
- **limit**: Maximum number of posts to return
|
||||
- **platform**: Filter by platform (optional)
|
||||
"""
|
||||
posts = await analytics_service.get_top_posts(
|
||||
days=days,
|
||||
limit=limit,
|
||||
platform=platform
|
||||
)
|
||||
return {"posts": posts, "count": len(posts)}
|
||||
|
||||
|
||||
@router.get("/optimal-times")
|
||||
async def get_optimal_times(
|
||||
platform: Optional[str] = None,
|
||||
days: int = Query(90, ge=30, le=365),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get optimal posting times based on historical performance.
|
||||
|
||||
- **platform**: Filter by platform (optional)
|
||||
- **days**: Days of historical data to analyze
|
||||
"""
|
||||
times = await analytics_service.get_optimal_times(
|
||||
platform=platform,
|
||||
days=days
|
||||
)
|
||||
return {
|
||||
"optimal_times": times[:20],
|
||||
"analysis_period_days": days,
|
||||
"platform": platform
|
||||
}
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
async def get_reports(
|
||||
report_type: str = Query("weekly", regex="^(daily|weekly|monthly)$"),
|
||||
limit: int = Query(10, ge=1, le=52),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get historical analytics reports.
|
||||
|
||||
- **report_type**: Type of report (daily, weekly, monthly)
|
||||
- **limit**: Maximum number of reports to return
|
||||
"""
|
||||
reports = await analytics_service.get_reports(
|
||||
report_type=report_type,
|
||||
limit=limit
|
||||
)
|
||||
return {"reports": reports, "count": len(reports)}
|
||||
|
||||
|
||||
@router.post("/reports/generate")
|
||||
async def generate_report(
|
||||
report_type: str = Query("weekly", regex="^(daily|weekly|monthly)$"),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Generate a new analytics report.
|
||||
|
||||
- **report_type**: Type of report to generate
|
||||
"""
|
||||
if report_type == "weekly":
|
||||
report = await analytics_service.generate_weekly_report()
|
||||
return {
|
||||
"message": "Reporte generado exitosamente",
|
||||
"report": report.to_dict()
|
||||
}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Report type '{report_type}' not implemented yet"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/reports/send-telegram")
|
||||
async def send_report_telegram(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Generate and send weekly report via Telegram.
|
||||
"""
|
||||
try:
|
||||
# Generate report
|
||||
report = await analytics_service.generate_weekly_report()
|
||||
|
||||
# Send via Telegram
|
||||
if report.summary_text:
|
||||
success = await notification_service.notify_daily_summary({
|
||||
"custom_message": report.summary_text
|
||||
})
|
||||
|
||||
if success:
|
||||
return {
|
||||
"message": "Reporte enviado a Telegram",
|
||||
"report_id": report.id
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"message": "Reporte generado pero no se pudo enviar a Telegram",
|
||||
"report_id": report.id
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"message": "Reporte generado sin resumen",
|
||||
"report_id": report.id
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error generando reporte: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/posts/{post_id}/metrics")
|
||||
async def get_post_metrics(
|
||||
post_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get detailed metrics for a specific post.
|
||||
"""
|
||||
from app.models.post import Post
|
||||
from app.models.post_metrics import PostMetrics
|
||||
|
||||
post = db.query(Post).filter(Post.id == post_id).first()
|
||||
if not post:
|
||||
raise HTTPException(status_code=404, detail="Post not found")
|
||||
|
||||
# Get metrics history
|
||||
metrics_history = db.query(PostMetrics).filter(
|
||||
PostMetrics.post_id == post_id
|
||||
).order_by(PostMetrics.recorded_at.desc()).limit(100).all()
|
||||
|
||||
return {
|
||||
"post_id": post_id,
|
||||
"current_metrics": post.metrics,
|
||||
"published_at": post.published_at.isoformat() if post.published_at else None,
|
||||
"platforms": post.platforms,
|
||||
"metrics_history": [m.to_dict() for m in metrics_history]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/engagement-trend")
|
||||
async def get_engagement_trend(
|
||||
days: int = Query(30, ge=7, le=365),
|
||||
platform: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get engagement trend over time for charting.
|
||||
"""
|
||||
from app.models.post import Post
|
||||
from datetime import timedelta
|
||||
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
posts_query = db.query(Post).filter(
|
||||
Post.published_at >= start_date,
|
||||
Post.status == "published"
|
||||
)
|
||||
|
||||
if platform:
|
||||
posts_query = posts_query.filter(Post.platforms.contains([platform]))
|
||||
|
||||
posts = posts_query.order_by(Post.published_at).all()
|
||||
|
||||
# Group by day
|
||||
daily_data = {}
|
||||
for post in posts:
|
||||
if post.published_at:
|
||||
day_key = post.published_at.strftime("%Y-%m-%d")
|
||||
if day_key not in daily_data:
|
||||
daily_data[day_key] = {
|
||||
"date": day_key,
|
||||
"posts": 0,
|
||||
"impressions": 0,
|
||||
"engagements": 0
|
||||
}
|
||||
daily_data[day_key]["posts"] += 1
|
||||
if post.metrics:
|
||||
daily_data[day_key]["impressions"] += post.metrics.get("impressions", 0)
|
||||
daily_data[day_key]["engagements"] += (
|
||||
post.metrics.get("likes", 0) +
|
||||
post.metrics.get("comments", 0) +
|
||||
post.metrics.get("shares", 0)
|
||||
)
|
||||
|
||||
# Sort by date
|
||||
trend = sorted(daily_data.values(), key=lambda x: x["date"])
|
||||
|
||||
return {
|
||||
"trend": trend,
|
||||
"period_days": days,
|
||||
"platform": platform
|
||||
}
|
||||
@@ -180,3 +180,29 @@ async def dashboard_settings(request: Request, db: Session = Depends(get_db)):
|
||||
"request": request,
|
||||
"user": user.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@router.get("/analytics", response_class=HTMLResponse)
|
||||
async def dashboard_analytics(request: Request, db: Session = Depends(get_db)):
|
||||
"""Página de analytics."""
|
||||
user = require_auth(request, db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
return templates.TemplateResponse("analytics.html", {
|
||||
"request": request,
|
||||
"user": user.to_dict()
|
||||
})
|
||||
|
||||
|
||||
@router.get("/leads", response_class=HTMLResponse)
|
||||
async def dashboard_leads(request: Request, db: Session = Depends(get_db)):
|
||||
"""Página de leads."""
|
||||
user = require_auth(request, db)
|
||||
if not user:
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
return templates.TemplateResponse("leads.html", {
|
||||
"request": request,
|
||||
"user": user.to_dict()
|
||||
})
|
||||
|
||||
271
app/api/routes/image_templates.py
Normal file
271
app/api/routes/image_templates.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
API Routes for Image Templates.
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
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.models.image_template import ImageTemplate
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class TemplateCreate(BaseModel):
|
||||
"""Schema for creating an image template."""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
category: str
|
||||
template_type: str = "general"
|
||||
html_template: Optional[str] = None
|
||||
template_file: Optional[str] = None
|
||||
variables: list
|
||||
design_config: Optional[Dict[str, Any]] = None
|
||||
output_sizes: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
"""Schema for updating a template."""
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
template_type: Optional[str] = None
|
||||
html_template: Optional[str] = None
|
||||
variables: Optional[list] = None
|
||||
design_config: Optional[Dict[str, Any]] = None
|
||||
output_sizes: Optional[Dict[str, Any]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class PreviewRequest(BaseModel):
|
||||
"""Schema for previewing a template."""
|
||||
template_id: Optional[int] = None
|
||||
html_template: Optional[str] = None
|
||||
variables: Dict[str, str]
|
||||
output_size: Optional[Dict[str, int]] = None
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_templates(
|
||||
category: Optional[str] = None,
|
||||
template_type: Optional[str] = None,
|
||||
active_only: bool = True,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List all image templates.
|
||||
|
||||
- **category**: Filter by category (tip, producto, servicio, etc.)
|
||||
- **template_type**: Filter by type (tip_card, product_card, quote, etc.)
|
||||
- **active_only**: Only show active templates
|
||||
"""
|
||||
query = db.query(ImageTemplate)
|
||||
|
||||
if category:
|
||||
query = query.filter(ImageTemplate.category == category)
|
||||
if template_type:
|
||||
query = query.filter(ImageTemplate.template_type == template_type)
|
||||
if active_only:
|
||||
query = query.filter(ImageTemplate.is_active == True)
|
||||
|
||||
templates = query.order_by(ImageTemplate.name).limit(limit).all()
|
||||
|
||||
return {
|
||||
"templates": [t.to_dict() for t in templates],
|
||||
"count": len(templates)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{template_id}")
|
||||
async def get_template(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get a specific template with full details.
|
||||
"""
|
||||
template = db.query(ImageTemplate).filter(ImageTemplate.id == template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
result = template.to_dict()
|
||||
# Include full HTML template
|
||||
if template.html_template:
|
||||
result["full_html_template"] = template.html_template
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_template(
|
||||
template_data: TemplateCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new image template.
|
||||
|
||||
Templates can be defined with:
|
||||
- **html_template**: Inline HTML/CSS template
|
||||
- **template_file**: Path to a template file
|
||||
|
||||
Variables are placeholders like: ["title", "content", "accent_color"]
|
||||
"""
|
||||
if not template_data.html_template and not template_data.template_file:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either html_template or template_file is required"
|
||||
)
|
||||
|
||||
template = ImageTemplate(
|
||||
name=template_data.name,
|
||||
description=template_data.description,
|
||||
category=template_data.category,
|
||||
template_type=template_data.template_type,
|
||||
html_template=template_data.html_template,
|
||||
template_file=template_data.template_file,
|
||||
variables=template_data.variables,
|
||||
design_config=template_data.design_config,
|
||||
output_sizes=template_data.output_sizes,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
db.add(template)
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
return {
|
||||
"message": "Template created successfully",
|
||||
"template": template.to_dict()
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{template_id}")
|
||||
async def update_template(
|
||||
template_id: int,
|
||||
template_data: TemplateUpdate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update an existing template.
|
||||
"""
|
||||
template = db.query(ImageTemplate).filter(ImageTemplate.id == template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
update_data = template_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if value is not None:
|
||||
setattr(template, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
|
||||
return {
|
||||
"message": "Template updated successfully",
|
||||
"template": template.to_dict()
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{template_id}")
|
||||
async def delete_template(
|
||||
template_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete a template.
|
||||
"""
|
||||
template = db.query(ImageTemplate).filter(ImageTemplate.id == template_id).first()
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
db.delete(template)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Template deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/preview")
|
||||
async def preview_template(
|
||||
preview_data: PreviewRequest,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Generate a preview of a template with variables.
|
||||
|
||||
You can either provide:
|
||||
- **template_id**: To use an existing template
|
||||
- **html_template**: To preview custom HTML
|
||||
|
||||
The preview returns the rendered HTML (image generation requires separate processing).
|
||||
"""
|
||||
html_template = preview_data.html_template
|
||||
|
||||
if preview_data.template_id:
|
||||
template = db.query(ImageTemplate).filter(
|
||||
ImageTemplate.id == preview_data.template_id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
raise HTTPException(status_code=404, detail="Template not found")
|
||||
|
||||
html_template = template.html_template
|
||||
|
||||
if not html_template:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="No HTML template available"
|
||||
)
|
||||
|
||||
# Simple variable substitution
|
||||
rendered_html = html_template
|
||||
for var_name, var_value in preview_data.variables.items():
|
||||
rendered_html = rendered_html.replace(f"{{{{{var_name}}}}}", str(var_value))
|
||||
|
||||
# Get output size
|
||||
output_size = preview_data.output_size or {"width": 1080, "height": 1080}
|
||||
|
||||
return {
|
||||
"rendered_html": rendered_html,
|
||||
"output_size": output_size,
|
||||
"variables_used": list(preview_data.variables.keys())
|
||||
}
|
||||
|
||||
|
||||
@router.get("/categories/list")
|
||||
async def list_categories(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get list of available template categories.
|
||||
"""
|
||||
from sqlalchemy import distinct
|
||||
|
||||
categories = db.query(distinct(ImageTemplate.category)).filter(
|
||||
ImageTemplate.is_active == True
|
||||
).all()
|
||||
|
||||
return {
|
||||
"categories": [c[0] for c in categories if c[0]]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/types/list")
|
||||
async def list_template_types(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get list of available template types.
|
||||
"""
|
||||
from sqlalchemy import distinct
|
||||
|
||||
types = db.query(distinct(ImageTemplate.template_type)).filter(
|
||||
ImageTemplate.is_active == True
|
||||
).all()
|
||||
|
||||
return {
|
||||
"types": [t[0] for t in types if t[0]]
|
||||
}
|
||||
317
app/api/routes/leads.py
Normal file
317
app/api/routes/leads.py
Normal file
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
API Routes for Leads Management.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
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.models.lead import Lead
|
||||
from app.models.interaction import Interaction
|
||||
from app.services.odoo_service import odoo_service
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class LeadCreate(BaseModel):
|
||||
"""Schema for creating a lead."""
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
company: Optional[str] = None
|
||||
platform: str
|
||||
username: Optional[str] = None
|
||||
profile_url: Optional[str] = None
|
||||
interest: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
priority: str = "medium"
|
||||
products_interested: Optional[List[int]] = None
|
||||
services_interested: Optional[List[int]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
class LeadUpdate(BaseModel):
|
||||
"""Schema for updating a lead."""
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
company: Optional[str] = None
|
||||
interest: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
assigned_to: Optional[str] = None
|
||||
products_interested: Optional[List[int]] = None
|
||||
services_interested: Optional[List[int]] = None
|
||||
tags: Optional[List[str]] = None
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def list_leads(
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
platform: Optional[str] = None,
|
||||
synced: Optional[bool] = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
List all leads with optional filters.
|
||||
|
||||
- **status**: Filter by status (new, contacted, qualified, proposal, won, lost)
|
||||
- **priority**: Filter by priority (low, medium, high, urgent)
|
||||
- **platform**: Filter by source platform
|
||||
- **synced**: Filter by Odoo sync status
|
||||
"""
|
||||
query = db.query(Lead)
|
||||
|
||||
if status:
|
||||
query = query.filter(Lead.status == status)
|
||||
if priority:
|
||||
query = query.filter(Lead.priority == priority)
|
||||
if platform:
|
||||
query = query.filter(Lead.platform == platform)
|
||||
if synced is not None:
|
||||
query = query.filter(Lead.synced_to_odoo == synced)
|
||||
|
||||
total = query.count()
|
||||
leads = query.order_by(Lead.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
return {
|
||||
"leads": [lead.to_dict() for lead in leads],
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{lead_id}")
|
||||
async def get_lead(
|
||||
lead_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get a specific lead by ID.
|
||||
"""
|
||||
lead = db.query(Lead).filter(Lead.id == lead_id).first()
|
||||
if not lead:
|
||||
raise HTTPException(status_code=404, detail="Lead not found")
|
||||
|
||||
return lead.to_dict()
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_lead(
|
||||
lead_data: LeadCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a new lead manually.
|
||||
"""
|
||||
lead = Lead(
|
||||
name=lead_data.name,
|
||||
email=lead_data.email,
|
||||
phone=lead_data.phone,
|
||||
company=lead_data.company,
|
||||
platform=lead_data.platform,
|
||||
username=lead_data.username,
|
||||
profile_url=lead_data.profile_url,
|
||||
interest=lead_data.interest,
|
||||
notes=lead_data.notes,
|
||||
priority=lead_data.priority,
|
||||
products_interested=lead_data.products_interested,
|
||||
services_interested=lead_data.services_interested,
|
||||
tags=lead_data.tags,
|
||||
status="new"
|
||||
)
|
||||
|
||||
db.add(lead)
|
||||
db.commit()
|
||||
db.refresh(lead)
|
||||
|
||||
return {
|
||||
"message": "Lead created successfully",
|
||||
"lead": lead.to_dict()
|
||||
}
|
||||
|
||||
|
||||
@router.post("/from-interaction/{interaction_id}")
|
||||
async def create_lead_from_interaction(
|
||||
interaction_id: int,
|
||||
interest: Optional[str] = None,
|
||||
priority: str = "medium",
|
||||
notes: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Create a lead from an existing interaction.
|
||||
|
||||
- **interaction_id**: ID of the interaction to convert
|
||||
- **interest**: What the lead is interested in
|
||||
- **priority**: Lead priority (low, medium, high, urgent)
|
||||
- **notes**: Additional notes
|
||||
"""
|
||||
# Get interaction
|
||||
interaction = db.query(Interaction).filter(Interaction.id == interaction_id).first()
|
||||
if not interaction:
|
||||
raise HTTPException(status_code=404, detail="Interaction not found")
|
||||
|
||||
# Check if lead already exists for this interaction
|
||||
existing = db.query(Lead).filter(Lead.interaction_id == interaction_id).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Lead already exists for this interaction"
|
||||
)
|
||||
|
||||
# Create lead
|
||||
lead = Lead(
|
||||
interaction_id=interaction_id,
|
||||
platform=interaction.platform,
|
||||
username=interaction.author_username,
|
||||
profile_url=interaction.author_profile_url,
|
||||
source_content=interaction.content,
|
||||
interest=interest or f"Interest shown in post {interaction.post_id}",
|
||||
notes=notes,
|
||||
priority=priority,
|
||||
status="new"
|
||||
)
|
||||
|
||||
# Mark interaction as potential lead
|
||||
interaction.is_potential_lead = True
|
||||
|
||||
db.add(lead)
|
||||
db.commit()
|
||||
db.refresh(lead)
|
||||
|
||||
return {
|
||||
"message": "Lead created from interaction",
|
||||
"lead": lead.to_dict()
|
||||
}
|
||||
|
||||
|
||||
@router.put("/{lead_id}")
|
||||
async def update_lead(
|
||||
lead_id: int,
|
||||
lead_data: LeadUpdate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Update an existing lead.
|
||||
"""
|
||||
lead = db.query(Lead).filter(Lead.id == lead_id).first()
|
||||
if not lead:
|
||||
raise HTTPException(status_code=404, detail="Lead not found")
|
||||
|
||||
# Update only provided fields
|
||||
update_data = lead_data.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
if value is not None:
|
||||
setattr(lead, field, value)
|
||||
|
||||
lead.updated_at = datetime.utcnow()
|
||||
|
||||
# If status changed to contacted, update last_contacted_at
|
||||
if lead_data.status == "contacted":
|
||||
lead.last_contacted_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(lead)
|
||||
|
||||
return {
|
||||
"message": "Lead updated successfully",
|
||||
"lead": lead.to_dict()
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{lead_id}")
|
||||
async def delete_lead(
|
||||
lead_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Delete a lead.
|
||||
"""
|
||||
lead = db.query(Lead).filter(Lead.id == lead_id).first()
|
||||
if not lead:
|
||||
raise HTTPException(status_code=404, detail="Lead not found")
|
||||
|
||||
db.delete(lead)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Lead deleted successfully"}
|
||||
|
||||
|
||||
@router.post("/{lead_id}/sync-odoo")
|
||||
async def sync_lead_to_odoo(
|
||||
lead_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Sync a specific lead to Odoo CRM.
|
||||
"""
|
||||
lead = db.query(Lead).filter(Lead.id == lead_id).first()
|
||||
if not lead:
|
||||
raise HTTPException(status_code=404, detail="Lead not found")
|
||||
|
||||
if lead.synced_to_odoo:
|
||||
return {
|
||||
"message": "Lead already synced to Odoo",
|
||||
"odoo_lead_id": lead.odoo_lead_id
|
||||
}
|
||||
|
||||
result = await odoo_service.create_lead(lead)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=result.get("error", "Sync failed")
|
||||
)
|
||||
|
||||
lead.odoo_lead_id = result["odoo_lead_id"]
|
||||
lead.synced_to_odoo = True
|
||||
lead.odoo_synced_at = datetime.utcnow()
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Lead synced to Odoo successfully",
|
||||
"odoo_lead_id": result["odoo_lead_id"]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def get_leads_summary(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get leads summary statistics.
|
||||
"""
|
||||
from sqlalchemy import func
|
||||
|
||||
total = db.query(Lead).count()
|
||||
by_status = db.query(
|
||||
Lead.status, func.count(Lead.id)
|
||||
).group_by(Lead.status).all()
|
||||
|
||||
by_platform = db.query(
|
||||
Lead.platform, func.count(Lead.id)
|
||||
).group_by(Lead.platform).all()
|
||||
|
||||
by_priority = db.query(
|
||||
Lead.priority, func.count(Lead.id)
|
||||
).group_by(Lead.priority).all()
|
||||
|
||||
unsynced = db.query(Lead).filter(Lead.synced_to_odoo == False).count()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"by_status": {status: count for status, count in by_status},
|
||||
"by_platform": {platform: count for platform, count in by_platform},
|
||||
"by_priority": {priority: count for priority, count in by_priority},
|
||||
"unsynced_to_odoo": unsynced
|
||||
}
|
||||
123
app/api/routes/odoo.py
Normal file
123
app/api/routes/odoo.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
API Routes for Odoo Integration.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.services.odoo_service import odoo_service
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_odoo_status(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get Odoo connection status.
|
||||
"""
|
||||
status = await odoo_service.test_connection()
|
||||
return status
|
||||
|
||||
|
||||
@router.post("/sync/products")
|
||||
async def sync_products(
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Sync products from Odoo to local database.
|
||||
|
||||
- **limit**: Maximum number of products to sync
|
||||
"""
|
||||
result = await odoo_service.sync_products(limit=limit)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=result.get("error", "Sync failed")
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Products synced successfully",
|
||||
**result
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sync/services")
|
||||
async def sync_services(
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Sync services from Odoo to local database.
|
||||
|
||||
- **limit**: Maximum number of services to sync
|
||||
"""
|
||||
result = await odoo_service.sync_services(limit=limit)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=result.get("error", "Sync failed")
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Services synced successfully",
|
||||
**result
|
||||
}
|
||||
|
||||
|
||||
@router.post("/sync/leads")
|
||||
async def export_leads(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Export unsynced leads to Odoo CRM.
|
||||
"""
|
||||
result = await odoo_service.export_leads_to_odoo()
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=result.get("error", "Export failed")
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Leads exported successfully",
|
||||
**result
|
||||
}
|
||||
|
||||
|
||||
@router.get("/sync/logs")
|
||||
async def get_sync_logs(
|
||||
limit: int = 20,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get Odoo sync history.
|
||||
|
||||
- **limit**: Maximum number of logs to return
|
||||
"""
|
||||
logs = await odoo_service.get_sync_logs(limit=limit)
|
||||
return {"logs": logs, "count": len(logs)}
|
||||
|
||||
|
||||
@router.get("/sales")
|
||||
async def get_sales_summary(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get sales summary from Odoo.
|
||||
|
||||
- **days**: Number of days to look back
|
||||
"""
|
||||
result = await odoo_service.get_sales_summary(days=days)
|
||||
|
||||
if not result.get("success"):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=result.get("error", "Failed to get sales data")
|
||||
)
|
||||
|
||||
return result
|
||||
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
|
||||
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