678 lines
21 KiB
Python
678 lines
21 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
|
|
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)}"
|
|
) |