""" API endpoints and business logic for scheduled tasks management. """ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from pydantic import BaseModel, Field from typing import List, Optional from datetime import datetime, time as time_type import pytz import logging from .database import get_db from .models import ScheduledTask, TaskExecutionHistory from .scheduler import schedule_task, unschedule_task, calculate_next_run from .task_queue import task_queue from .auth import get_oauth_manager logger = logging.getLogger(__name__) # Create router router = APIRouter(prefix="/api/scheduled-tasks", tags=["scheduled-tasks"]) # Pydantic models for API class ScheduledTaskCreate(BaseModel): name: str = Field(..., min_length=1, max_length=255) source_type: str = Field(..., pattern="^(subreddit|user)$") source_name: str = Field(..., min_length=1, max_length=255) download_mode: str = Field(..., pattern="^(download|archive|clone)$") limit: int = Field(default=25, ge=1, le=1000) sort: str = Field(default="hot") min_score: Optional[int] = None simple_check: bool = False run_time: str = Field(..., pattern="^([01]?[0-9]|2[0-3]):[0-5][0-9]$") # HH:MM format timezone: str = Field(default="UTC") auth_state: Optional[str] = None upvoted: bool = False saved: bool = False class ScheduledTaskUpdate(BaseModel): name: Optional[str] = Field(None, min_length=1, max_length=255) limit: Optional[int] = Field(None, ge=1, le=1000) sort: Optional[str] = None min_score: Optional[int] = None simple_check: Optional[bool] = None run_time: Optional[str] = Field(None, pattern="^([01]?[0-9]|2[0-3]):[0-5][0-9]$") timezone: Optional[str] = None enabled: Optional[bool] = None class ScheduledTaskResponse(BaseModel): id: str name: str enabled: bool source_type: str source_name: str download_mode: str limit: int sort: str time_filter: str min_score: Optional[int] no_dupes: bool simple_check: bool schedule_frequency: str run_time: str timezone: str created_at: str updated_at: str last_run_at: Optional[str] next_run_at: Optional[str] auth_state: Optional[str] upvoted: bool saved: bool class Config: from_attributes = True class TaskExecutionResponse(BaseModel): id: str task_id: str started_at: str completed_at: Optional[str] status: str items_found: int items_downloaded: int error_message: Optional[str] download_id: Optional[str] class Config: from_attributes = True # API Endpoints @router.post("", response_model=ScheduledTaskResponse, status_code=status.HTTP_201_CREATED) async def create_scheduled_task(task_data: ScheduledTaskCreate, db: Session = Depends(get_db)): """ Create a new scheduled download task. """ try: # Validate timezone try: pytz.timezone(task_data.timezone) except pytz.exceptions.UnknownTimeZoneError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid timezone: {task_data.timezone}" ) # Parse run_time hour, minute = map(int, task_data.run_time.split(':')) run_time_obj = time_type(hour=hour, minute=minute) # Create task task = ScheduledTask( name=task_data.name, enabled=True, source_type=task_data.source_type, source_name=task_data.source_name, download_mode=task_data.download_mode, limit=task_data.limit, sort=task_data.sort, time_filter="day", # Always "day" for daily scheduled tasks min_score=task_data.min_score, no_dupes=True, # Always true for scheduled tasks simple_check=task_data.simple_check, schedule_frequency="daily", run_time=run_time_obj, timezone=task_data.timezone, auth_state=task_data.auth_state, upvoted=task_data.upvoted, saved=task_data.saved ) # Calculate next run time task.next_run_at = calculate_next_run(task) # Save to database db.add(task) db.commit() db.refresh(task) # Schedule the task schedule_task(task) logger.info(f"Created scheduled task {task.id}: {task.name}") return task.to_dict() except HTTPException: raise except Exception as e: logger.error(f"Failed to create scheduled task: {e}", exc_info=True) db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create scheduled task: {str(e)}" ) @router.get("", response_model=List[ScheduledTaskResponse]) async def list_scheduled_tasks(db: Session = Depends(get_db)): """ Get all scheduled tasks. """ try: tasks = db.query(ScheduledTask).order_by(ScheduledTask.created_at.desc()).all() return [task.to_dict() for task in tasks] except Exception as e: if "no such column" in str(e): logger.warning(f"Database schema is outdated: {e}") # Return empty list if schema is outdated return [] logger.error(f"Failed to list scheduled tasks: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to list scheduled tasks" ) @router.get("/{task_id}", response_model=ScheduledTaskResponse) async def get_scheduled_task(task_id: str, db: Session = Depends(get_db)): """ Get a specific scheduled task by ID. """ task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Task {task_id} not found" ) return task.to_dict() @router.put("/{task_id}", response_model=ScheduledTaskResponse) async def update_scheduled_task( task_id: str, task_data: ScheduledTaskUpdate, db: Session = Depends(get_db) ): """ Update a scheduled task. """ task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Task {task_id} not found" ) try: # Update fields if task_data.name is not None: task.name = task_data.name if task_data.limit is not None: task.limit = task_data.limit if task_data.sort is not None: task.sort = task_data.sort if task_data.min_score is not None: task.min_score = task_data.min_score if task_data.simple_check is not None: task.simple_check = task_data.simple_check # Handle run_time update reschedule_needed = False if task_data.run_time is not None: hour, minute = map(int, task_data.run_time.split(':')) task.run_time = time_type(hour=hour, minute=minute) reschedule_needed = True # Handle timezone update if task_data.timezone is not None: try: pytz.timezone(task_data.timezone) task.timezone = task_data.timezone reschedule_needed = True except pytz.exceptions.UnknownTimeZoneError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid timezone: {task_data.timezone}" ) # Handle enabled status if task_data.enabled is not None and task_data.enabled != task.enabled: task.enabled = task_data.enabled reschedule_needed = True # Update next_run_at if needed if reschedule_needed: if task.enabled: task.next_run_at = calculate_next_run(task) # Reschedule unschedule_task(task_id) schedule_task(task) else: # Unschedule if disabled unschedule_task(task_id) task.next_run_at = None task.updated_at = datetime.now(pytz.UTC) db.commit() db.refresh(task) logger.info(f"Updated scheduled task {task_id}") return task.to_dict() except HTTPException: raise except Exception as e: logger.error(f"Failed to update task {task_id}: {e}", exc_info=True) db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update task: {str(e)}" ) @router.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_scheduled_task(task_id: str, db: Session = Depends(get_db)): """ Delete a scheduled task. """ task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Task {task_id} not found" ) try: # Unschedule unschedule_task(task_id) # Delete from database (cascade will delete execution history) db.delete(task) db.commit() logger.info(f"Deleted scheduled task {task_id}") except Exception as e: logger.error(f"Failed to delete task {task_id}: {e}", exc_info=True) db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete task: {str(e)}" ) @router.post("/{task_id}/toggle") async def toggle_scheduled_task(task_id: str, db: Session = Depends(get_db)): """ Toggle a task's enabled status. """ task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Task {task_id} not found" ) try: # Toggle enabled task.enabled = not task.enabled task.updated_at = datetime.now(pytz.UTC) if task.enabled: # Re-enable: schedule and calculate next run task.next_run_at = calculate_next_run(task) schedule_task(task) message = "Task enabled" else: # Disable: unschedule unschedule_task(task_id) task.next_run_at = None message = "Task disabled" db.commit() logger.info(f"Toggled task {task_id}: {message}") return { "message": message, "enabled": task.enabled } except Exception as e: logger.error(f"Failed to toggle task {task_id}: {e}", exc_info=True) db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to toggle task: {str(e)}" ) @router.post("/{task_id}/run-now") async def run_task_now(task_id: str, db: Session = Depends(get_db)): """ Manually trigger a scheduled task to run now. Adds it to the queue with high priority. """ task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Task {task_id} not found" ) try: # Add to queue with priority (goes ahead of scheduled tasks) await task_queue.add_task(task_id, priority=1) queue_status = task_queue.get_queue_status() logger.info(f"Manually queued task {task_id} for immediate execution") return { "message": f"Task queued for execution", "task_id": task_id, "queue_position": queue_status['queue_size'], "currently_running": queue_status['current_task'], "status": "queued" if queue_status['current_task'] else "starting" } except Exception as e: logger.error(f"Failed to queue task {task_id}: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to queue task: {str(e)}" ) @router.get("/{task_id}/history", response_model=List[TaskExecutionResponse]) async def get_task_history(task_id: str, limit: int = 10, db: Session = Depends(get_db)): """ Get execution history for a specific task. """ task = db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first() if not task: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Task {task_id} not found" ) try: executions = db.query(TaskExecutionHistory)\ .filter(TaskExecutionHistory.task_id == task_id)\ .order_by(TaskExecutionHistory.started_at.desc())\ .limit(limit)\ .all() return [execution.to_dict() for execution in executions] except Exception as e: logger.error(f"Failed to get history for task {task_id}: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get task history" ) @router.get("/history/recent", response_model=List[TaskExecutionResponse]) async def get_recent_history(limit: int = 20, db: Session = Depends(get_db)): """ Get recent execution history across all tasks. """ try: executions = db.query(TaskExecutionHistory)\ .order_by(TaskExecutionHistory.started_at.desc())\ .limit(limit)\ .all() return [execution.to_dict() for execution in executions] except Exception as e: logger.error(f"Failed to get recent history: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get execution history" ) @router.get("/queue/status") async def get_queue_status(): """Get current task queue status""" try: status = task_queue.get_queue_status() # Get details of current task if any current_task_info = None if status['current_task']: db = next(get_db()) try: task = db.query(ScheduledTask).filter(ScheduledTask.id == status['current_task']).first() if task: current_task_info = { 'id': task.id, 'name': task.name, 'source': f"{task.source_type}/{task.source_name}" } finally: db.close() return { 'queue_size': status['queue_size'], 'is_processing': status['is_processing'], 'current_task': current_task_info } except Exception as e: logger.error(f"Failed to get queue status: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to get queue status" ) @router.post("/create-likes", response_model=ScheduledTaskResponse, status_code=status.HTTP_201_CREATED) async def create_likes_task( limit: int = 25, sort: str = "hot", download_mode: str = "download", run_now: bool = False, run_time: str = "02:00", timezone: str = "UTC", auth_state: str = None, db: Session = Depends(get_db) ): """ Create a scheduled task to download the user's liked posts. """ try: # Get current username from auth if not auth_state: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication required" ) oauth_manager = get_oauth_manager() auth_status = oauth_manager.get_auth_status(auth_state) if not auth_status["authenticated"]: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication" ) username = auth_status["username"] task_name = f"{username} - Liked posts" # Validate timezone try: pytz.timezone(timezone) except pytz.exceptions.UnknownTimeZoneError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid timezone: {timezone}" ) # Parse run_time hour, minute = map(int, run_time.split(':')) run_time_obj = time_type(hour=hour, minute=minute) # Create task task = ScheduledTask( name=task_name, enabled=True, source_type="user", source_name=username, download_mode=download_mode, limit=limit, sort=sort, time_filter="day", no_dupes=True, simple_check=False, schedule_frequency="daily", run_time=run_time_obj, timezone=timezone, auth_state=auth_state, upvoted=True, saved=False ) # Calculate next run time task.next_run_at = calculate_next_run(task) # Save to database db.add(task) db.commit() db.refresh(task) # Schedule the task schedule_task(task) # If run_now is True, queue it immediately if run_now: await task_queue.add_task(task.id, priority=1) logger.info(f"Created likes task {task.id}: {task.name}") return task.to_dict() except HTTPException: raise except Exception as e: logger.error(f"Failed to create likes task: {e}", exc_info=True) db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create likes task: {str(e)}" ) @router.post("/create-saved", response_model=ScheduledTaskResponse, status_code=status.HTTP_201_CREATED) async def create_saved_task( limit: int = 25, sort: str = "hot", download_mode: str = "download", run_now: bool = False, run_time: str = "02:00", timezone: str = "UTC", auth_state: str = None, db: Session = Depends(get_db) ): """ Create a scheduled task to download the user's saved posts. """ try: # Get current username from auth if not auth_state: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication required" ) oauth_manager = get_oauth_manager() auth_status = oauth_manager.get_auth_status(auth_state) if not auth_status["authenticated"]: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authentication" ) username = auth_status["username"] task_name = f"{username} - Saved posts" # Validate timezone try: pytz.timezone(timezone) except pytz.exceptions.UnknownTimeZoneError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid timezone: {timezone}" ) # Parse run_time hour, minute = map(int, run_time.split(':')) run_time_obj = time_type(hour=hour, minute=minute) # Create task task = ScheduledTask( name=task_name, enabled=True, source_type="user", source_name=username, download_mode=download_mode, limit=limit, sort=sort, time_filter="day", no_dupes=True, simple_check=False, schedule_frequency="daily", run_time=run_time_obj, timezone=timezone, auth_state=auth_state, upvoted=False, saved=True ) # Calculate next run time task.next_run_at = calculate_next_run(task) # Save to database db.add(task) db.commit() db.refresh(task) # Schedule the task schedule_task(task) # If run_now is True, queue it immediately if run_now: await task_queue.add_task(task.id, priority=1) logger.info(f"Created saved task {task.id}: {task.name}") return task.to_dict() except HTTPException: raise except Exception as e: logger.error(f"Failed to create saved task: {e}", exc_info=True) db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to create saved task: {str(e)}" )