cosmo/backend/app/api/scheduled_job.py

272 lines
9.3 KiB
Python

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