Real Excel export for the Jobs list

- GET /jobs/export (jobs.export): styled .xlsx via openpyxl — branded
  banner, dark-green frozen header with auto-filter, zebra rows, color-
  coded status, bulleted requirements/nice-to-have, date formatting.
  Honors the same filters as /jobs/fetch.
- Export button on the Jobs screen now downloads the file through the
  authenticated downloadFile helper, carrying the active UI filters,
  with an Exporting... busy state (was a fake success toast).
- openpyxl pinned in backend requirements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/8/head
Talha Ahmed 2026-08-19 20:10:42 +05:00
parent 9eb1a5f311
commit 202d05c4f9
5 changed files with 225 additions and 4 deletions

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter,Depends,Query
from fastapi import APIRouter,Depends,Query,Response
from fastapi.responses import FileResponse,JSONResponse
from fastapi import HTTPException
from db_setup import get_session
@ -14,6 +14,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from users.permissions import PermissionTag, require_permission
from job.job_post.views import JobPost,JobPostCreate
from job_assist.execute_agent import run_field_assist
from job.job_post.export import build_jobs_workbook
import logging
from users.views import User
from job.job_post.models import SocialPlatform
@ -465,6 +466,35 @@ async def fetch_jobs(
raise HTTPException(status_code=500,detail=str(e))
@router.get("/jobs/export")
async def export_jobs(
search: str | None = Query(None),
department: str | None = Query(None),
requisition_status: str | None = Query(None),
employment_type: str | None = Query(None),
active_only: bool = Query(False),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EXPORT)),
session: AsyncSession = Depends(get_session),
):
"""Styled .xlsx of the requisition list — same filters as /jobs/fetch, no paging."""
try:
service=JobPost(session=session)
data,_=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status,
employment_type=employment_type,top=None,skip=0,active_only=active_only,
)
filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx"
return Response(
content=build_jobs_workbook(data),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition":f'attachment; filename="{filename}"'},
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/fetch_by_id")
async def fetch_candidate_by_id(
candidate_id: str = Query(...),

View File

@ -0,0 +1,160 @@
"""Styled .xlsx export of job requisitions — openpyxl only.
Pure module: no FastAPI imports and no HTTPException.
Takes the already-serialized rows from JobPost.fetch_jobs (serialize_job_row
dicts) so the export always matches what the Jobs screen shows, filters
included. Returns the finished workbook as bytes for a Response body.
"""
from __future__ import annotations
from datetime import datetime
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
BRAND_DARK = "0B3D2E" # header/banner green, matches the app chrome
BRAND_STRIPE = "EFF7F2" # zebra row tint
BORDER_TINT = "CBDCD2"
STATUS_LABELS = {"open": "Open", "closed": "Closed", "on_hold": "On Hold"}
STATUS_COLORS = {"open": "1B7F4B", "closed": "B3261E", "on_hold": "9A6700"}
# (header, column width)
COLUMNS = [
("Title", 34),
("Department", 16),
("Location", 20),
("Type", 12),
("Platform", 12),
("Vacancies", 11),
("Experience", 13),
("Salary", 16),
("Status", 10),
("Publishing", 12),
("Recruiter", 18),
("Created By", 18),
("Created", 13),
("Requirements", 46),
("Nice to Have", 34),
("Description", 60),
]
_THIN = Side(style="thin", color=BORDER_TINT)
_BORDER = Border(left=_THIN, right=_THIN, top=_THIN, bottom=_THIN)
def _experience(row) -> str:
lo, hi = row.get("experience_min"), row.get("experience_max")
if lo is None and hi is None:
return ""
if lo is not None and hi is not None:
return f"{lo}-{hi} years"
return f"{lo if lo is not None else hi}+ years"
def _bullets(items) -> str:
return "\n".join(f"{str(i).strip()}" for i in (items or []) if str(i).strip())
def _created(row):
raw = row.get("created_at")
if not raw:
return None
try:
return datetime.fromisoformat(raw).replace(tzinfo=None)
except ValueError:
return None
def build_jobs_workbook(rows) -> bytes:
wb = Workbook()
ws = wb.active
ws.title = "Jobs"
ws.sheet_properties.tabColor = BRAND_DARK
ws.sheet_view.showGridLines = False
last_col = get_column_letter(len(COLUMNS))
for idx, (_, width) in enumerate(COLUMNS, start=1):
ws.column_dimensions[get_column_letter(idx)].width = width
# Banner
ws.merge_cells(f"A1:{last_col}1")
banner = ws["A1"]
banner.value = "Jobs Export"
banner.font = Font(size=16, bold=True, color=BRAND_DARK)
banner.alignment = Alignment(vertical="center")
ws.row_dimensions[1].height = 30
ws.merge_cells(f"A2:{last_col}2")
sub = ws["A2"]
sub.value = (
f"TalentFlow · generated {datetime.now().strftime('%d %b %Y, %H:%M')} · "
f"{len(rows)} requisition{'s' if len(rows) != 1 else ''}"
)
sub.font = Font(size=10, color="6B7A72")
ws.row_dimensions[3].height = 6
# Header
header_row = 4
for idx, (label, _) in enumerate(COLUMNS, start=1):
cell = ws.cell(row=header_row, column=idx, value=label)
cell.font = Font(bold=True, color="FFFFFF", size=11)
cell.fill = PatternFill("solid", fgColor=BRAND_DARK)
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.border = _BORDER
ws.row_dimensions[header_row].height = 22
# Data
top = Alignment(vertical="top", wrap_text=False)
wrap = Alignment(vertical="top", wrap_text=True)
center = Alignment(horizontal="center", vertical="top")
for r, row in enumerate(rows, start=header_row + 1):
status_key = row.get("requisition_status") or ""
values = [
row.get("title") or "",
row.get("department") or "",
row.get("location") or "",
row.get("employment_type") or "",
row.get("platform") or "",
row.get("vacancies"),
_experience(row),
row.get("salary") or "",
STATUS_LABELS.get(status_key, status_key),
row.get("status") or "",
row.get("recruiter_name") or "",
row.get("created_by_name") or "",
_created(row),
_bullets(row.get("requirements")),
_bullets(row.get("optional_skills")),
(row.get("description") or "").strip(),
]
stripe = r % 2 == 0
for c, value in enumerate(values, start=1):
cell = ws.cell(row=r, column=c, value=value)
cell.border = _BORDER
cell.alignment = top
if stripe:
cell.fill = PatternFill("solid", fgColor=BRAND_STRIPE)
ws.cell(row=r, column=1).font = Font(bold=True)
ws.cell(row=r, column=6).alignment = center
status_cell = ws.cell(row=r, column=9)
status_cell.alignment = center
if status_key in STATUS_COLORS:
status_cell.font = Font(bold=True, color=STATUS_COLORS[status_key])
created_cell = ws.cell(row=r, column=13)
if created_cell.value is not None:
created_cell.number_format = "dd mmm yyyy"
for c in (14, 15, 16):
ws.cell(row=r, column=c).alignment = wrap
last_row = header_row + max(len(rows), 1)
ws.auto_filter.ref = f"A{header_row}:{last_col}{last_row}"
ws.freeze_panes = f"A{header_row + 1}"
buf = BytesIO()
wb.save(buf)
return buf.getvalue()

View File

@ -44,3 +44,4 @@ langgraph==1.2.10 # StateGraph agent framework in agent/agent_setup.py
# editable from the repo root — run once per environment:
# pip install -e ..
# Its dependencies are already satisfied by the pins above.
openpyxl==3.1.5

View File

@ -1,4 +1,4 @@
import { request } from '../lib/apiClient'
import { downloadFile, request } from '../lib/apiClient'
/**
* Job requisitions backend/job/app.py `GET /jobs/fetch`.
@ -65,6 +65,22 @@ export function toJobView(row) {
const LABEL_TO_STATUS = { Open: 'open', Closed: 'closed', 'On Hold': 'on_hold' }
/**
* Styled .xlsx download of the requisition list GET /jobs/export
* (jobs.export). Same filters as list(); `status` takes the UI label.
* downloadFile triggers the browser save from the Content-Disposition name.
*/
export function exportXlsx({ search, department, status, employmentType } = {}) {
return downloadFile('/jobs/export', {
params: {
search: search || undefined,
department: department || undefined,
requisition_status: status ? (LABEL_TO_STATUS[status] ?? status) : undefined,
employment_type: employmentType || undefined,
},
})
}
export function update(jobPostId, body) {
return request('/jobs/update', {
method: 'PATCH',

View File

@ -181,6 +181,20 @@ export default function Jobs() {
const openCount = jobs.filter((j) => j.status === 'Open').length
const [exporting, setExporting] = useState(false)
async function exportJobs() {
if (exporting) return
setExporting(true)
try {
await jobsApi.exportXlsx({ search: q, department: dept, status, employmentType: type })
toast('Jobs exported to Excel', 'success')
} catch (err) {
toast(friendlyAuthError(err, 'Could not export jobs'), 'error')
} finally {
setExporting(false)
}
}
const columns = [
{
key: 'title', label: 'Job Title', sortable: true,
@ -224,8 +238,8 @@ export default function Jobs() {
<p className="page-sub">{jobs.length} requisitions · {openCount} currently open</p>
</div>
<div className="page-head-actions">
<button className="btn btn-secondary" onClick={() => toast('Jobs exported to CSV', 'success')}>
<Icon name="download" /> Export
<button className="btn btn-secondary" onClick={exportJobs} disabled={exporting}>
<Icon name="download" /> {exporting ? 'Exporting…' : 'Export'}
</button>
{can('job_board.create') && (
<button className="btn btn-primary" onClick={() => setCreating(true)}>