From 040882deb53997a2f20e503806ab091a52a0b4a3 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 29 Jul 2026 08:31:35 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20batch=20polish=20=E2=80=94=20edit/delet?= =?UTF-8?q?e=20positions,=20ZIP=20export,=20exports=20page,=20candidate=20?= =?UTF-8?q?batch=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __pycache__/main.cpython-312.pyc | Bin 67320 -> 76417 bytes main.py | 216 ++++++++++++++++++++++++++++--- static/app.js | 64 +++++++-- static/batches.js | 212 ++++++++++++++++++++++++------ static/index.html | 4 +- 5 files changed, 423 insertions(+), 73 deletions(-) diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc index b18247e03c433454e9bb373dc7df76ca13864847..8603be173d01247ce17af09a62b6840bdc997797 100644 GIT binary patch delta 12530 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 C#egFK delta 5240 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 Hvr+#C=7xPc 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