fix: batch generate JSON datetime serialization, unique PDF names, approved-only ZIP/exports

This commit is contained in:
root
2026-07-29 08:37:10 +00:00
parent 040882deb5
commit 8d0223d734
5 changed files with 83 additions and 14 deletions

Binary file not shown.

95
main.py
View File

@@ -544,7 +544,11 @@ async def generate_cvs_for_requirement(req_id: str, request: Request):
@app.get("/api/generated-cvs") @app.get("/api/generated-cvs")
async def list_generated_cvs(req_id: str = None, candidate_id: str = None, batch_id: str = None): async def list_generated_cvs(req_id: str = None, candidate_id: str = None, batch_id: str = None):
"""List batch-generated CV exports (plus any legacy generated_cvs rows).""" """List batch-generated CV exports (plus any legacy generated_cvs rows)."""
where_parts = ["bi.generated_cv_path IS NOT NULL", "bi.generated_cv_path <> ''"] where_parts = [
"bi.generated_cv_path IS NOT NULL",
"bi.generated_cv_path <> ''",
"bi.status = 'approved'",
]
params = [] params = []
if candidate_id: if candidate_id:
where_parts.append("bi.candidate_id = %s") where_parts.append("bi.candidate_id = %s")
@@ -901,7 +905,11 @@ async def list_batches():
COUNT(*) FILTER (WHERE status = 'approved') AS approved_items, COUNT(*) FILTER (WHERE status = 'approved') AS approved_items,
COUNT(*) FILTER (WHERE status = 'proposed') AS proposed_items, COUNT(*) FILTER (WHERE status = 'proposed') AS proposed_items,
COUNT(*) FILTER (WHERE status = 'removed') AS removed_items, COUNT(*) FILTER (WHERE status = 'removed') AS removed_items,
COUNT(*) FILTER (WHERE generated_cv_path IS NOT NULL AND generated_cv_path <> '') AS generated_items COUNT(*) FILTER (
WHERE generated_cv_path IS NOT NULL
AND generated_cv_path <> ''
AND status = 'approved'
) AS generated_items
FROM batch_items FROM batch_items
GROUP BY batch_id GROUP BY batch_id
) stats ON stats.batch_id = b.id ) stats ON stats.batch_id = b.id
@@ -1070,11 +1078,41 @@ async def analyze_batch(batch_id: str):
async def update_batch_item(batch_id: str, item_id: str, request: Request): async def update_batch_item(batch_id: str, item_id: str, request: Request):
"""Update a batch item (approve, remove, edit realignment).""" """Update a batch item (approve, remove, edit realignment)."""
body = await request.json() body = await request.json()
row = db.execute( status = body.get("status", "proposed")
"""UPDATE batch_items SET status = %s, realigned_cv_data = %s, updated_at = NOW() realigned = body.get("realigned_cv_data")
WHERE id = %s AND batch_id = %s RETURNING *""",
(body.get("status", "proposed"), Json(body.get("realigned_cv_data")), item_id, batch_id) # If removing a candidate, clear any stale generated PDF path so exports/ZIPs stay clean.
) if status == "removed":
existing = db.query(
"SELECT generated_cv_path FROM batch_items WHERE id = %s AND batch_id = %s",
(item_id, batch_id),
fetch="one",
)
if existing and existing.get("generated_cv_path"):
rel = str(existing["generated_cv_path"]).lstrip("/")
if rel.startswith("static/"):
file_path = static_dir / rel[len("static/"):]
else:
file_path = static_dir / "generated" / Path(rel).name
try:
if file_path.exists() and file_path.is_file():
# Only delete files that belong to this item id tag when possible.
if str(item_id)[:8] in file_path.name:
file_path.unlink()
except Exception:
pass
row = db.execute(
"""UPDATE batch_items
SET status = %s, realigned_cv_data = %s, generated_cv_path = NULL, updated_at = NOW()
WHERE id = %s AND batch_id = %s RETURNING *""",
(status, Json(realigned), item_id, batch_id),
)
else:
row = db.execute(
"""UPDATE batch_items SET status = %s, realigned_cv_data = %s, updated_at = NOW()
WHERE id = %s AND batch_id = %s RETURNING *""",
(status, Json(realigned), item_id, batch_id),
)
if not row: if not row:
raise HTTPException(404, "Batch item not found") raise HTTPException(404, "Batch item not found")
return dict(row) return dict(row)
@@ -1183,6 +1221,25 @@ async def update_batch_positions(batch_id: str, request: Request):
async def generate_batch_cvs(batch_id: str): async def generate_batch_cvs(batch_id: str):
"""Generate CVs for all approved items in the batch using the selected template.""" """Generate CVs for all approved items in the batch using the selected template."""
import httpx import httpx
from datetime import date as _date, datetime as _datetime
from decimal import Decimal
from uuid import UUID
def _jsonable(value):
"""Recursively convert DB row values into JSON-safe types."""
if isinstance(value, dict):
return {k: _jsonable(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [_jsonable(v) for v in value]
if isinstance(value, (_datetime, _date)):
return value.isoformat()
if isinstance(value, UUID):
return str(value)
if isinstance(value, Decimal):
return float(value)
if isinstance(value, (bytes, bytearray)):
return value.decode("utf-8", errors="replace")
return value
batch = db.query("SELECT * FROM cv_batches WHERE id = %s", (batch_id,), fetch='one') batch = db.query("SELECT * FROM cv_batches WHERE id = %s", (batch_id,), fetch='one')
if not batch: if not batch:
@@ -1218,6 +1275,7 @@ async def generate_batch_cvs(batch_id: str):
cand["experience"] = [dict(e) for e in db.query("SELECT * FROM experience WHERE candidate_id = %s", (candidate_id,))] cand["experience"] = [dict(e) for e in db.query("SELECT * FROM experience WHERE candidate_id = %s", (candidate_id,))]
cand["education"] = [dict(e) for e in db.query("SELECT * FROM education WHERE candidate_id = %s", (candidate_id,))] cand["education"] = [dict(e) for e in db.query("SELECT * FROM education WHERE candidate_id = %s", (candidate_id,))]
cand["certifications"] = [dict(c) for c in db.query("SELECT * FROM certifications WHERE candidate_id = %s", (candidate_id,))] cand["certifications"] = [dict(c) for c in db.query("SELECT * FROM certifications WHERE candidate_id = %s", (candidate_id,))]
cand = _jsonable(cand)
# Use realigned data if available, else original # Use realigned data if available, else original
realigned = item.get("realigned_cv_data") realigned = item.get("realigned_cv_data")
@@ -1231,11 +1289,13 @@ async def generate_batch_cvs(batch_id: str):
if resp.status_code == 200: if resp.status_code == 200:
# Save the PDF # Save the PDF
import tempfile
pdf_dir = os.path.join(str(static_dir), "generated") pdf_dir = os.path.join(str(static_dir), "generated")
os.makedirs(pdf_dir, exist_ok=True) os.makedirs(pdf_dir, exist_ok=True)
safe_name = f"{item['first_name']}_{item['last_name']}".replace(" ", "_") safe_name = f"{item['first_name']}_{item['last_name']}".replace(" ", "_")
filename = f"{batch['name'].replace(' ', '_')}_{safe_name}_{candidate_id[:8]}.pdf" safe_batch = str(batch['name']).replace(" ", "_").replace("/", "-")
safe_pos = str(item.get("position_title") or "role").replace(" ", "_").replace("/", "-")[:40]
item_tag = str(item["id"])[:8]
filename = f"{safe_batch}_{safe_pos}_{safe_name}_{item_tag}.pdf"
filepath = os.path.join(pdf_dir, filename) filepath = os.path.join(pdf_dir, filename)
with open(filepath, "wb") as f: with open(filepath, "wb") as f:
f.write(resp.content) f.write(resp.content)
@@ -1245,16 +1305,24 @@ async def generate_batch_cvs(batch_id: str):
"UPDATE batch_items SET generated_cv_path = %s, updated_at = NOW() WHERE id = %s", "UPDATE batch_items SET generated_cv_path = %s, updated_at = NOW() WHERE id = %s",
(f"/static/generated/{filename}", item["id"]) (f"/static/generated/{filename}", item["id"])
) )
results.append({"candidate": f"{item['first_name']} {item['last_name']}", "status": "ok", "file": filename}) results.append({
"candidate": f"{item['first_name']} {item['last_name']}",
"position_title": item.get("position_title"),
"status": "ok",
"file": filename,
"path": f"/static/generated/{filename}",
})
else: else:
results.append({"candidate": f"{item['first_name']} {item['last_name']}", "status": "error", "error": resp.text[:200]}) results.append({"candidate": f"{item['first_name']} {item['last_name']}", "status": "error", "error": resp.text[:200]})
except Exception as e: except Exception as e:
results.append({"candidate": f"{item['first_name']} {item['last_name']}", "status": "error", "error": str(e)}) results.append({"candidate": f"{item['first_name']} {item['last_name']}", "status": "error", "error": str(e)})
# Update batch status to exported ok_count = len([r for r in results if r["status"] == "ok"])
db.execute("UPDATE cv_batches SET status = 'exported', updated_at = NOW() WHERE id = %s", (batch_id,)) # Only mark exported if at least one CV succeeded
if ok_count:
db.execute("UPDATE cv_batches SET status = 'exported', updated_at = NOW() WHERE id = %s", (batch_id,))
return {"success": True, "generated": len([r for r in results if r["status"] == "ok"]), "results": results} return {"success": True, "generated": ok_count, "results": results}
@app.get("/api/batches/{batch_id}/download-zip") @app.get("/api/batches/{batch_id}/download-zip")
@@ -1273,6 +1341,7 @@ async def download_batch_zip(batch_id: str):
FROM batch_items bi FROM batch_items bi
JOIN candidates c ON c.id = bi.candidate_id JOIN candidates c ON c.id = bi.candidate_id
WHERE bi.batch_id = %s WHERE bi.batch_id = %s
AND bi.status = 'approved'
AND bi.generated_cv_path IS NOT NULL AND bi.generated_cv_path IS NOT NULL
AND bi.generated_cv_path <> '' AND bi.generated_cv_path <> ''
ORDER BY bi.position_title, c.last_name, c.first_name ORDER BY bi.position_title, c.last_name, c.first_name