""" 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 }