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
}

View File

@@ -0,0 +1,143 @@
"""
API Routes for notification management.
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional
from app.core.config import settings
from app.services.notifications import telegram_notify, notification_service
router = APIRouter()
class TestNotificationRequest(BaseModel):
"""Request for testing notifications."""
message: Optional[str] = "Test desde Social Media Automation"
class NotificationSettingsResponse(BaseModel):
"""Response with notification settings."""
telegram_configured: bool
bot_token_set: bool
chat_id_set: bool
@router.get("/status")
async def get_notification_status():
"""
Verificar estado de las notificaciones.
Muestra si Telegram está configurado correctamente.
"""
return NotificationSettingsResponse(
telegram_configured=notification_service.telegram_enabled,
bot_token_set=bool(settings.TELEGRAM_BOT_TOKEN),
chat_id_set=bool(settings.TELEGRAM_CHAT_ID)
)
@router.post("/test")
async def test_notification(request: TestNotificationRequest):
"""
Enviar notificación de prueba.
Útil para verificar que la configuración de Telegram funciona.
"""
if not notification_service.telegram_enabled:
raise HTTPException(
status_code=503,
detail="Telegram no configurado. Agrega TELEGRAM_BOT_TOKEN y TELEGRAM_CHAT_ID en .env"
)
message = f"🧪 *Test de Notificación*\n\n{request.message}\n\n✅ Si ves esto, las notificaciones funcionan correctamente."
success = await telegram_notify(message)
if success:
return {"success": True, "message": "Notificación enviada"}
else:
raise HTTPException(
status_code=500,
detail="Error al enviar notificación. Verifica las credenciales."
)
@router.post("/send")
async def send_custom_notification(
message: str,
parse_mode: str = "Markdown"
):
"""
Enviar notificación personalizada.
- **message**: Texto del mensaje (soporta Markdown)
- **parse_mode**: "Markdown" o "HTML"
"""
if not notification_service.telegram_enabled:
raise HTTPException(
status_code=503,
detail="Telegram no configurado"
)
success = await telegram_notify(message, parse_mode)
return {"success": success}
@router.get("/setup-guide")
async def get_setup_guide():
"""
Obtener guía de configuración de Telegram.
"""
return {
"title": "Configuración de Notificaciones Telegram",
"steps": [
{
"step": 1,
"title": "Crear Bot de Telegram",
"instructions": [
"Abre Telegram y busca @BotFather",
"Envía el comando /newbot",
"Sigue las instrucciones para nombrar tu bot",
"Guarda el token que te proporciona"
]
},
{
"step": 2,
"title": "Obtener Chat ID",
"instructions": [
"Inicia una conversación con tu nuevo bot",
"Envía cualquier mensaje",
"Visita: https://api.telegram.org/bot<TU_TOKEN>/getUpdates",
"Busca el campo 'chat': {'id': XXXXXXX}",
"Ese número es tu CHAT_ID"
]
},
{
"step": 3,
"title": "Configurar Variables",
"instructions": [
"Edita tu archivo .env",
"Agrega: TELEGRAM_BOT_TOKEN=tu_token_aqui",
"Agrega: TELEGRAM_CHAT_ID=tu_chat_id_aqui",
"Reinicia la aplicación"
]
},
{
"step": 4,
"title": "Verificar",
"instructions": [
"Usa el endpoint POST /api/notifications/test",
"Deberías recibir un mensaje en Telegram"
]
}
],
"current_status": {
"configured": notification_service.telegram_enabled,
"bot_token": "✓ Configurado" if settings.TELEGRAM_BOT_TOKEN else "✗ Falta",
"chat_id": "✓ Configurado" if settings.TELEGRAM_CHAT_ID else "✗ Falta"
}
}