import secrets from pathlib import Path import aiofiles from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile from fastapi.responses import FileResponse from sqlmodel import Session, select from app.core.auth import current_user from app.core.config import settings from app.core.db import get_session from app.models.job import Job, JobStatus # noqa: F401 from app.models.user import User from app.services.pipeline import run_pipeline router = APIRouter(prefix="/api/jobs", tags=["jobs"]) MAX_BYTES = lambda: settings.max_upload_mb * 1024 * 1024 def _require_access(job: Job | None, user: User) -> Job: """404 wenn nicht gefunden oder nicht zugänglich (kein Hinweis darauf, dass es existiert).""" if not job or (job.owner_id != user.id and not user.is_admin): raise HTTPException(404, "Job nicht gefunden") return job @router.post("") async def create_job( background: BackgroundTasks, audio: UploadFile = File(...), title: str = Form(""), profile: str = Form(""), session: Session = Depends(get_session), user: User = Depends(current_user), ): if not audio.filename: raise HTTPException(400, "Dateiname fehlt") ext = Path(audio.filename).suffix.lower().lstrip(".") if ext not in settings.allowed_ext_set: raise HTTPException(400, f"Format '{ext}' nicht erlaubt. Erlaubt: {sorted(settings.allowed_ext_set)}") safe = secrets.token_hex(8) + "." + ext dest = settings.upload_dir / safe size = 0 limit = MAX_BYTES() async with aiofiles.open(dest, "wb") as f: while chunk := await audio.read(1024 * 1024): size += len(chunk) if size > limit: await f.close() dest.unlink(missing_ok=True) raise HTTPException(413, f"Datei zu groß (max. {settings.max_upload_mb} MB)") await f.write(chunk) job = Job( owner_id=user.id, profile=(profile.strip() or user.default_profile or "meeting"), filename=safe, original_name=audio.filename, title=title.strip(), ) session.add(job) session.commit() session.refresh(job) background.add_task(run_pipeline, job.id) return _job_dict(job, user) @router.get("") def list_jobs(session: Session = Depends(get_session), user: User = Depends(current_user)): stmt = select(Job).order_by(Job.created_at.desc()) if not user.is_admin: stmt = stmt.where(Job.owner_id == user.id) jobs = session.exec(stmt).all() # Admin sieht zusätzlich Owner-Username — kleine Map für effiziente Anzeige. owner_names: dict[int, str] = {} if user.is_admin: owner_ids = {j.owner_id for j in jobs if j.owner_id is not None} if owner_ids: owners = session.exec(select(User).where(User.id.in_(owner_ids))).all() # type: ignore[attr-defined] owner_names = {u.id: u.username for u in owners} return [_job_dict(j, user, owner_names.get(j.owner_id) if user.is_admin else None) for j in jobs] @router.get("/{job_id}") def get_job(job_id: int, session: Session = Depends(get_session), user: User = Depends(current_user)): job = _require_access(session.get(Job, job_id), user) return _job_dict(job, user) @router.post("/{job_id}/retry") async def retry_job( job_id: int, background: BackgroundTasks, session: Session = Depends(get_session), user: User = Depends(current_user), ): """Verarbeitung neu anstoßen. Bereits vorhandenes Transkript wird beibehalten (teuerster Schritt), Summary/Protokoll/DOCX werden gelöscht und neu erzeugt. Sinnvoll wenn der Job bei summarize/protocol/docx hängt oder failed ist. """ job = _require_access(session.get(Job, job_id), user) if job.status == JobStatus.DONE: raise HTTPException(400, "Job ist bereits abgeschlossen") # Post-Transkriptions-Artefakte löschen, damit sie neu erzeugt werden. for attr in ("summary_path", "protocol_path", "docx_path", "pdf_path"): p = getattr(job, attr) if p: try: Path(p).unlink(missing_ok=True) except OSError: pass setattr(job, attr, "") job.status = JobStatus.QUEUED job.progress = 0 job.error = "" session.add(job) session.commit() session.refresh(job) background.add_task(run_pipeline, job.id) return _job_dict(job, user) @router.get("/{job_id}/download/{kind}") def download(job_id: int, kind: str, session: Session = Depends(get_session), user: User = Depends(current_user)): job = _require_access(session.get(Job, job_id), user) mapping = { "docx": (job.docx_path, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "protocol.docx"), "pdf": (job.pdf_path, "application/pdf", "protocol.pdf"), "transcript": (job.transcript_path, "application/json", "transcript.json"), "summary": (job.summary_path, "application/json", "summary.json"), "protocol": (job.protocol_path, "application/json", "protocol.json"), } if kind not in mapping: raise HTTPException(400, "Unbekannter Download-Typ") path, media, name = mapping[kind] if not path or not Path(path).exists(): raise HTTPException(404, "Datei noch nicht verfügbar") base = (job.title or Path(job.original_name).stem).replace("/", "_") return FileResponse(path, media_type=media, filename=f"{base}-{name}") def _job_dict(j: Job, user: User, owner_username: str | None = None) -> dict: d = { "id": j.id, "title": j.title, "original_name": j.original_name, "status": j.status, "progress": j.progress, "error": j.error, "profile": j.profile or "meeting", "created_at": j.created_at.isoformat(), "updated_at": j.updated_at.isoformat(), "has": { "transcript": bool(j.transcript_path), "summary": bool(j.summary_path), "protocol": bool(j.protocol_path), "docx": bool(j.docx_path), "pdf": bool(j.pdf_path), }, } if user.is_admin: d["owner_id"] = j.owner_id d["owner_username"] = owner_username or "" return d