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")
|
@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,7 +1221,26 @@ 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:
|
||||||
raise HTTPException(404, "Batch not found")
|
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["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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user