1646 lines
61 KiB
Python
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))
|