HR-ATS-Portal/backend/job/app.py

1646 lines
61 KiB
Python

from fastapi import APIRouter,Depends,Query,Response
from fastapi.responses import FileResponse,JSONResponse
from fastapi import HTTPException
from db_setup import get_session
from job.candidate.views import CandidateScoring,FileRead,CandidateView,extract_bank_profile_from_cv,parse_linkedin_url_from_cv
from job.interviews.views import Interview
from job.notes.views import Note
from job.activity.views import ActivityLog
from job.feedback.views import FeedbackView
from job.pipeline.views import Pipeline
from job.history.views import HistoryRecorder
from job.assignment.views import Assignment
from job.cost.views import HiringCost
from sqlalchemy.ext.asyncio import AsyncSession
from users.permissions import PermissionTag, is_hiring_manager, 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
from fastapi import UploadFile, File, Form
from dotenv import load_dotenv
from datetime import datetime, time, timezone
from pydantic import BaseModel
from uuid import UUID
from typing import Literal, Optional
import os
import uuid
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
router = APIRouter()
# A banked CV is personal data held with no job to justify it, so it is held for
# a stated period rather than forever. Stamped on the row at upload so changing
# the setting later cannot silently extend CVs already taken in.
CV_BANK_RETENTION_MONTHS = int(os.getenv("CV_BANK_RETENTION_MONTHS", "24"))
# Deterministic keyword overlap, not comprehension — the floor only decides who
# is worth telling a recruiter about, never who is qualified.
CV_BANK_SUGGEST_THRESHOLD = int(os.getenv("CV_BANK_SUGGEST_THRESHOLD", "55"))
class MatchingAssign(BaseModel):
id: UUID
job_post_id: UUID | None = None
class CvBankScoreRequest(BaseModel):
job_id: UUID
ids: list[UUID]
class CandidateUpdate(BaseModel):
favorite: bool | None = None
rating: float | None = None
class InterviewCreate(BaseModel):
inbox_id: int
interview_date: datetime | None = None
interview_time: datetime | None = None
interview_type: str | None = None
interview_status: str | None = None
class InterviewUpdate(BaseModel):
interview_date: datetime | None = None
interview_time: datetime | None = None
interview_type: str | None = None
interview_status: str | None = None
inbox_id: int | None = None
class NoteCreate(BaseModel):
user_id: UUID
note: str
class NoteUpdate(BaseModel):
note: str | None = None
class ActivityCreate(BaseModel):
message_id: UUID | None = None
user_id: UUID | None = None
inbox_id: int | None = None
activity_type: str | None = None
activity_status: str | None = None
description: str | None = None
activity_date: datetime | None = None
activity_time: datetime | None = None
class FeedbackCreate(BaseModel):
inbox_id: int | None = None
review: str | None = None
financial_status: str | None = None
score: float | None = None
note: str | None = None
reviewed_by: UUID | None = None
class FeedbackUpdate(BaseModel):
review: str | None = None
financial_status: str | None = None
score: float | None = None
note: str | None = None
inbox_id: int | None = None
reviewed_by: UUID | None = None
class StageChange(BaseModel):
inbox_id: int | None = None
manual_upload_id: UUID | None = None
to_stage: str
change_reason: str | None = None
class JobAssignmentCreate(BaseModel):
job_post_id: UUID
user_id: UUID
assignment_role: str | None = None
class ApplicationAssignmentCreate(BaseModel):
inbox_id: int
user_id: UUID
assignment_role: str | None = None
class HiringCostCreate(BaseModel):
cost_type: str
amount: float
job_post_id: UUID | None = None
source_channel_id: int | None = None
currency: str | None = None
description: str | None = None
incurred_at: datetime | None = None
class JobUpdate(BaseModel):
title: str | None = None
department: str | None = None
location: str | None = None
employment_type: str | None = None
vacancies: int | None = None
salary: str | None = None
salary_min: float | None = None
salary_max: float | None = None
experience_min: int | None = None
experience_max: int | None = None
description: str | None = None
current_recruiter_id: UUID | None = None
hiring_manager_id: UUID | None = None
requisition_id: UUID | None = None
class JobStatusUpdate(BaseModel):
requisition_status: str
class FeedbackTemplateCreate(BaseModel):
name: str
department: str | None = None
criteria: list[str] | None = None
is_active: bool | None = None
class FeedbackTemplateUpdate(BaseModel):
name: str | None = None
department: str | None = None
criteria: list[str] | None = None
is_active: bool | None = None
@router.get("/jobs/alias")
async def get_job_alias(session: AsyncSession = Depends(get_session)):
try:
alias_lst=await SocialPlatform.list_aliases(session)
return JSONResponse(content={"data":alias_lst,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/create/candidate")
async def create_manual_candidate(
file: UploadFile = File(...),
candidate_email: str | None = Form(None),
candidate_name: str | None = Form(None),
candidate_phone: str | None = Form(None),
job_post_id: str | None = Form(None),
current_company: str | None = Form(None),
current_position: str | None = Form(None),
platform: str | None = Form(None),
experience: str | None = Form(None),
status: str | None = Form(None),
referral_by: str | None = Form(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
# Gate before any parse / DB / S3 work — only PDFs proceed.
from s3.plugins import S3ServiceError,assert_pdf
try:
assert_pdf(file.filename or "resume.pdf",file.content_type)
except S3ServiceError as e:
raise HTTPException(status_code=e.status_code,detail=e.message) from e
file_content = await file.read()
logger.info(f"Received file: {file.filename} ({len(file_content)} bytes)")
reader=FileRead(session=session,filename=file.filename,file=file_content)
# Parse first: an unreadable PDF is a 400 before any table row exists.
parsed=await reader.injest_manual_upload()
service=CandidateView(session=session)
# Atomicity lives in create_candidate: insert row → S3 Manual/{id}/{user_id}/
# → set file_path; on S3 failure the row is deleted.
data=await service.create_candidate(
candidate_email=candidate_email,
candidate_name=candidate_name,
candidate_phone=candidate_phone,
job_post_id=job_post_id,
current_company=current_company,
current_position=current_position,
platform=platform,
experience=experience,
status=status,
referral_by=referral_by,
file_name=file.filename,
full_text=parsed.get("text") or "",
current_user=current_user.get("id"),
file_bytes=file_content,
content_type=file.content_type,
)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/fetch/users")
async def fetch_users(
role_id:Optional[int]=Query(None),
top:Optional[int]=Query(None),
skip:Optional[int]=Query(None),
assigned_job_post_id:Optional[str]=Query(None),
search:Optional[str]=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
if is_hiring_manager(current_user):
raise HTTPException(
status_code=403,
detail="Hiring managers can only list candidates on their requisitions",
)
service=User(session=session)
data=await service.get_users(role_id=role_id,top=top,skip=skip,assigned_job_post_id=assigned_job_post_id)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/fetch/users/count")
async def count_candidate_users(
role_id:Optional[int]=Query(None),
search:Optional[str]=Query(None),
assigned_job_post_id:Optional[str]=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Total matching users for the Candidates pager. Called once on page open."""
try:
if is_hiring_manager(current_user):
raise HTTPException(
status_code=403,
detail="Hiring managers can only list candidates on their requisitions",
)
service=User(session=session)
total=await service.count_users(search=search,role_id=role_id,assigned_job_post_id=assigned_job_post_id)
return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/cv_upload")
async def cv_upload(
file: UploadFile = File(...),
candidate_email: str | None = Form(None),
candidate_name: str | None = Form(None),
candidate_phone: str | None = Form(None),
job_post_id: str | None = Form(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
file_content = await file.read()
logger.info(f"Received file: {file.filename} ({len(file_content)} bytes)")
service=FileRead(session=session,filename=file.filename,file=file_content)
data=await service.ingest_upload(
candidate_email=candidate_email,candidate_name=candidate_name,
current_user=current_user,
)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/cv-bank/upload")
async def cv_bank_upload(
file: UploadFile = File(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
"""Store a CV in the bank: parsed text + S3 object under Temp/{id}/.
No job, no inbox entry, no scoring — the CV waits until a recruiter picks
it up. Email/name are captured only if the CV contains them. Insert the
row first so the S3 key can use the table PK; roll the row back if S3 fails."""
from pathlib import PurePosixPath,PureWindowsPath
from job.candidate.models import Manual_UPLOAD_CANDIDATE
from job.candidate.plugins import extract_candidate_email
from s3.plugins import S3,S3ServiceError,S3Source
try:
content=await file.read()
if len(content)>15*1024*1024:
raise HTTPException(status_code=413,detail="CV must be under 15 MB")
reader=FileRead(session=session,filename=file.filename,file=content)
parsed=await reader.injest_manual_upload() # 400 on unreadable/empty PDF
text=parsed.get("text") or ""
detected,_=extract_candidate_email(text)
# One agent call for the whole profile. Banking is the only ingest path
# with no job attached, so this is the CV's only structured data until
# a recruiter scores it against a real opening.
profile=await extract_bank_profile_from_cv(text)
# Basename against both separator styles — a Windows client sends
# C:\Users\x\cv.pdf whose PosixPath name is the whole string.
original=PurePosixPath(PureWindowsPath(file.filename or "resume.pdf").name).name or "resume.pdf"
row=await Manual_UPLOAD_CANDIDATE.insert_bank_cv(
session,
candidate_email=detected or "",
candidate_name="",
full_text=text,
file_name=original,
created_by=current_user.get("id"),
pdf_bytes=content,
linkedin_url=profile.get("linkedin_url"),
profile=profile,
bank_reason="speculative",
retention_months=CV_BANK_RETENTION_MONTHS,
)
try:
uploaded=S3().upload_for_record(
content,
original,
source=S3Source.TEMP,
record_id=row.id,
owner_id="",
content_type=file.content_type,
)
except S3ServiceError as e:
await Manual_UPLOAD_CANDIDATE.delete_bank_cv(session,row.id)
raise HTTPException(status_code=e.status_code,detail=e.message) from e
except Exception:
await Manual_UPLOAD_CANDIDATE.delete_bank_cv(session,row.id)
raise
row=await Manual_UPLOAD_CANDIDATE.set_file_path(
session,row.id,uploaded["url"],file_name=uploaded.get("filename") or original,
)
return JSONResponse(content={"data":{
"id":str(row.id),
"file_name":row.file_name,
"file_path":row.file_path or None,
"candidate_email":row.candidate_email or None,
"linkedin_url":row.linkedin_url or None,
"created_at":row.created_at.isoformat() if row.created_at else None,
},"status_code":200})
except HTTPException:
raise
except Exception as e:
logger.exception("cv-bank upload failed")
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/cv-bank/fetch")
async def cv_bank_fetch(
top: int = Query(100, ge=1, le=500),
skip: int = Query(0, ge=0),
source: Literal["speculative","silver_medalist"] | None = Query(default=None),
search: str | None = Query(default=None),
skills: list[str] | None = Query(default=None),
min_years: int | None = Query(default=None, ge=0, le=60),
band: str | None = Query(default=None),
job_post_id: str | None = Query(default=None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""The CV Bank: speculative uploads plus rejected applicants who scored well.
`job_post_id` does not filter the list — it attaches the deterministic
tier-1 rank for that job and sorts by it, which is the "a role just opened,
who do we already have" view. Download a file via
GET /candidate/cv-bank/file?id=<record_id>."""
try:
service=CandidateView(session=session)
data,total=await service.list_bank(
source=source,search=search,skills=skills,min_years=min_years,
band=band,job_post_id=job_post_id,limit=top,offset=skip,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
logger.exception("cv-bank fetch failed")
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/cv-bank/score")
async def cv_bank_score(
payload: CvBankScoreRequest,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
"""Run the real ATS score on CVs already in the bank — the paid tier.
No upload: the bytes are already stored. Mirrors POST /candidate/score_inbox,
and the results land in candidates / ats_results like any other scored CV,
so a banked candidate shows up on the leaderboard the same way."""
try:
service=CandidateScoring(session=session)
data=await service.score_bank(str(payload.job_id),payload.ids,current_user)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
logger.exception("cv-bank scoring failed")
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/cv-bank/suggestions")
async def cv_bank_suggestions(
job_post_id: str = Query(...),
top: int = Query(20, ge=1, le=200),
min_rank: int | None = Query(default=None, ge=0, le=100),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Banked CVs worth looking at for one job, best first.
rank_score is deterministic keyword overlap, not an ATS score — it orders
the bank so a recruiter knows where to start. Scoring for real costs money
and happens via POST /candidate/cv-bank/score on the ones they pick."""
try:
service=CandidateView(session=session)
floor=CV_BANK_SUGGEST_THRESHOLD if min_rank is None else min_rank
rows,_=await service.list_bank(
job_post_id=job_post_id,limit=service.BANK_SCAN_CAP,offset=0,
)
data=[r for r in rows if (r.get("rank_score") or 0)>=floor][:top]
return JSONResponse(content={
"data":data,"total":len(data),"threshold":floor,"status_code":200,
})
except HTTPException:
raise
except Exception as e:
logger.exception("cv-bank suggestions failed")
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/cv-bank/file")
async def cv_bank_file(
id: str = Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""The stored CV's bytes, straight from the database. Content-Disposition
carries the original filename so browser saves are named sensibly; the
frontend preview re-types the blob and renders it inline."""
from urllib.parse import quote
from job.candidate.models import CvBankFiles,Manual_UPLOAD_CANDIDATE
try:
row=await Manual_UPLOAD_CANDIDATE.get_by_id(session,id)
if not row or row.apply_via!="cv_bank":
raise HTTPException(status_code=404,detail="CV not found in the bank")
file_row=await CvBankFiles.get(session,row.id)
if not file_row:
raise HTTPException(status_code=404,detail="CV file is missing")
name=file_row.file_name or row.file_name or "cv.pdf"
return Response(
content=file_row.data,
media_type=file_row.content_type or "application/pdf",
headers={"Content-Disposition":f"attachment; filename*=UTF-8''{quote(name)}"},
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.delete("/candidate/cv-bank/delete")
async def cv_bank_delete(
id: str = Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_DELETE)),
session: AsyncSession = Depends(get_session),
):
from job.candidate.models import Manual_UPLOAD_CANDIDATE
from s3.plugins import S3,S3ServiceError
try:
row=await Manual_UPLOAD_CANDIDATE.delete_bank_cv(session,id)
if not row:
raise HTTPException(status_code=404,detail="CV not found in the bank")
if (row.file_path or "").strip():
try:
S3().delete_object(row.file_path)
except S3ServiceError:
logger.warning("could not delete bank CV from S3 key=%s",row.file_path[:120])
FileRead.discard_upload(row.file_path)
return JSONResponse(content={"data":{"id":str(row.id),"deleted":True},"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/matching/fetch")
async def matching_fetch(
top: int = Query(10, ge=1, le=500),
skip: int = Query(0, ge=0),
assigned: bool | None = Query(default=None),
search: str | None = Query(default=None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Job Matching queue: CV Import 'No job' rows (apply_via=cv_bank)."""
try:
service=CandidateView(session=session)
data,total=await service.list_matching(
assigned=assigned,search=search,limit=top,offset=skip,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/matching/fetch_by_id")
async def matching_fetch_by_id(
id: str = Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
data=await service.get_matching(id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/matching/assign")
async def matching_assign(
payload: MatchingAssign,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
"""Set job_post_id on a CV-bank row — it then joins like any manual upload."""
try:
service=CandidateView(session=session)
data=await service.assign_matching(payload.id,payload.job_post_id,current_user.get("id"))
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/inbox-match")
async def candidate_inbox_match(
inbox_message_id: str = Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=FileRead(session=session)
data=await service.match_inbox_cv(inbox_message_id,current_user=current_user)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/job/post-job")
async def post_job(
payload: JobPostCreate,
current_user: dict = Depends(require_permission(PermissionTag.JOB_BOARD_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=JobPost(session=session)
data=payload.model_dump()
if data['mode']=="customScheduled" and data.get('scheduler_date'):
data['due_at']=datetime.combine(
data['scheduler_date'],
data['scheduler_time'] or time(0, 0, 0),
tzinfo=timezone.utc,
).strftime("%Y-%m-%dT%H:%M:%S.000Z")
result=await service.post_job(data,current_user)
return JSONResponse(content={"data":result,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/job/image/upload")
async def upload_job_image(
job_post_id: str = Form(...),
file: UploadFile = File(...),
current_user: dict = Depends(require_permission(
PermissionTag.JOB_BOARD_CREATE, PermissionTag.JOBS_EDIT, require_all=False,
)),
session: AsyncSession = Depends(get_session),
):
"""Attach (or replace) the cover image of a job post. Stored in the
job_post_images table; the create flow calls this right after /job/post-job."""
try:
content=await file.read()
service=JobPost(session=session)
data=await service.save_job_image(
job_post_id,file.filename,file.content_type,content,current_user,
)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/image/fetch")
async def fetch_job_image(
job_post_id: str = Query(...),
current_user: dict = Depends(require_permission(
PermissionTag.JOBS_VIEW, PermissionTag.JOB_BOARD_VIEW, require_all=False,
)),
session: AsyncSession = Depends(get_session),
):
"""The stored cover image, served inline from the database; 404 when the
post has none."""
try:
service=JobPost(session=session)
content,media_type=await service.get_job_image(job_post_id)
return Response(
content=content,
media_type=media_type,
headers={"Content-Disposition":"inline"},
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
class JobAssistRequest(BaseModel):
field: Literal[
"title", "department", "location", "salary",
"requirements", "optional_skills", "description",
]
action: Literal["fix", "suggest"]
text: str = ""
context: dict = {}
@router.post("/job/assist-field")
async def assist_job_field(
payload: JobAssistRequest,
current_user: dict = Depends(require_permission(
PermissionTag.JOB_BOARD_CREATE, PermissionTag.JOBS_EDIT, require_all=False,
)),
):
"""AI assist for one job-form field: fix the recruiter's text or suggest content.
No DB session — the form state travels in the payload. RuntimeError from the
agent is a provider problem and must surface as a generic 503; its cause can
carry prompt content that never belongs in a response body.
"""
try:
suggestion = await run_field_assist(
field=payload.field,
action=payload.action,
text=payload.text,
context=payload.context,
)
return JSONResponse(content={"data": {"suggestion": suggestion}, "status_code": 200})
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except Exception:
raise HTTPException(status_code=503, detail="AI assist is unavailable right now. Try again shortly.")
@router.get("/job/buffer/channels")
async def buffer_channels(
current_user: dict = Depends(require_permission(PermissionTag.JOB_BOARD_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=JobPost(session=session)
data=await service.list_channels()
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
class InboxScoreRequest(BaseModel):
job_id: str
message_ids: list[str] # inbox_messages PK uuids, not Graph message ids
@router.post("/candidate/score")
async def score_candidates(
job_id: str = Form(...),
files: list[UploadFile] = File(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
"""Score uploaded CV PDFs against a job post; persists and returns the leaderboard."""
try:
pairs=[(f.filename,await f.read()) for f in files]
service=CandidateScoring(session=session)
data=await service.score_uploads(job_id,pairs,current_user)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/score_inbox")
async def score_inbox_candidates(
payload: InboxScoreRequest,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
"""Score the decoded attachments of inbox messages against a job post."""
try:
service=CandidateScoring(session=session)
data=await service.score_inbox(payload.job_id,payload.message_ids,current_user)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/scored/fetch")
async def fetch_scored_candidates(
job_id: str = Query(None),
limit: int = Query(10, ge=1, le=100),
offset: int = Query(0, ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Persisted scored candidates, newest first. Without job_id returns the whole
pool across jobs. `total` is the full result-set size, not the page length."""
try:
service=CandidateScoring(session=session)
data,total=await service.fetch_candidates(job_id,limit=limit,offset=offset)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/fetch")
async def fetch_job_posts(
search: str | None = Query(None),
top: int | None = Query(10, ge=1, le=100),
skip: int = Query(0, ge=0),
ids: str | None = Query(None),
active_only: bool = Query(True),
# Job-board, candidate, or talent viewers may list jobs — recruiters scoring
# CVs need a job to score against (CV Import picker), and talent sourcing
# needs the same picker to choose which job to source for.
current_user: dict = Depends(
require_permission(
PermissionTag.JOB_BOARD_VIEW,
PermissionTag.CANDIDATES_VIEW,
PermissionTag.TALENT_VIEW,
require_all=False,
)
),
session: AsyncSession = Depends(get_session),
):
try:
service=JobPost(session=session)
id_list=[x.strip() for x in (ids or "").split(",") if x.strip()] or None
data,total=await service.fetch_job_posts(
search=search,
top=top,
skip=skip,
ids=id_list,
active_only=active_only,
current_user=current_user,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/stats/fetch")
async def fetch_job_stats(
job_post_id: Optional[uuid.UUID] = Query(None),
search: str | None = Query(None),
ids: str | None = Query(None),
top: int | None = Query(10, ge=1, le=500),
skip: int = Query(0, ge=0),
active_only: bool = Query(False),
current_user: dict = Depends(
require_permission(
PermissionTag.JOBS_VIEW,
PermissionTag.PIPELINE_VIEW,
require_all=False,
)
),
session: AsyncSession = Depends(get_session),
):
"""Live pipeline-stage counts per job post (inbox + manual upload).
Omit job_post_id for a paged list (optional search / ids). Pass job_post_id
for a single object. Counts are aggregated, not stored.
"""
try:
service=JobPost(session=session)
id_list=[x.strip() for x in (ids or "").split(",") if x.strip()] or None
data,total=await service.fetch_job_stats(
job_post_id=job_post_id,
search=search,
ids=id_list,
top=top,
skip=skip,
active_only=active_only,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/departments/fetch")
async def fetch_job_departments(
active_only: bool = Query(False),
current_user: dict = Depends(
require_permission(
PermissionTag.JOB_BOARD_VIEW,
PermissionTag.CANDIDATES_VIEW,
PermissionTag.TALENT_VIEW,
PermissionTag.JOBS_VIEW,
require_all=False,
)
),
session: AsyncSession = Depends(get_session),
):
"""Distinct job_posts.department values for filter dropdowns."""
try:
service=JobPost(session=session)
data=await service.fetch_departments(active_only=active_only)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/jobs/requisition-statuses/fetch")
async def fetch_requisition_statuses(
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Hiring-lifecycle tags for the Jobs status dropdown (open/on_hold/closed/completed)."""
try:
service=JobPost(session=session)
data=await service.fetch_requisition_statuses()
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/jobs/status-history/fetch")
async def fetch_job_status_history(
job_post_id:str=Query(...),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Who changed requisition_status on one job, from what, to what, and when."""
try:
service=JobPost(session=session)
data=await service.fetch_status_history(job_post_id)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/jobs/fetch")
async def fetch_jobs(
search: str | None = Query(None),
department: str | None = Query(None),
requisition_status: str | None = Query(None),
employment_type: str | None = Query(None),
hiring_manager_id: str | None = Query(None),
# le=500 (not 100): the Jobs board loads a full client-side page for facets;
# a 200 ceiling used to 422 the SPA and render an empty requisition list.
top: int | None = Query(10, ge=1, le=500),
skip: int = Query(0, ge=0),
# Defaults False, unlike /job/fetch: a requisition list must show CLOSED
# requisitions, and those carry is_active = false. Soft-deleted rows are still
# excluded by the include_deleted branch in fetch_job_posts.
active_only: bool = Query(False),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=JobPost(session=session)
data,total=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status,
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
top=top,skip=skip,active_only=active_only,current_user=current_user,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
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),
hiring_manager_id: 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,hiring_manager_id=hiring_manager_id,
top=None,skip=0,active_only=active_only,current_user=current_user,
)
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(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateScoring(session=session)
data=await service.fetch_candidate_by_id(candidate_id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/manager/fetch")
async def fetch_manager_candidates(
limit:int=Query(50,ge=1,le=200),
offset:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Candidates allocated to jobs opened from this user's requisitions
(or where they are the assigned hiring manager)."""
try:
service=CandidateView(session=session)
data,total=await service.list_manager_candidates(current_user,limit=limit,offset=offset)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/fetch")
async def fetch_candidate(
user_id:str=Query(None),
limit:int=Query(10,ge=1,le=100),
assigned_job_post_id:UUID=Query(None),
offset:int=Query(0,ge=0),
search:str=Query(None),
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
data=await service.get_candidate(
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
)
total=await service.count_candidates(
user_id=user_id,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
) if isinstance(data,list) else 1
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/applications/fetch")
async def fetch_candidate_applications(
email:str=Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
data=await service.get_application_history(email)
return JSONResponse(content={"data":data,"total":len(data.get("applications") or []),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.patch("/candidate/update")
async def update_candidate(
user_id:str=Query(...),
payload:CandidateUpdate=...,
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/history/fetch")
async def fetch_candidate_history(
user_id:str=Query(...),
limit:int=Query(10,ge=1,le=100),
offset:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=HistoryRecorder(session=session)
data,total=await service.list_for_user(user_id,limit=limit,offset=offset)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/interview/fetch")
async def fetch_interview(
interview_id:str=Query(None),
inbox_id:int=Query(None),
from_date:datetime=Query(None),
to_date:datetime=Query(None),
status:str=Query(None),
recruiter_id:str=Query(None),
top:int=Query(None),
skip:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_VIEW,PermissionTag.CANDIDATES_VIEW,require_all=False)),
session: AsyncSession = Depends(get_session),
):
try:
service=Interview(session=session)
if not interview_id and inbox_id is None and (from_date is not None or to_date is not None or status is not None or recruiter_id or top is not None):
data,total=await service.get_interviews_range(
from_date=from_date,to_date=to_date,status=status,recruiter_id=recruiter_id,top=top,skip=skip,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
data=await service.get_interview(
interview_id=interview_id,inbox_id=inbox_id,
from_date=from_date,to_date=to_date,status=status,recruiter_id=recruiter_id,top=top,skip=skip,
)
if isinstance(data,tuple):
data,total=data
else:
total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/interview/create")
async def create_interview(
payload:InterviewCreate,
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_CREATE,PermissionTag.CANDIDATES_CREATE,require_all=False)),
session: AsyncSession = Depends(get_session),
):
try:
service=Interview(session=session)
data=await service.create_interview(payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.patch("/interview/update")
async def update_interview(
interview_id:str=Query(...),
payload:InterviewUpdate=...,
current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_EDIT,PermissionTag.CANDIDATES_EDIT,require_all=False)),
session: AsyncSession = Depends(get_session),
):
try:
service=Interview(session=session)
data=await service.update_interview(interview_id,payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/notes/fetch")
async def fetch_notes(
note_id:str=Query(None),
user_id:str=Query(None),
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Note(session=session)
data=await service.get_note(note_id=note_id,user_id=user_id,current_user=current_user,created_by=created_by)
total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/notes/create")
async def create_note(
payload:NoteCreate,
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=Note(session=session)
data=await service.create_note(payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.patch("/notes/update")
async def update_note(
note_id:str=Query(...),
payload:NoteUpdate=...,
created_by:Optional[bool]=Query(False),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Note(session=session)
data=await service.update_note(note_id,payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/activity/fetch")
async def fetch_activity(
activity_id:str=Query(None),
inbox_id:int=Query(None),
top:int=Query(None),
skip:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=ActivityLog(session=session)
if not activity_id and inbox_id is None and top is not None:
data,total=await service.get_activity_feed(top=top,skip=skip)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
data=await service.get_activity(activity_id=activity_id,inbox_id=inbox_id,top=top,skip=skip)
if isinstance(data,tuple):
data,total=data
else:
total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/activity/create")
async def create_activity(
payload:ActivityCreate,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=ActivityLog(session=session)
data=await service.create_activity(payload.model_dump(exclude_unset=True))
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/feedback/fetch")
async def fetch_feedback(
feedback_id:str=Query(None),
inbox_id:int=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=FeedbackView(session=session)
data=await service.get_feedback(feedback_id=feedback_id,inbox_id=inbox_id)
total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/feedback/create")
async def create_feedback(
payload:FeedbackCreate,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=FeedbackView(session=session)
data=await service.create_feedback(payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.patch("/feedback/update")
async def update_feedback(
feedback_id:str=Query(...),
payload:FeedbackUpdate=...,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=FeedbackView(session=session)
data=await service.update_feedback(feedback_id,payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.patch("/candidate/stage")
async def change_candidate_stage(
payload:StageChange,
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Pipeline(session=session)
data=await service.change_stage(
payload.to_stage,current_user,inbox_id=payload.inbox_id,
manual_upload_id=payload.manual_upload_id,change_reason=payload.change_reason,
)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/pipeline/candidates/fetch")
async def fetch_pipeline_candidates(
job_post_id:Optional[uuid.UUID]=Query(None),
limit:int=Query(10,ge=1,le=1000),
offset:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Pipeline(session=session)
result=await service.get_all(job_post_id=job_post_id,limit=limit,offset=offset)
return JSONResponse(content={**result,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/pipeline/candidate/score/fetch")
async def fetch_pipeline_candidate_score(
user_id:uuid.UUID=Query(...),
job_post_id:uuid.UUID=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Pipeline(session=session)
data=await service.get_pipeline_candidates(user_id=user_id,job_post_id=job_post_id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/pipeline/transitions/fetch")
async def fetch_pipeline_transitions(
transition_id:str=Query(None),
inbox_id:int=Query(None),
manual_upload_id:UUID=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Pipeline(session=session)
data=await service.get_transitions(inbox_id=inbox_id,transition_id=transition_id,manual_upload_id=manual_upload_id)
total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/assignments/fetch")
async def fetch_job_assignments(
job_post_id:str=Query(...),
current_only:bool=Query(True),
assignment_role:str=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Assignment(session=session)
data=await service.list_job_assignments(
job_post_id,current_only=current_only,assignment_role=assignment_role,
)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/job/assignments/create")
async def create_job_assignment(
payload:JobAssignmentCreate,
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Assignment(session=session)
data=await service.create_job_assignment(payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/candidate/assignments/fetch")
async def fetch_application_assignments(
inbox_id:int=Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Assignment(session=session)
data=await service.list_application_assignments(inbox_id)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/candidate/assignments/create")
async def create_application_assignment(
payload:ApplicationAssignmentCreate,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Assignment(session=session)
data=await service.create_application_assignment(payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/costs/fetch")
async def fetch_hiring_costs(
job_post_id:str=Query(None),
from_date:datetime=Query(None),
to_date:datetime=Query(None),
top:int=Query(None),
skip:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=HiringCost(session=session)
data,total=await service.list_costs(
job_post_id=job_post_id,from_date=from_date,to_date=to_date,top=top,skip=skip,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/costs/source-channels/fetch")
async def fetch_cost_source_channels(
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Active source channels for tagging spend (REQ-ANL-09 attribution)."""
try:
from inbox.models import SourceChannels
rows=await SourceChannels.list_active(session)
data=[{"id":r.id,"key":r.key,"label":r.label} for r in rows]
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/job/costs/create")
async def create_hiring_cost(
payload:HiringCostCreate,
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=HiringCost(session=session)
data=await service.create_cost(payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.patch("/jobs/update")
async def update_job(
payload:JobUpdate,
job_post_id:str=Query(...),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=JobPost(session=session)
data=await service.update_job(job_post_id,payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.delete("/jobs/delete")
async def delete_job(
job_post_id:str=Query(...),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_DELETE)),
session: AsyncSession = Depends(get_session),
):
try:
service=JobPost(session=session)
data=await service.delete_job(job_post_id,current_user)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.patch("/jobs/status")
async def set_job_status(
payload:JobStatusUpdate,
job_post_id:str=Query(...),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=JobPost(session=session)
data=await service.set_job_status(job_post_id,payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/feedback/templates/fetch")
async def fetch_feedback_templates(
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=FeedbackView(session=session)
data,total=await service.get_templates()
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.post("/feedback/templates/create")
async def create_feedback_template(
payload:FeedbackTemplateCreate,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=FeedbackView(session=session)
data=await service.create_template(payload.model_dump(exclude_unset=True),current_user)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.patch("/feedback/templates/update")
async def update_feedback_template(
payload:FeedbackTemplateUpdate,
template_id:str=Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=FeedbackView(session=session)
data=await service.update_template(template_id,payload.model_dump(exclude_unset=True))
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.delete("/feedback/templates/delete")
async def delete_feedback_template(
template_id:str=Query(...),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_DELETE)),
session: AsyncSession = Depends(get_session),
):
try:
service=FeedbackView(session=session)
data=await service.delete_template(template_id)
return JSONResponse(content={"data":data,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/documents/download")
async def download_document(
inbox_id:int|None=Query(None),
manual_upload_candidate_id:str|None=Query(None),
index:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
path,filename=await service.download_document(inbox_id,manual_upload_candidate_id,index)
return FileResponse(
path=str(path),
filename=filename,
media_type="application/octet-stream",
content_disposition_type="attachment",
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))