"""备份运行历史 API。""" from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from app.core.storage.factory import create_storage from app.deps import CurrentUser, DBSession from app.models.job import Job from app.models.run import Run, RunLog from app.schemas.run import RunListResponse, RunLogOut, RunOut router = APIRouter(prefix="/runs", tags=["runs"]) @router.get("", response_model=RunListResponse) def list_runs( db: DBSession, _: CurrentUser, job_id: int | None = None, status: str | None = None, page: int = 1, page_size: int = 20, ) -> RunListResponse: page = max(1, page) page_size = max(1, min(100, page_size)) q = db.query(Run) if job_id is not None: q = q.filter(Run.job_id == job_id) if status: q = q.filter(Run.status == status) total = q.count() items = ( q.order_by(Run.id.desc()) .offset((page - 1) * page_size) .limit(page_size) .all() ) return RunListResponse(total=total, items=[RunOut.model_validate(r) for r in items]) @router.get("/{run_id}", response_model=RunOut) def get_run(run_id: int, db: DBSession, _: CurrentUser) -> Run: run = db.get(Run, run_id) if not run: raise HTTPException(status_code=404, detail="Not found") return run @router.get("/{run_id}/log", response_model=RunLogOut) def get_run_log(run_id: int, db: DBSession, _: CurrentUser) -> RunLog: run = db.get(Run, run_id) if not run: raise HTTPException(status_code=404, detail="Not found") log = db.query(RunLog).filter(RunLog.run_id == run_id).order_by(RunLog.id.desc()).first() if not log: raise HTTPException(status_code=404, detail="No log") return log @router.get("/{run_id}/download") async def download_run(run_id: int, db: DBSession, _: CurrentUser): run = db.get(Run, run_id) if not run or not run.artifact_path: raise HTTPException(status_code=404, detail="Not found") job = db.get(Job, run.job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") storage = create_storage(job.storage) filename = run.artifact_path.split("/")[-1] async def stream(): async for chunk in storage.download_stream(run.artifact_path): yield chunk return StreamingResponse( stream(), media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @router.delete("/{run_id}", status_code=204) async def delete_run(run_id: int, db: DBSession, _: CurrentUser) -> None: run = db.get(Run, run_id) if not run: raise HTTPException(status_code=404, detail="Not found") job = db.get(Job, run.job_id) if job and run.artifact_path: try: storage = create_storage(job.storage) await storage.delete(run.artifact_path) except Exception: pass db.delete(run) db.commit()