feat: batch polish — edit/delete positions, ZIP export, exports page, candidate batch links

This commit is contained in:
root
2026-07-29 08:31:35 +00:00
parent 29c0a0c348
commit 040882deb5
5 changed files with 423 additions and 73 deletions

216
main.py
View File

@@ -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
# ============================================================