diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc index b18247e..8603be1 100644 Binary files a/__pycache__/main.cpython-312.pyc and b/__pycache__/main.cpython-312.pyc differ diff --git a/main.py b/main.py index c447f46..eac1f5d 100644 --- a/main.py +++ b/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/ + 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 # ============================================================ diff --git a/static/app.js b/static/app.js index 71880da..759c20a 100644 --- a/static/app.js +++ b/static/app.js @@ -166,6 +166,22 @@ async function viewCandidate(id) { `).join('') || '

No certifications extracted

'} +
+

Batches (${(data.batches || []).length})

+ ${(data.batches || []).length ? (data.batches || []).map(b => ` +
+
+ ${escapeHtml(b.batch_name || 'Batch')} +
${escapeHtml(b.position_title || 'Unassigned')} · ${escapeHtml(b.item_status || '')} · Score ${b.match_score || 0}%
+
+
+ ${escapeHtml(b.batch_status || 'draft')} + ${b.generated_cv_path ? `PDF` : ''} + +
+
+ `).join('') : '

Not linked to any batches yet.

'} +

Raw CV Text

${c.raw_cv_text || 'No raw text available'}
@@ -354,26 +370,52 @@ async function deleteTemplate(id) { async function loadGeneratedCVs() { const data = await api('/api/generated-cvs'); const list = document.getElementById('generated-list'); - if (!data.generated_cvs.length) { - list.innerHTML = '

No generated CVs yet. Match and generate from a requirement request.

'; + const rows = data.generated_cvs || []; + if (!rows.length) { + list.innerHTML = '

No generated CVs yet. Approve candidates in a Batch and click Generate CVs.

'; return; } - list.innerHTML = data.generated_cvs.map(g => ` + list.innerHTML = rows.map(g => { + const source = g.source || 'legacy'; + const status = g.item_status || g.status || 'draft'; + const openBtn = g.generated_cv_path + ? `Open PDF` + : ``; + const zipBtn = (source === 'batch' && g.batch_id) + ? `Batch ZIP` + : ''; + const delBtn = source === 'batch' + ? `` + : ``; + + return `
- ${g.first_name || ''} ${g.last_name || ''} - · ${g.position_title || 'N/A'} -
Generated: ${formatDate(g.generation_date)} · Score: ${g.match_score || 0}%
+ ${escapeHtml(g.first_name || '')} ${escapeHtml(g.last_name || '')} + · ${escapeHtml(g.position_title || 'N/A')} +
+ Batch: ${escapeHtml(g.batch_name || '—')} · Score: ${g.match_score || 0}% · + ${g.updated_at ? ('Updated: ' + formatDate(g.updated_at)) : (g.created_at ? ('Created: ' + formatDate(g.created_at)) : '')} +
- ${g.status} - - + ${escapeHtml(status)} + ${source === 'batch' ? 'Batch export' : 'Legacy'} + ${openBtn} + ${zipBtn} + ${delBtn}
-
- `).join(''); +
`; + }).join(''); +} + +async function deleteBatchGenerated(batchId, itemId) { + if (!confirm('Remove this generated CV from the exports list?')) return; + await api('/api/batches/' + batchId + '/items/' + itemId + '/generated', 'DELETE'); + toast('Generated CV removed'); + loadGeneratedCVs(); } async function viewGeneratedCV(id) { diff --git a/static/batches.js b/static/batches.js index d0dcbea..0089ace 100644 --- a/static/batches.js +++ b/static/batches.js @@ -32,19 +32,31 @@ async function loadBatches() { list.innerHTML = '

No batches yet. Create one manually or upload a requirements document.

'; return; } - list.innerHTML = data.batches.map(b => ` + list.innerHTML = data.batches.map(b => { + const statusClass = b.status === 'active' ? 'green' : b.status === 'exported' ? 'blue' : 'orange'; + const positionsCount = (b.positions && b.positions.length) ? b.positions.length : 0; + const approved = b.approved_items || 0; + const proposed = b.proposed_items || 0; + const generated = b.generated_items || 0; + return `
- ${b.name} - ${b.status} + ${escapeHtml(b.name)} + ${escapeHtml(b.status || 'draft')}
${b.description ? '

' + escapeHtml(b.description) + '

' : ''}
Created: ${new Date(b.created_at).toLocaleDateString()} - ${b.positions && b.positions.length ? ' · Positions: ' + b.positions.length : ''} + · Positions: ${positionsCount} +
+
+ Proposed ${proposed} + Approved ${approved} + Generated ${generated}
- `).join(''); + `; + }).join(''); } catch (e) { list.innerHTML = '

Error: ' + e.message + '

'; } @@ -187,43 +199,53 @@ function renderBatchDetail(data) { }); // Build positions HTML - const positions = batch.positions || []; - let positionsHtml = ''; - if (positions.length) { - positionsHtml = '
Positions
'; - positions.forEach(p => { - const posItems = byPosition[p.job_title] || []; - positionsHtml += ` -
-

${escapeHtml(p.job_title || '')} ${p.num_positions > 1 ? '×' + p.num_positions : ''}

- ${p.description ? '

' + escapeHtml(p.description) + '

' : ''} - ${p.required_skills && p.required_skills.length ? '
Required: ' + p.required_skills.map(s => '' + escapeHtml(s) + '').join(' ') + '
' : ''} - ${p.required_years ? '
Min years: ' + p.required_years + '
' : ''} - ${p.required_certs && p.required_certs.length ? '
Certs: ' + p.required_certs.map(c => '' + escapeHtml(c) + '').join(' ') + '
' : ''} - ${posItems.length ? ` -
- ${posItems.map(item => ` -
-
- ${escapeHtml(item.first_name || '')} ${escapeHtml(item.last_name || '')} - ${item.match_score}% - ${item.status} - ${item.match_reasoning ? '
' + escapeHtml(item.match_reasoning) + '
' : ''} - ${item.generated_cv_path ? 'Download CV' : ''} -
-
- ${item.status !== 'approved' ? '' : ''} - ${item.status !== 'removed' ? '' : ''} -
-
- `).join('')} + const positions = batch.positions || []; + let positionsHtml = ''; + if (positions.length) { + positionsHtml = '
Positions
'; + positions.forEach((p, idx) => { + const posItems = byPosition[p.job_title] || []; + positionsHtml += ` +
+
+
+

${escapeHtml(p.job_title || '')} ${p.num_positions > 1 ? '×' + p.num_positions : ''}

+ ${p.description ? '

' + escapeHtml(p.description) + '

' : ''} + ${p.required_skills && p.required_skills.length ? '
Required: ' + p.required_skills.map(s => '' + escapeHtml(s) + '').join(' ') + '
' : ''} + ${p.required_years ? '
Min years: ' + p.required_years + '
' : ''} + ${p.required_certs && p.required_certs.length ? '
Certs: ' + p.required_certs.map(c => '' + escapeHtml(c) + '').join(' ') + '
' : ''} +
+
+ + +
- ` : '

No candidates matched yet. Click "Analyze & Match" to find candidates.

'} -
- `; - }); - positionsHtml += '
'; - } + ${posItems.length ? ` +
+ ${posItems.map(item => ` +
+
+ ${escapeHtml(item.first_name || '')} ${escapeHtml(item.last_name || '')} + ${item.match_score}% + ${item.status} + ${item.match_reasoning ? '
' + escapeHtml(item.match_reasoning) + '
' : ''} + ${item.generated_cv_path ? 'Download CV' : ''} +
+
+ ${item.status !== 'approved' ? `` : ''} + ${item.status !== 'removed' ? `` : ''} +
+
+ `).join('')} +
+ ` : '

No candidates matched yet. Click "Analyze & Match" to find candidates.

'} +
+ `; + }); + positionsHtml += '
'; + } else { + positionsHtml = '

No positions yet. Add one below, or upload a requirements document when creating a batch.

'; + } // Unassigned items const unassigned = byPosition['Unassigned'] || []; @@ -255,6 +277,7 @@ function renderBatchDetail(data) {
+ @@ -346,7 +369,7 @@ async function addPosition() { const certs = document.getElementById('new-pos-certs').value.split(',').map(s => s.trim()).filter(s => s); const desc = document.getElementById('new-pos-desc').value.trim(); - const positions = currentBatch.batch.positions || []; + const positions = [...(currentBatch.batch.positions || [])]; positions.push({ job_title: title, num_positions: qty, @@ -371,6 +394,111 @@ async function addPosition() { } } +function editPosition(idx) { + const positions = currentBatch?.batch?.positions || []; + const p = positions[idx]; + if (!p) return; + + const body = ` +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+ + `; + showModal(body, 'Edit Position'); +} + +async function saveEditedPosition(idx) { + const title = document.getElementById('edit-pos-title').value.trim(); + if (!title) { toast('Job title required', 'error'); return; } + + const positions = [...(currentBatch.batch.positions || [])]; + if (!positions[idx]) return; + + const oldTitle = positions[idx].job_title; + positions[idx] = { + ...positions[idx], + job_title: title, + num_positions: parseInt(document.getElementById('edit-pos-qty').value) || 1, + required_years: document.getElementById('edit-pos-years').value ? parseInt(document.getElementById('edit-pos-years').value) : null, + required_skills: document.getElementById('edit-pos-skills').value.split(',').map(s => s.trim()).filter(Boolean), + required_certs: document.getElementById('edit-pos-certs').value.split(',').map(s => s.trim()).filter(Boolean), + description: document.getElementById('edit-pos-desc').value.trim() + }; + + const payload = { positions }; + if (oldTitle && oldTitle !== title) { + payload.title_renames = { [oldTitle]: title }; + } + + try { + await fetch('/api/batches/' + currentBatchId + '/positions', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + closeModal(); + toast('Position updated'); + openBatch(currentBatchId); + } catch (e) { + toast('Error: ' + e.message, 'error'); + } +} + +async function deletePosition(idx) { + const positions = [...(currentBatch?.batch?.positions || [])]; + const p = positions[idx]; + if (!p) return; + if (!confirm('Delete position "' + (p.job_title || '') + '"? Candidate matches for this title will remain listed under Unassigned until rematched.')) return; + + positions.splice(idx, 1); + try { + await fetch('/api/batches/' + currentBatchId + '/positions', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ positions }) + }); + toast('Position deleted'); + openBatch(currentBatchId); + } catch (e) { + toast('Error: ' + e.message, 'error'); + } +} + +async function downloadBatchZip() { + if (!currentBatchId) return; + const generatedCount = (currentBatch.items || []).filter(i => i.generated_cv_path).length; + if (!generatedCount) { + toast('No generated CVs to download yet', 'error'); + return; + } + toast('Preparing ZIP...'); + window.open('/api/batches/' + currentBatchId + '/download-zip', '_blank'); +} + // ============================================================ // GENERATE CVs // ============================================================ diff --git a/static/index.html b/static/index.html index 534a37f..e766165 100644 --- a/static/index.html +++ b/static/index.html @@ -201,8 +201,8 @@
- + - + \ No newline at end of file