feat: batch polish — edit/delete positions, ZIP export, exports page, candidate batch links
This commit is contained in:
216
main.py
216
main.py
@@ -205,12 +205,23 @@ async def get_candidate(candidate_id: str):
|
||||
)
|
||||
skills_with_years.append(s_dict)
|
||||
|
||||
batches = db.query("""
|
||||
SELECT bi.id AS item_id, bi.batch_id, bi.position_title, bi.match_score,
|
||||
bi.status AS item_status, bi.generated_cv_path, bi.updated_at AS item_updated_at,
|
||||
b.name AS batch_name, b.status AS batch_status
|
||||
FROM batch_items bi
|
||||
JOIN cv_batches b ON b.id = bi.batch_id
|
||||
WHERE bi.candidate_id = %s
|
||||
ORDER BY bi.updated_at DESC
|
||||
""", (candidate_id,), fetch='all')
|
||||
|
||||
return {
|
||||
"candidate": dict(candidate),
|
||||
"skills": skills_with_years,
|
||||
"experience": [dict(e) for e in experience],
|
||||
"education": [dict(e) for e in education],
|
||||
"certifications": [dict(c) for c in certs]
|
||||
"certifications": [dict(c) for c in certs],
|
||||
"batches": [dict(b) for b in batches],
|
||||
}
|
||||
|
||||
|
||||
@@ -531,27 +542,69 @@ 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):
|
||||
where_parts = []
|
||||
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 <> ''"]
|
||||
params = []
|
||||
if req_id:
|
||||
where_parts.append("requirement_request_id = %s")
|
||||
params.append(req_id)
|
||||
if candidate_id:
|
||||
where_parts.append("candidate_id = %s")
|
||||
where_parts.append("bi.candidate_id = %s")
|
||||
params.append(candidate_id)
|
||||
|
||||
where_clause = "WHERE " + " AND ".join(where_parts) if where_parts else ""
|
||||
|
||||
rows = db.query(f"""
|
||||
SELECT gc.*, c.first_name, c.last_name
|
||||
if batch_id:
|
||||
where_parts.append("bi.batch_id = %s")
|
||||
params.append(batch_id)
|
||||
|
||||
where_clause = "WHERE " + " AND ".join(where_parts)
|
||||
batch_rows = db.query(f"""
|
||||
SELECT bi.id,
|
||||
bi.candidate_id,
|
||||
bi.batch_id,
|
||||
bi.position_title,
|
||||
bi.match_score,
|
||||
bi.match_reasoning,
|
||||
bi.status AS item_status,
|
||||
bi.generated_cv_path,
|
||||
bi.created_at,
|
||||
bi.updated_at,
|
||||
c.first_name,
|
||||
c.last_name,
|
||||
b.name AS batch_name,
|
||||
b.status AS batch_status,
|
||||
b.template_id,
|
||||
'batch' AS source
|
||||
FROM batch_items bi
|
||||
JOIN candidates c ON c.id = bi.candidate_id
|
||||
JOIN cv_batches b ON b.id = bi.batch_id
|
||||
{where_clause}
|
||||
ORDER BY bi.updated_at DESC
|
||||
""", params if params else None, fetch='all')
|
||||
|
||||
# Keep legacy requirements-generated rows visible until fully retired.
|
||||
legacy_where = []
|
||||
legacy_params = []
|
||||
if req_id:
|
||||
legacy_where.append("gc.requirement_request_id = %s")
|
||||
legacy_params.append(req_id)
|
||||
if candidate_id:
|
||||
legacy_where.append("gc.candidate_id = %s")
|
||||
legacy_params.append(candidate_id)
|
||||
legacy_clause = ("WHERE " + " AND ".join(legacy_where)) if legacy_where else ""
|
||||
legacy_rows = db.query(f"""
|
||||
SELECT gc.id, gc.candidate_id, gc.requirement_request_id AS batch_id,
|
||||
gc.position_title, gc.match_score, gc.match_reasoning, gc.status AS item_status,
|
||||
NULL AS generated_cv_path, gc.created_at, gc.updated_at,
|
||||
c.first_name, c.last_name,
|
||||
rr.title AS batch_name, rr.status AS batch_status,
|
||||
gc.template_id, 'legacy' AS source
|
||||
FROM generated_cvs gc
|
||||
LEFT JOIN candidates c ON gc.candidate_id = c.id
|
||||
{where_clause}
|
||||
LEFT JOIN requirement_requests rr ON rr.id = gc.requirement_request_id
|
||||
{legacy_clause}
|
||||
ORDER BY gc.created_at DESC
|
||||
""", params if params else None, fetch='all')
|
||||
|
||||
return {"generated_cvs": [dict(r) for r in rows]}
|
||||
""", legacy_params if legacy_params else None, fetch='all')
|
||||
|
||||
combined = [dict(r) for r in (batch_rows or [])] + [dict(r) for r in (legacy_rows or [])]
|
||||
combined.sort(key=lambda r: str(r.get("updated_at") or r.get("created_at") or ""), reverse=True)
|
||||
return {"generated_cvs": combined}
|
||||
|
||||
@app.get("/api/generated-cvs/{gen_id}")
|
||||
async def get_generated_cv(gen_id: str):
|
||||
@@ -833,8 +886,27 @@ async def render_generated_cv_pdf(gen_id: str):
|
||||
|
||||
@app.get("/api/batches")
|
||||
async def list_batches():
|
||||
"""List all CV batches."""
|
||||
rows = db.query("SELECT * FROM cv_batches ORDER BY created_at DESC")
|
||||
"""List all CV batches with item/export summary chips."""
|
||||
rows = db.query("""
|
||||
SELECT b.*,
|
||||
COALESCE(stats.total_items, 0) AS total_items,
|
||||
COALESCE(stats.approved_items, 0) AS approved_items,
|
||||
COALESCE(stats.proposed_items, 0) AS proposed_items,
|
||||
COALESCE(stats.removed_items, 0) AS removed_items,
|
||||
COALESCE(stats.generated_items, 0) AS generated_items
|
||||
FROM cv_batches b
|
||||
LEFT JOIN (
|
||||
SELECT batch_id,
|
||||
COUNT(*) AS total_items,
|
||||
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
|
||||
FROM batch_items
|
||||
GROUP BY batch_id
|
||||
) stats ON stats.batch_id = b.id
|
||||
ORDER BY b.created_at DESC
|
||||
""")
|
||||
return {"batches": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@@ -1080,12 +1152,30 @@ async def update_batch_positions(batch_id: str, request: Request):
|
||||
"""Update the positions array on a batch (add/edit/remove positions)."""
|
||||
body = await request.json()
|
||||
positions = body.get("positions", [])
|
||||
title_renames = body.get("title_renames") or {} # {old_title: new_title}
|
||||
|
||||
batch = db.query("SELECT * FROM cv_batches WHERE id = %s", (batch_id,), fetch='one')
|
||||
if not batch:
|
||||
raise HTTPException(404, "Batch not found")
|
||||
|
||||
row = db.execute(
|
||||
"UPDATE cv_batches SET positions = %s, updated_at = NOW() WHERE id = %s RETURNING *",
|
||||
(Json(positions), batch_id)
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "Batch not found")
|
||||
|
||||
# When a position title is renamed, remap linked batch items.
|
||||
if isinstance(title_renames, dict):
|
||||
for old_title, new_title in title_renames.items():
|
||||
if not old_title or not new_title or old_title == new_title:
|
||||
continue
|
||||
db.execute(
|
||||
"""UPDATE batch_items SET position_title = %s, updated_at = NOW()
|
||||
WHERE batch_id = %s AND position_title = %s""",
|
||||
(new_title, batch_id, old_title)
|
||||
)
|
||||
|
||||
return dict(row)
|
||||
|
||||
|
||||
@@ -1167,6 +1257,96 @@ async def generate_batch_cvs(batch_id: str):
|
||||
return {"success": True, "generated": len([r for r in results if r["status"] == "ok"]), "results": results}
|
||||
|
||||
|
||||
@app.get("/api/batches/{batch_id}/download-zip")
|
||||
async def download_batch_zip(batch_id: str):
|
||||
"""Zip all generated PDFs for a batch into one download."""
|
||||
import io
|
||||
import zipfile
|
||||
from urllib.parse import unquote
|
||||
|
||||
batch = db.query("SELECT * FROM cv_batches WHERE id = %s", (batch_id,), fetch='one')
|
||||
if not batch:
|
||||
raise HTTPException(404, "Batch not found")
|
||||
|
||||
items = db.query("""
|
||||
SELECT bi.generated_cv_path, bi.position_title, c.first_name, c.last_name
|
||||
FROM batch_items bi
|
||||
JOIN candidates c ON c.id = bi.candidate_id
|
||||
WHERE bi.batch_id = %s
|
||||
AND bi.generated_cv_path IS NOT NULL
|
||||
AND bi.generated_cv_path <> ''
|
||||
ORDER BY bi.position_title, c.last_name, c.first_name
|
||||
""", (batch_id,))
|
||||
|
||||
if not items:
|
||||
raise HTTPException(400, "No generated CVs available to zip")
|
||||
|
||||
buf = io.BytesIO()
|
||||
added = 0
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for item in items:
|
||||
rel = unquote(str(item["generated_cv_path"] or "")).lstrip("/")
|
||||
# Paths are stored as /static/generated/<file>
|
||||
if rel.startswith("static/"):
|
||||
file_path = static_dir / rel[len("static/"):]
|
||||
else:
|
||||
file_path = Path(rel)
|
||||
if not file_path.exists():
|
||||
# Also try direct under static/generated
|
||||
name_only = Path(rel).name
|
||||
file_path = static_dir / "generated" / name_only
|
||||
if not file_path.exists():
|
||||
continue
|
||||
safe_person = f"{item.get('first_name') or ''}_{item.get('last_name') or ''}".strip("_").replace(" ", "_") or "candidate"
|
||||
safe_pos = (item.get("position_title") or "role").replace(" ", "_")
|
||||
arcname = f"{safe_pos}/{safe_person}_{file_path.name}"
|
||||
zf.write(str(file_path), arcname=arcname)
|
||||
added += 1
|
||||
|
||||
if added == 0:
|
||||
raise HTTPException(404, "Generated CV files not found on disk")
|
||||
|
||||
buf.seek(0)
|
||||
safe_batch = str(batch.get("name") or "batch").replace(" ", "_")
|
||||
return Response(
|
||||
content=buf.getvalue(),
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": f'attachment; filename="{safe_batch}_CVs.zip"'}
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/batches/{batch_id}/items/{item_id}/generated")
|
||||
async def clear_batch_item_generated(batch_id: str, item_id: str):
|
||||
"""Clear a generated CV path (optional file cleanup for batch exports)."""
|
||||
item = db.query(
|
||||
"SELECT * FROM batch_items WHERE id = %s AND batch_id = %s",
|
||||
(item_id, batch_id),
|
||||
fetch='one'
|
||||
)
|
||||
if not item:
|
||||
raise HTTPException(404, "Batch item not found")
|
||||
|
||||
path = item.get("generated_cv_path")
|
||||
if path:
|
||||
rel = str(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():
|
||||
file_path.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
row = db.execute(
|
||||
"""UPDATE batch_items SET generated_cv_path = NULL, updated_at = NOW()
|
||||
WHERE id = %s AND batch_id = %s RETURNING *""",
|
||||
(item_id, batch_id)
|
||||
)
|
||||
return dict(row) if row else {"success": True}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# SERVE FRONTEND
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user