- 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>
426 lines
13 KiB
Python
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
|
|
}
|