Files
BDFR_Web/web_interface/app/scheduled_tasks.py
T

474 lines
15 KiB
Python

"""
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
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
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]
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
)
# 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:
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"
)