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 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, 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 uuid load_dotenv() logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) router = APIRouter() 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 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), search:Optional[str]=Query(None), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), session: AsyncSession = Depends(get_session), ): try: service=User(session=session) data=await service.get_users(role_id=role_id,top=top,skip=skip) return JSONResponse(content={"data":data,"status_code":200}) 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), 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: service=User(session=session) total=await service.count_users(search=search,role_id=role_id) return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200}) 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) # 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, ) 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, "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), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), session: AsyncSession = Depends(get_session), ): """The stored-CV bank, newest first. Download the file via GET /documents/download?manual_upload_candidate_id=.""" from job.candidate.models import Manual_UPLOAD_CANDIDATE try: rows,total=await Manual_UPLOAD_CANDIDATE.list_bank(session,limit=top,offset=skip) data=[{ "id":str(r.id), "file_name":r.file_name, "file_path":(r.file_path or "").strip() or None, "candidate_email":r.candidate_email or None, "candidate_name":r.candidate_name or None, "created_at":r.created_at.isoformat() if r.created_at else None, } for r in rows] 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/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.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, ) 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/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), # 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,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("/jobs/export") async def export_jobs( search: str | None = Query(None), department: str | None = Query(None), requisition_status: str | None = Query(None), employment_type: str | None = Query(None), active_only: bool = Query(False), current_user: dict = Depends(require_permission(PermissionTag.JOBS_EXPORT)), session: AsyncSession = Depends(get_session), ): """Styled .xlsx of the requisition list — same filters as /jobs/fetch, no paging.""" try: service=JobPost(session=session) data,_=await service.fetch_jobs( search=search,department=department,requisition_status=requisition_status, employment_type=employment_type,top=None,skip=0,active_only=active_only, ) filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx" return Response( content=build_jobs_workbook(data), media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition":f'attachment; filename="{filename}"'}, ) except HTTPException: raise except Exception as e: raise HTTPException(status_code=500,detail=str(e)) @router.get("/candidate/fetch_by_id") async def fetch_candidate_by_id( candidate_id: str = Query(...), 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/fetch") async def fetch_candidate( user_id:str=Query(None), limit:int=Query(10,ge=1,le=100), offset:int=Query(0,ge=0), search:str=Query(None), 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) total=await service.count_candidates(user_id=user_id,search=search) 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.patch("/candidate/update") async def update_candidate( user_id:str=Query(...), payload:CandidateUpdate=..., 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) 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(200,ge=1,le=500), 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), 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=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 top is not None): data,total=await service.get_interviews_range( from_date=from_date,to_date=to_date,status=status,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,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.CANDIDATES_CREATE)), 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.CANDIDATES_EDIT)), 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), 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) 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, 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) 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=..., 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) 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_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) 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))