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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user