fix: batch generate JSON datetime serialization, unique PDF names, approved-only ZIP/exports
This commit is contained in:
Binary file not shown.
97
main.py
97
main.py
@@ -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,7 +1221,26 @@ 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:
|
||||
raise HTTPException(404, "Batch not found")
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user