Files
Consultoría AS edc0e5577b 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>
2026-01-28 01:56:10 +00:00

426 lines
13 KiB
Python

"""
API Routes para gestión del Calendario de Contenido.
"""
from datetime import datetime, time, timedelta
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.models.content_calendar import ContentCalendar
router = APIRouter()
# ===========================================
# SCHEMAS
# ===========================================
class CalendarEntryCreate(BaseModel):
day_of_week: int # 0=Lunes, 6=Domingo
time: str # Formato "HH:MM"
content_type: str
platforms: List[str]
requires_approval: bool = False
category_filter: Optional[str] = None
priority: int = 0
description: Optional[str] = None
class Config:
from_attributes = True
class CalendarEntryUpdate(BaseModel):
day_of_week: Optional[int] = None
time: Optional[str] = None
content_type: Optional[str] = None
platforms: Optional[List[str]] = None
is_active: Optional[bool] = None
requires_approval: Optional[bool] = None
category_filter: Optional[str] = None
priority: Optional[int] = None
description: Optional[str] = None
class Config:
from_attributes = True
# ===========================================
# ENDPOINTS
# ===========================================
@router.get("/")
async def list_calendar_entries(
day_of_week: Optional[int] = Query(None),
content_type: Optional[str] = Query(None),
platform: Optional[str] = Query(None),
is_active: Optional[bool] = Query(True),
db: Session = Depends(get_db)
):
"""Listar entradas del calendario."""
query = db.query(ContentCalendar)
if day_of_week is not None:
query = query.filter(ContentCalendar.day_of_week == day_of_week)
if content_type:
query = query.filter(ContentCalendar.content_type == content_type)
if is_active is not None:
query = query.filter(ContentCalendar.is_active == is_active)
if platform:
query = query.filter(ContentCalendar.platforms.contains([platform]))
entries = query.order_by(
ContentCalendar.day_of_week,
ContentCalendar.time
).all()
return [e.to_dict() for e in entries]
@router.get("/week")
async def get_week_schedule(db: Session = Depends(get_db)):
"""Obtener el calendario semanal completo."""
entries = db.query(ContentCalendar).filter(
ContentCalendar.is_active == True
).order_by(
ContentCalendar.day_of_week,
ContentCalendar.time
).all()
# Organizar por día
week = {i: [] for i in range(7)}
for entry in entries:
week[entry.day_of_week].append(entry.to_dict())
days = ["Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado", "Domingo"]
return {
days[i]: week[i] for i in range(7)
}
@router.get("/today")
async def get_today_schedule(db: Session = Depends(get_db)):
"""Obtener el calendario de hoy."""
today = datetime.now().weekday()
entries = db.query(ContentCalendar).filter(
ContentCalendar.day_of_week == today,
ContentCalendar.is_active == True
).order_by(ContentCalendar.time).all()
return [e.to_dict() for e in entries]
@router.get("/{entry_id}")
async def get_calendar_entry(entry_id: int, db: Session = Depends(get_db)):
"""Obtener una entrada del calendario por ID."""
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
if not entry:
raise HTTPException(status_code=404, detail="Entrada no encontrada")
return entry.to_dict()
@router.post("/")
async def create_calendar_entry(entry_data: CalendarEntryCreate, db: Session = Depends(get_db)):
"""Crear una nueva entrada en el calendario."""
# Parsear hora
hour, minute = map(int, entry_data.time.split(":"))
entry_time = time(hour=hour, minute=minute)
entry = ContentCalendar(
day_of_week=entry_data.day_of_week,
time=entry_time,
content_type=entry_data.content_type,
platforms=entry_data.platforms,
requires_approval=entry_data.requires_approval,
category_filter=entry_data.category_filter,
priority=entry_data.priority,
description=entry_data.description
)
db.add(entry)
db.commit()
db.refresh(entry)
return entry.to_dict()
@router.put("/{entry_id}")
async def update_calendar_entry(
entry_id: int,
entry_data: CalendarEntryUpdate,
db: Session = Depends(get_db)
):
"""Actualizar una entrada del calendario."""
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
if not entry:
raise HTTPException(status_code=404, detail="Entrada no encontrada")
update_data = entry_data.dict(exclude_unset=True)
# Parsear hora si se proporciona
if "time" in update_data and update_data["time"]:
hour, minute = map(int, update_data["time"].split(":"))
update_data["time"] = time(hour=hour, minute=minute)
for field, value in update_data.items():
setattr(entry, field, value)
entry.updated_at = datetime.utcnow()
db.commit()
db.refresh(entry)
return entry.to_dict()
@router.delete("/{entry_id}")
async def delete_calendar_entry(entry_id: int, db: Session = Depends(get_db)):
"""Eliminar una entrada del calendario."""
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
if not entry:
raise HTTPException(status_code=404, detail="Entrada no encontrada")
db.delete(entry)
db.commit()
return {"message": "Entrada eliminada", "entry_id": entry_id}
@router.post("/{entry_id}/toggle")
async def toggle_calendar_entry(entry_id: int, db: Session = Depends(get_db)):
"""Activar/desactivar una entrada del calendario."""
entry = db.query(ContentCalendar).filter(ContentCalendar.id == entry_id).first()
if not entry:
raise HTTPException(status_code=404, detail="Entrada no encontrada")
entry.is_active = not entry.is_active
db.commit()
return {
"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
}