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")
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)."""
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 = []
if candidate_id:
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 = 'proposed') AS proposed_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
GROUP BY batch_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):
"""Update a batch item (approve, remove, edit realignment)."""
body = await request.json()
row = db.execute(
"""UPDATE batch_items SET status = %s, realigned_cv_data = %s, updated_at = NOW()
WHERE id = %s AND batch_id = %s RETURNING *""",
(body.get("status", "proposed"), Json(body.get("realigned_cv_data")), item_id, batch_id)
)
status = body.get("status", "proposed")
realigned = body.get("realigned_cv_data")
# 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:
raise HTTPException(404, "Batch item not found")
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):
"""Generate CVs for all approved items in the batch using the selected template."""
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')
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["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 = _jsonable(cand)
# Use realigned data if available, else original
realigned = item.get("realigned_cv_data")
@@ -1231,11 +1289,13 @@ async def generate_batch_cvs(batch_id: str):
if resp.status_code == 200:
# Save the PDF
import tempfile
pdf_dir = os.path.join(str(static_dir), "generated")
os.makedirs(pdf_dir, exist_ok=True)
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)
with open(filepath, "wb") as f:
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",
(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:
results.append({"candidate": f"{item['first_name']} {item['last_name']}", "status": "error", "error": resp.text[:200]})
except Exception as e:
results.append({"candidate": f"{item['first_name']} {item['last_name']}", "status": "error", "error": str(e)})
# Update batch status to exported
db.execute("UPDATE cv_batches SET status = 'exported', updated_at = NOW() WHERE id = %s", (batch_id,))
ok_count = len([r for r in results if r["status"] == "ok"])
# 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")
@@ -1273,6 +1341,7 @@ async def download_batch_zip(batch_id: str):
FROM batch_items bi
JOIN candidates c ON c.id = bi.candidate_id
WHERE bi.batch_id = %s
AND bi.status = 'approved'
AND bi.generated_cv_path IS NOT NULL
AND bi.generated_cv_path <> ''
ORDER BY bi.position_title, c.last_name, c.first_name