Files
postgenerator/backend/routers/export.py
Michele 9e7205eca2 feat(04-01): integrazione Unsplash in pipeline + CSVBuilder + export
- CSVBuilder.build_csv() e build_csv_content() accettano image_url_map opzionale
- _resolve_image() risolve keyword->URL Unsplash con fallback keyword originale
- _build_rows() chiama _resolve_image per cover, slides e cta image keywords
- JobStatus ha campo image_url_map con persistenza su disco JSON
- GenerationPipeline._resolve_unsplash_keywords() chiamato dopo batch LLM
- Carica unsplash_api_key da settings.json, crea UnsplashService, chiama resolve_keywords
- image_url_map salvato nel job JSON per riuso in export con edits
- Export router recupera image_url_map dal job JSON e passa a build_csv_content
- generate_single NON risolve Unsplash (velocità e riuso map job originale)
2026-03-09 08:10:06 +01:00

162 lines
5.2 KiB
Python

"""Router per l'export del CSV Canva.
Endpoint:
- GET /api/export/{job_id}/csv — scarica CSV originale con Content-Disposition attachment
- POST /api/export/{job_id}/csv — accetta dati modificati inline e rigenera il CSV
"""
from __future__ import annotations
import logging
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel
from backend.config import OUTPUTS_PATH
from backend.schemas.generate import PostResult
from backend.services.csv_builder import CSVBuilder
from backend.services.generation_pipeline import GenerationPipeline
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/export", tags=["export"])
_csv_builder = CSVBuilder()
# ---------------------------------------------------------------------------
# Request schema per POST con dati modificati
# ---------------------------------------------------------------------------
class ExportWithEditsRequest(BaseModel):
"""Richiesta POST per rigenerare il CSV con dati modificati dall'utente."""
results: list[PostResult]
# ---------------------------------------------------------------------------
# Endpoint
# ---------------------------------------------------------------------------
@router.get("/{job_id}/csv")
async def download_csv_original(job_id: str) -> FileResponse:
"""Scarica il CSV originale generato per un job.
Cerca il file CSV in OUTPUTS_PATH/{job_id}.csv e lo serve come attachment.
Args:
job_id: Identificatore del job (da POST /api/generate/bulk).
Returns:
File CSV con Content-Disposition: attachment per il download automatico.
Raises:
HTTPException 404: Se il file CSV non esiste (job non completato o non trovato).
"""
csv_path = OUTPUTS_PATH / f"{job_id}.csv"
if not csv_path.exists():
raise HTTPException(
status_code=404,
detail=f"File CSV per job '{job_id}' non trovato. "
"Assicurati che la generazione sia completata.",
)
return FileResponse(
path=str(csv_path),
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="postgenerator_{job_id}.csv"',
},
)
@router.post("/{job_id}/csv")
async def download_csv_with_edits(
job_id: str,
request: ExportWithEditsRequest,
) -> Response:
"""Rigenera il CSV con i dati modificati inline dall'utente.
Accetta i PostResult aggiornati dal frontend (con modifiche al testo
delle slide, titoli, caption, etc.) e produce un nuovo CSV aggiornato.
Il file viene salvato come OUTPUTS_PATH/{job_id}_edited.csv.
Args:
job_id: Identificatore del job originale.
request: Lista di PostResult con i dati aggiornati dall'utente.
Returns:
File CSV rigenerato con le modifiche inline.
Raises:
HTTPException 404: Se il job originale (e quindi il calendario) non esiste.
"""
# Carica il calendario originale dal job per i metadati degli slot
job_path = OUTPUTS_PATH / f"{job_id}.json"
if not job_path.exists():
raise HTTPException(
status_code=404,
detail=f"Job '{job_id}' non trovato. "
"Impossibile rigenerare il CSV senza i metadati originali.",
)
# Carica il calendario dal JSON del job
import json
from typing import Optional
from backend.schemas.calendar import CalendarResponse
try:
with open(job_path, "r", encoding="utf-8") as f:
job_data = json.load(f)
calendar = CalendarResponse.model_validate(job_data["calendar"])
# Recupera image_url_map se presente (risoluzione Unsplash originale)
image_url_map: Optional[dict[str, str]] = job_data.get("image_url_map")
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Errore nel caricamento del job: {str(e)}",
)
if image_url_map:
logger.info(
"Uso image_url_map dal job originale | job_id=%s | url_count=%d",
job_id,
len(image_url_map),
)
# Genera il CSV con i dati modificati (+ URL Unsplash se disponibili)
csv_content = _csv_builder.build_csv_content(
posts=request.results,
calendar=calendar,
job_id=job_id,
image_url_map=image_url_map,
)
# Salva anche su disco come versione edited
edited_path = OUTPUTS_PATH / f"{job_id}_edited.csv"
edited_path.parent.mkdir(parents=True, exist_ok=True)
with open(edited_path, "w", newline="", encoding="utf-8-sig") as f:
f.write(csv_content)
logger.info(
"CSV con modifiche generato | job_id=%s | righe=%d | path=%s",
job_id,
len([r for r in request.results if r.status == "success"]),
edited_path,
)
# Ritorna il CSV come response con Content-Disposition attachment
# Nota: build_csv_content include già il BOM (\ufeff), usiamo encode("utf-8")
# per non raddoppiare il BOM nel body
return Response(
content=csv_content.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={
"Content-Disposition": f'attachment; filename="postgenerator_{job_id}_edited.csv"',
},
)