Files
voicebox/backend/routes/history.py
T
9a3c307c75 fix(history): populate status/error/engine fields from DB row (#394)
* fix(history): populate status/error/engine/model_size/is_favorited from DB

GET /history/{generation_id} was constructing HistoryResponse without
passing status, error, engine, model_size, or is_favorited from the
DB row. Since HistoryResponse.status defaults to "completed" in the
Pydantic model (models.py:141), this endpoint returned
status="completed" for every generation regardless of the actual DB
state — including jobs still in "loading_model" or "generating", and
even "failed" jobs.

This breaks any client polling /history/{id} for job completion:
the API lies about the status, so the only trustworthy success
signal becomes `audio_path` being non-empty. All other fields left
at their model defaults were similarly masked.

Fix: pass all fields through from the DB row, matching the pattern
used elsewhere in the codebase. The DB model (Generation in
database/models.py) already has all these columns.

* fix(history): apply NULL fallbacks to match list endpoint

Align the defensive mappings with services/history.py:206-223 so
both the single-item and list history endpoints handle legacy rows
with NULL status/engine/is_favorited identically. Without this,
HistoryResponse's non-Optional str/bool fields would raise a
pydantic ValidationError (500) on any row where these columns are
NULL — possible from direct SQL updates or past migrations.

Addresses review feedback on PR #394.

---------

Co-authored-by: malletfils <[email protected]>
2026-04-16 01:51:12 -07:00

183 lines
5.7 KiB
Python

"""Generation history endpoints."""
import io
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
from .. import config, models
from ..services import export_import, history
from ..app import safe_content_disposition
from ..database import Generation as DBGeneration, VoiceProfile as DBVoiceProfile, get_db
router = APIRouter()
@router.get("/history", response_model=models.HistoryListResponse)
async def list_history(
profile_id: str | None = None,
search: str | None = None,
limit: int = 50,
offset: int = 0,
db: Session = Depends(get_db),
):
"""List generation history with optional filters."""
query = models.HistoryQuery(
profile_id=profile_id,
search=search,
limit=limit,
offset=offset,
)
return await history.list_generations(query, db)
@router.get("/history/stats")
async def get_stats(db: Session = Depends(get_db)):
"""Get generation statistics."""
return await history.get_generation_stats(db)
@router.post("/history/import")
async def import_generation(
file: UploadFile = File(...),
db: Session = Depends(get_db),
):
"""Import a generation from a ZIP archive."""
MAX_FILE_SIZE = 50 * 1024 * 1024
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=400, detail=f"File too large. Maximum size is {MAX_FILE_SIZE / (1024 * 1024)}MB"
)
try:
result = await export_import.import_generation_from_zip(content, db)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/history/{generation_id}", response_model=models.HistoryResponse)
async def get_generation(
generation_id: str,
db: Session = Depends(get_db),
):
"""Get a generation by ID."""
result = (
db.query(DBGeneration, DBVoiceProfile.name.label("profile_name"))
.join(DBVoiceProfile, DBGeneration.profile_id == DBVoiceProfile.id)
.filter(DBGeneration.id == generation_id)
.first()
)
if not result:
raise HTTPException(status_code=404, detail="Generation not found")
gen, profile_name = result
return models.HistoryResponse(
id=gen.id,
profile_id=gen.profile_id,
profile_name=profile_name,
text=gen.text,
language=gen.language,
audio_path=gen.audio_path,
duration=gen.duration,
seed=gen.seed,
instruct=gen.instruct,
engine=gen.engine or "qwen",
model_size=gen.model_size,
status=gen.status or "completed",
error=gen.error,
is_favorited=bool(gen.is_favorited),
created_at=gen.created_at,
)
@router.post("/history/{generation_id}/favorite")
async def toggle_favorite(
generation_id: str,
db: Session = Depends(get_db),
):
"""Toggle the favorite status of a generation."""
gen = db.query(DBGeneration).filter_by(id=generation_id).first()
if not gen:
raise HTTPException(status_code=404, detail="Generation not found")
gen.is_favorited = not gen.is_favorited
db.commit()
return {"is_favorited": gen.is_favorited}
@router.delete("/history/{generation_id}")
async def delete_generation(
generation_id: str,
db: Session = Depends(get_db),
):
"""Delete a generation."""
success = await history.delete_generation(generation_id, db)
if not success:
raise HTTPException(status_code=404, detail="Generation not found")
return {"message": "Generation deleted successfully"}
@router.get("/history/{generation_id}/export")
async def export_generation(
generation_id: str,
db: Session = Depends(get_db),
):
"""Export a generation as a ZIP archive."""
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
if not generation:
raise HTTPException(status_code=404, detail="Generation not found")
try:
zip_bytes = export_import.export_generation_to_zip(generation_id, db)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip()
if not safe_text:
safe_text = "generation"
filename = f"generation-{safe_text}.voicebox.zip"
return StreamingResponse(
io.BytesIO(zip_bytes),
media_type="application/zip",
headers={"Content-Disposition": safe_content_disposition("attachment", filename)},
)
@router.get("/history/{generation_id}/export-audio")
async def export_generation_audio(
generation_id: str,
db: Session = Depends(get_db),
):
"""Export only the audio file from a generation."""
generation = db.query(DBGeneration).filter_by(id=generation_id).first()
if not generation:
raise HTTPException(status_code=404, detail="Generation not found")
if not generation.audio_path:
raise HTTPException(status_code=404, detail="Generation has no audio file")
audio_path = config.resolve_storage_path(generation.audio_path)
if audio_path is None or not audio_path.is_file():
raise HTTPException(status_code=404, detail="Audio file not found")
safe_text = "".join(c for c in generation.text[:30] if c.isalnum() or c in (" ", "-", "_")).strip()
if not safe_text:
safe_text = "generation"
filename = f"{safe_text}.wav"
return FileResponse(
audio_path,
media_type="audio/wav",
headers={"Content-Disposition": safe_content_disposition("attachment", filename)},
)