""" Scheduled Jobs Management API """ import logging import asyncio from typing import List, Optional, Dict, Any from datetime import datetime from fastapi import APIRouter, HTTPException, Depends, status from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from pydantic import BaseModel from app.database import get_db from app.models.db.scheduled_job import ScheduledJob, JobType from app.services.scheduler_service import scheduler_service from app.services.code_validator import code_validator from app.jobs.registry import task_registry logger = logging.getLogger(__name__) router = APIRouter(prefix="/scheduled-jobs", tags=["scheduled-jobs"]) # Pydantic Models class ScheduledJobBase(BaseModel): name: str cron_expression: str description: Optional[str] = None is_active: bool = True class ScheduledJobCreatePredefined(ScheduledJobBase): """Create predefined task job""" job_type: str = "predefined" predefined_function: str function_params: Optional[Dict[str, Any]] = {} class ScheduledJobCreateCustomCode(ScheduledJobBase): """Create custom code job""" job_type: str = "custom_code" python_code: str class ScheduledJobUpdate(BaseModel): name: Optional[str] = None cron_expression: Optional[str] = None job_type: Optional[str] = None predefined_function: Optional[str] = None function_params: Optional[Dict[str, Any]] = None python_code: Optional[str] = None description: Optional[str] = None is_active: Optional[bool] = None class ScheduledJobResponse(BaseModel): id: int name: str job_type: str predefined_function: Optional[str] = None function_params: Optional[Dict[str, Any]] = None cron_expression: str python_code: Optional[str] = None is_active: bool last_run_at: Optional[datetime] = None last_run_status: Optional[str] = None next_run_at: Optional[datetime] = None description: Optional[str] = None created_at: datetime updated_at: datetime class Config: from_attributes = True @router.get("", response_model=List[ScheduledJobResponse]) async def get_scheduled_jobs(db: AsyncSession = Depends(get_db)): """Get all scheduled jobs""" result = await db.execute(select(ScheduledJob).order_by(ScheduledJob.id)) return result.scalars().all() @router.get("/available-tasks", response_model=List[Dict[str, Any]]) async def get_available_tasks(): """Get list of all available predefined tasks""" tasks = task_registry.list_tasks() return tasks @router.get("/{job_id}", response_model=ScheduledJobResponse) async def get_scheduled_job(job_id: int, db: AsyncSession = Depends(get_db)): """Get a specific scheduled job""" result = await db.execute(select(ScheduledJob).where(ScheduledJob.id == job_id)) job = result.scalar_one_or_none() if not job: raise HTTPException(status_code=404, detail="Job not found") return job @router.post("", response_model=ScheduledJobResponse, status_code=status.HTTP_201_CREATED) async def create_scheduled_job( job_data: Dict[str, Any], db: AsyncSession = Depends(get_db) ): """Create a new scheduled job (predefined or custom code)""" job_type = job_data.get("job_type", "predefined") # Validate job type if job_type not in ["predefined", "custom_code"]: raise HTTPException(status_code=400, detail="job_type must be 'predefined' or 'custom_code'") # Validate based on job type if job_type == "predefined": # Validate predefined function exists predefined_function = job_data.get("predefined_function") if not predefined_function: raise HTTPException(status_code=400, detail="predefined_function is required for predefined jobs") task_def = task_registry.get_task(predefined_function) if not task_def: raise HTTPException( status_code=400, detail=f"Predefined task '{predefined_function}' not found. Use /scheduled-jobs/available-tasks to list available tasks." ) # Create job new_job = ScheduledJob( name=job_data["name"], job_type=JobType.PREDEFINED, predefined_function=predefined_function, function_params=job_data.get("function_params", {}), cron_expression=job_data["cron_expression"], description=job_data.get("description"), is_active=job_data.get("is_active", True) ) else: # custom_code # Validate python code python_code = job_data.get("python_code") if not python_code: raise HTTPException(status_code=400, detail="python_code is required for custom_code jobs") validation_result = code_validator.validate_code(python_code) if not validation_result["valid"]: raise HTTPException( status_code=400, detail={ "message": "代码验证失败", "errors": validation_result["errors"], "warnings": validation_result["warnings"] } ) # Log warnings if any if validation_result["warnings"]: logger.warning(f"Code validation warnings: {validation_result['warnings']}") # Create job new_job = ScheduledJob( name=job_data["name"], job_type=JobType.CUSTOM_CODE, python_code=python_code, cron_expression=job_data["cron_expression"], description=job_data.get("description"), is_active=job_data.get("is_active", True) ) db.add(new_job) await db.commit() await db.refresh(new_job) # Schedule it if new_job.is_active: scheduler_service.add_job_to_scheduler(new_job) return new_job @router.put("/{job_id}", response_model=ScheduledJobResponse) async def update_scheduled_job( job_id: int, job_data: ScheduledJobUpdate, db: AsyncSession = Depends(get_db) ): """Update a scheduled job""" result = await db.execute(select(ScheduledJob).where(ScheduledJob.id == job_id)) job = result.scalar_one_or_none() if not job: raise HTTPException(status_code=404, detail="Job not found") # Validate if changing job_type if job_data.job_type is not None and job_data.job_type != job.job_type.value: if job_data.job_type == "predefined": if not job_data.predefined_function: raise HTTPException(status_code=400, detail="predefined_function is required when changing to predefined type") task_def = task_registry.get_task(job_data.predefined_function) if not task_def: raise HTTPException(status_code=400, detail=f"Task '{job_data.predefined_function}' not found") elif job_data.job_type == "custom_code": if not job_data.python_code: raise HTTPException(status_code=400, detail="python_code is required when changing to custom_code type") # Validate python code if being updated if job_data.python_code is not None: validation_result = code_validator.validate_code(job_data.python_code) if not validation_result["valid"]: raise HTTPException( status_code=400, detail={ "message": "代码验证失败", "errors": validation_result["errors"], "warnings": validation_result["warnings"] } ) if validation_result["warnings"]: logger.warning(f"Code validation warnings: {validation_result['warnings']}") # Validate predefined function if being updated if job_data.predefined_function is not None: task_def = task_registry.get_task(job_data.predefined_function) if not task_def: raise HTTPException(status_code=400, detail=f"Task '{job_data.predefined_function}' not found") # Update fields update_dict = job_data.dict(exclude_unset=True) for key, value in update_dict.items(): if key == "job_type": setattr(job, key, JobType(value)) else: setattr(job, key, value) job.updated_at = datetime.utcnow() await db.commit() await db.refresh(job) # Update scheduler await scheduler_service.reload_job(job.id) return job @router.delete("/{job_id}") async def delete_scheduled_job(job_id: int, db: AsyncSession = Depends(get_db)): """Delete a scheduled job""" result = await db.execute(select(ScheduledJob).where(ScheduledJob.id == job_id)) job = result.scalar_one_or_none() if not job: raise HTTPException(status_code=404, detail="Job not found") # Remove from scheduler scheduler_service.remove_job(job_id) await db.delete(job) await db.commit() return {"message": "Job deleted successfully"} @router.post("/{job_id}/run") async def run_scheduled_job(job_id: int, db: AsyncSession = Depends(get_db)): """Manually trigger a scheduled job immediately""" result = await db.execute(select(ScheduledJob).where(ScheduledJob.id == job_id)) job = result.scalar_one_or_none() if not job: raise HTTPException(status_code=404, detail="Job not found") # Trigger async execution # We use create_task to run it in background so API returns immediately asyncio.create_task(scheduler_service.run_job_now(job_id)) return {"message": f"Job '{job.name}' triggered successfully"}