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
parent
9eb1a5f311
commit
202d05c4f9
|
|
@ -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(...),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
Loading…
Reference in New Issue