feat(phase-4): Complete scheduling and automation system

- Add Celery worker with 5 scheduled tasks (Beat)
- Create ContentScheduler for optimal posting times
- Add calendar endpoints for scheduled posts management
- Implement Telegram notification service
- Add notification API with setup guide

Celery Beat Schedule:
- check_scheduled_posts: Every minute
- generate_daily_content: Daily at 6 AM
- sync_interactions: Every 15 minutes
- send_daily_summary: Daily at 9 PM
- cleanup_old_data: Weekly on Sundays

New endpoints:
- GET /api/calendar/posts/scheduled - List scheduled posts
- GET /api/calendar/posts/view - Calendar view
- GET /api/calendar/posts/slots - Available time slots
- POST /api/calendar/posts/{id}/schedule - Schedule post
- POST /api/calendar/posts/{id}/publish-now - Publish immediately
- GET /api/notifications/status - Check notification config
- POST /api/notifications/test - Send test notification
- GET /api/notifications/setup-guide - Configuration guide

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-28 01:56:10 +00:00
parent 964e38564a
commit edc0e5577b
8 changed files with 1558 additions and 2 deletions

View File

@@ -2,7 +2,7 @@
API Routes para gestión del Calendario de Contenido.
"""
from datetime import datetime, time
from datetime import datetime, time, timedelta
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
@@ -199,3 +199,227 @@ async def toggle_calendar_entry(entry_id: int, db: Session = Depends(get_db)):
"message": f"Entrada {'activada' if entry.is_active else 'desactivada'}",
"is_active": entry.is_active
}
# ===========================================
# SCHEDULED POSTS ENDPOINTS
# ===========================================
@router.get("/posts/scheduled")
async def get_scheduled_posts(
start_date: Optional[str] = Query(None, description="YYYY-MM-DD"),
end_date: Optional[str] = Query(None, description="YYYY-MM-DD"),
platform: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""
Obtener posts programados en un rango de fechas.
Por defecto muestra los próximos 7 días.
"""
from app.models.post import Post
# Parse dates
if start_date:
start = datetime.strptime(start_date, "%Y-%m-%d")
else:
start = datetime.utcnow()
if end_date:
end = datetime.strptime(end_date, "%Y-%m-%d").replace(hour=23, minute=59)
else:
end = start + timedelta(days=7)
query = db.query(Post).filter(
Post.scheduled_at >= start,
Post.scheduled_at <= end,
Post.status.in_(["scheduled", "pending_approval"])
)
if platform:
query = query.filter(Post.platforms.contains([platform]))
posts = query.order_by(Post.scheduled_at).all()
return [p.to_dict() for p in posts]
@router.get("/posts/view")
async def get_calendar_view(
start_date: Optional[str] = Query(None, description="YYYY-MM-DD"),
days: int = Query(7, ge=1, le=30),
platforms: Optional[str] = Query(None, description="Comma-separated"),
db: Session = Depends(get_db)
):
"""
Obtener vista de calendario con posts agrupados por fecha.
"""
from app.services.scheduler import content_scheduler
if start_date:
start = datetime.strptime(start_date, "%Y-%m-%d")
else:
start = datetime.utcnow()
end = start + timedelta(days=days)
platform_list = None
if platforms:
platform_list = [p.strip() for p in platforms.split(",")]
calendar = content_scheduler.get_calendar(start, end, platform_list)
return {
"start_date": start.strftime("%Y-%m-%d"),
"end_date": end.strftime("%Y-%m-%d"),
"days": days,
"calendar": calendar
}
@router.get("/posts/slots")
async def get_available_slots(
platform: str,
start_date: Optional[str] = Query(None),
days: int = Query(7, ge=1, le=14),
db: Session = Depends(get_db)
):
"""
Obtener slots disponibles para programar en una plataforma.
"""
from app.services.scheduler import content_scheduler
if start_date:
start = datetime.strptime(start_date, "%Y-%m-%d")
else:
start = datetime.utcnow()
slots = content_scheduler.get_available_slots(platform, start, days)
return {
"platform": platform,
"slots": [
{
"datetime": s.datetime.isoformat(),
"available": s.available
}
for s in slots
]
}
@router.post("/posts/{post_id}/schedule")
async def schedule_post(
post_id: int,
scheduled_at: Optional[str] = Query(None, description="ISO format datetime"),
auto: bool = Query(False, description="Auto-select optimal time"),
db: Session = Depends(get_db)
):
"""
Programar un post para publicación.
- Con `scheduled_at`: programa para esa fecha/hora específica
- Con `auto=true`: selecciona automáticamente el mejor horario
"""
from app.models.post import Post
from app.services.scheduler import content_scheduler
post = db.query(Post).filter(Post.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="Post no encontrado")
if post.status == "published":
raise HTTPException(status_code=400, detail="Post ya publicado")
if scheduled_at:
schedule_time = datetime.fromisoformat(scheduled_at.replace("Z", "+00:00"))
elif auto:
platform = post.platforms[0] if post.platforms else "x"
schedule_time = content_scheduler.get_next_available_slot(platform)
else:
raise HTTPException(
status_code=400,
detail="Proporciona scheduled_at o usa auto=true"
)
post.scheduled_at = schedule_time
post.status = "scheduled"
db.commit()
return {
"message": "Post programado",
"post_id": post_id,
"scheduled_at": schedule_time.isoformat()
}
@router.post("/posts/{post_id}/reschedule")
async def reschedule_post(
post_id: int,
scheduled_at: str = Query(..., description="Nueva fecha/hora ISO"),
db: Session = Depends(get_db)
):
"""Reprogramar un post a una nueva fecha/hora."""
from app.models.post import Post
post = db.query(Post).filter(Post.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="Post no encontrado")
if post.status == "published":
raise HTTPException(status_code=400, detail="No se puede reprogramar un post publicado")
new_time = datetime.fromisoformat(scheduled_at.replace("Z", "+00:00"))
post.scheduled_at = new_time
post.status = "scheduled"
db.commit()
return {
"message": "Post reprogramado",
"post_id": post_id,
"scheduled_at": new_time.isoformat()
}
@router.post("/posts/{post_id}/cancel")
async def cancel_scheduled_post(post_id: int, db: Session = Depends(get_db)):
"""Cancelar un post programado (vuelve a draft)."""
from app.models.post import Post
post = db.query(Post).filter(Post.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="Post no encontrado")
if post.status not in ["scheduled", "pending_approval"]:
raise HTTPException(
status_code=400,
detail=f"No se puede cancelar un post con status '{post.status}'"
)
post.status = "draft"
post.scheduled_at = None
db.commit()
return {"message": "Post cancelado", "post_id": post_id}
@router.post("/posts/{post_id}/publish-now")
async def publish_post_now(post_id: int, db: Session = Depends(get_db)):
"""Publicar un post inmediatamente (sin esperar al horario programado)."""
from app.models.post import Post
post = db.query(Post).filter(Post.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="Post no encontrado")
if post.status == "published":
raise HTTPException(status_code=400, detail="Post ya publicado")
# Queue for immediate publishing
from app.worker.tasks import publish_post
publish_post.delay(post_id)
return {
"message": "Post enviado a publicación",
"post_id": post_id
}