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
|
||||
}
|
||||
|
||||
143
app/api/routes/notifications.py
Normal file
143
app/api/routes/notifications.py
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user