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

View 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
View 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
}

View File

@@ -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()
})

View 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
View 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
View 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
View 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
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"}