HR-ATS-Portal/backend/candidate_forms/views.py

442 lines
17 KiB
Python

import logging
import uuid
from datetime import timezone
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from candidate_forms.models import CandidateForms, Requisition, _now
from candidate_forms.plugins import (
FORM_DEFINITIONS,
FORM_READY_STATUSES,
FORM_TYPES,
RECOMMENDATIONS,
combined_summary,
normalize_fields,
normalize_sections,
)
from candidate_forms.serializers import (
serialize_form,
serialize_requisition,
serialize_requisition_option,
)
from inbox.models import Inbox
from job.candidate.models import Interviews, Manual_UPLOAD_CANDIDATE
from job.candidate.views import assert_manager_candidate_access
from job.history.enums import HistoryEvent
from job.history.views import HistoryRecorder
from job.job_post.models import JobPosts
from users.models import Users
from users.permissions import is_admin, is_hiring_manager
logger = logging.getLogger("candidate_forms")
def _as_uuid(value):
if value in (None, ""):
return None
try:
return uuid.UUID(str(value))
except (TypeError, ValueError):
return None
def _user_id(current_user):
if not current_user or not current_user.get("id"):
raise HTTPException(status_code=401, detail="Not authenticated")
uid = _as_uuid(current_user["id"])
if uid is None:
raise HTTPException(status_code=401, detail="Invalid user id")
return uid
def _aware(value):
if value is not None and getattr(value, "tzinfo", None) is None:
return value.replace(tzinfo=timezone.utc)
return value
def _stage_value(status) -> str:
return str(getattr(status, "value", status) or "").upper()
def _recommendation(form_type, value):
definition = FORM_DEFINITIONS.get(form_type) or {}
if not definition.get("has_recommendation"):
return None
if value in (None, ""):
return None
value = str(value).strip()
if value not in RECOMMENDATIONS:
raise HTTPException(
status_code=422,
detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}",
)
return value
def _score_sections(form_type, sections):
try:
return normalize_sections(form_type, sections)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
def _score_fields(form_type, fields):
try:
return normalize_fields(form_type, fields)
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc))
class CandidateForm:
def __init__(self, session: AsyncSession):
self.session = session
async def _validate_link(self, payload):
"""Exactly one of inbox_id / manual_upload_candidate_id; both rows must
exist. Returns (inbox_id, manual_id, job_post_id, current_stage)."""
inbox_id = payload.get("inbox_id")
manual_id = _as_uuid(payload.get("manual_upload_candidate_id"))
has_inbox = inbox_id is not None
has_manual = manual_id is not None
if has_inbox == has_manual:
raise HTTPException(
status_code=422,
detail="Exactly one of inbox_id or manual_upload_candidate_id is required",
)
if has_inbox:
try:
inbox_id = int(inbox_id)
except (TypeError, ValueError):
raise HTTPException(status_code=422, detail="Invalid inbox_id")
link = await Inbox.get_inbox_with_message(self.session, inbox_id)
if link is None:
raise HTTPException(status_code=404, detail="Inbox record not found")
stage = _stage_value(
link.messages.application_status if link.messages is not None else None
)
app_job = (
link.messages.assigned_job_post_id if link.messages is not None else None
)
else:
inbox_id = None
manual = await Manual_UPLOAD_CANDIDATE.get_by_id(self.session, manual_id)
if manual is None:
raise HTTPException(status_code=404, detail="Manual upload candidate not found")
stage = _stage_value(manual.status)
app_job = manual.job_post_id
job_post_id = _as_uuid(payload.get("job_post_id"))
if payload.get("job_post_id") and job_post_id is None:
raise HTTPException(status_code=422, detail="Invalid job_post_id")
if job_post_id is None:
job_post_id = app_job
if job_post_id is not None:
post = await JobPosts.get_job_post_by_id(self.session, str(job_post_id))
if not post or post.is_deleted:
raise HTTPException(status_code=404, detail="Job post not found")
return inbox_id, manual_id, job_post_id, stage
async def _context_maps(self, rows):
inbox_ids = [r.inbox_id for r in rows if r.inbox_id is not None]
manual_ids = [r.manual_upload_candidate_id for r in rows if r.manual_upload_candidate_id]
job_ids = [r.job_post_id for r in rows if r.job_post_id]
inbox_by_id = {}
if inbox_ids:
inbox_by_id = {row.id: row for row in await Inbox.get_by_ids(self.session, inbox_ids)}
for row in inbox_by_id.values():
msg = row.messages
if msg is not None and msg.assigned_job_post_id:
job_ids.append(msg.assigned_job_post_id)
manual_by_id = {}
if manual_ids:
manual_by_id = {
row.id: row for row in await Manual_UPLOAD_CANDIDATE.get_by_ids(self.session, manual_ids)
}
for row in manual_by_id.values():
if row.job_post_id:
job_ids.append(row.job_post_id)
jobs_by_id = {}
uids = [j for j in set(job_ids) if j]
if uids:
jobs_by_id = {
row.id: row for row in await JobPosts.get_by_ids(self.session, uids, active_only=False)
}
user_ids = {r.interviewer_id for r in rows if r.interviewer_id}
user_ids |= {r.created_by for r in rows if r.created_by}
users_by_id = await Users.names_by_ids(self.session, user_ids)
return inbox_by_id, manual_by_id, jobs_by_id, users_by_id
def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id):
candidate_name = None
job_title = None
if row.job_post_id and row.job_post_id in jobs_by_id:
job_title = jobs_by_id[row.job_post_id].title
if row.inbox_id is not None:
link = inbox_by_id.get(row.inbox_id)
if link is not None:
if link.user is not None:
candidate_name = link.user.name
msg = link.messages
if job_title is None and msg is not None and msg.assigned_job_post_id:
job = jobs_by_id.get(msg.assigned_job_post_id)
if job is not None:
job_title = job.title
if row.manual_upload_candidate_id:
manual = manual_by_id.get(row.manual_upload_candidate_id)
if manual is not None:
candidate_name = candidate_name or manual.candidate_name or None
if job_title is None and manual.job_post_id:
job = jobs_by_id.get(manual.job_post_id)
if job is not None:
job_title = job.title
return candidate_name, job_title
async def _serialize_rows(self, rows):
inbox_by_id, manual_by_id, jobs_by_id, users_by_id = await self._context_maps(rows)
out = []
for row in rows:
name, title = self._labels(row, inbox_by_id, manual_by_id, jobs_by_id)
out.append(
serialize_form(
row,
candidate_name=name,
job_title=title,
interviewer_name=users_by_id.get(str(row.interviewer_id)) if row.interviewer_id else None,
created_by_name=users_by_id.get(str(row.created_by)) if row.created_by else None,
)
)
return out
async def get_forms(
self,
form_id=None,
inbox_id=None,
manual_upload_candidate_id=None,
job_post_id=None,
form_type=None,
top=None,
skip=0,
current_user=None,
):
if form_type and form_type not in FORM_TYPES:
raise HTTPException(
status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}"
)
if is_hiring_manager(current_user) and not (
form_id or inbox_id is not None or manual_upload_candidate_id or job_post_id
):
raise HTTPException(
status_code=403,
detail="Hiring managers can only load forms for candidates on their requisitions",
)
if inbox_id is not None or manual_upload_candidate_id is not None or job_post_id:
await assert_manager_candidate_access(
self.session,
current_user,
job_post_id=job_post_id,
inbox_id=inbox_id,
manual_id=manual_upload_candidate_id,
)
rows, total = await CandidateForms.fetch_forms(
self.session,
form_id=form_id,
inbox_id=inbox_id,
manual_upload_candidate_id=manual_upload_candidate_id,
job_post_id=job_post_id,
form_type=form_type,
top=top,
skip=skip or 0,
)
if form_id and rows:
await assert_manager_candidate_access(
self.session,
current_user,
job_post_id=rows[0].job_post_id,
inbox_id=rows[0].inbox_id,
manual_id=rows[0].manual_upload_candidate_id,
)
summary = None
if inbox_id is not None or manual_upload_candidate_id is not None:
if form_type:
# The filtered fetch may not include both evaluation forms.
summary_rows, _ = await CandidateForms.fetch_forms(
self.session,
inbox_id=inbox_id,
manual_upload_candidate_id=manual_upload_candidate_id,
)
else:
summary_rows = rows
summary = combined_summary(summary_rows)
return await self._serialize_rows(rows), summary, total
async def create_form(self, payload, current_user):
form_type = (payload.get("form_type") or "").strip()
if form_type not in FORM_TYPES:
raise HTTPException(
status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}"
)
inbox_id, manual_id, job_post_id, stage = await self._validate_link(payload)
await assert_manager_candidate_access(
self.session,
current_user,
job_post_id=job_post_id,
inbox_id=inbox_id,
manual_id=manual_id,
)
if stage not in FORM_READY_STATUSES:
has_interview = False
if inbox_id is not None:
rows = await Interviews.get_interviews_by_inbox(self.session, inbox_id)
has_interview = bool(rows)
if not has_interview:
raise HTTPException(
status_code=409,
detail=(
"Forms unlock once the candidate reaches the Interview stage "
f"or has an interview scheduled — this candidate is at "
f"{stage or 'Shortlist'} with no interview on record"
),
)
interviewer_id = _as_uuid(payload.get("interviewer_id"))
if form_type != "requisition" and interviewer_id is None:
interviewer_id = _user_id(current_user)
form_date = _aware(payload.get("form_date")) or _now()
sections, overall_score = _score_sections(form_type, payload.get("sections"))
fields = _score_fields(form_type, payload.get("fields"))
row = await CandidateForms.insert_form(
self.session,
{
"form_type": form_type,
"inbox_id": inbox_id,
"manual_upload_candidate_id": manual_id,
"job_post_id": job_post_id,
"interviewer_id": interviewer_id,
"form_date": form_date,
"sections": sections,
"fields": fields,
"overall_score": overall_score,
"recommendation": _recommendation(form_type, payload.get("recommendation")),
"created_by": _user_id(current_user),
},
)
await HistoryRecorder(self.session).record(
HistoryEvent.FORM_CREATED,
current_user=current_user,
inbox_id=inbox_id,
manual_upload_candidate_id=manual_id,
entity_type="candidate_form",
entity_id=row.id,
to_value=form_type,
commit=True,
)
return (await self._serialize_rows([row]))[0]
async def update_form(self, form_id, payload, current_user):
_user_id(current_user)
row = await CandidateForms.get_form_by_id(self.session, form_id)
if not row:
raise HTTPException(status_code=404, detail="Form not found")
await assert_manager_candidate_access(
self.session,
current_user,
job_post_id=row.job_post_id,
inbox_id=row.inbox_id,
manual_id=row.manual_upload_candidate_id,
)
fields = {}
if "interviewer_id" in payload:
fields["interviewer_id"] = _as_uuid(payload.get("interviewer_id"))
if "form_date" in payload:
fields["form_date"] = _aware(payload.get("form_date"))
if "sections" in payload:
sections, overall_score = _score_sections(row.form_type, payload.get("sections"))
fields["sections"] = sections
fields["overall_score"] = overall_score
if "fields" in payload:
fields["fields"] = _score_fields(row.form_type, payload.get("fields"))
if "recommendation" in payload:
fields["recommendation"] = _recommendation(row.form_type, payload.get("recommendation"))
if not fields:
raise HTTPException(status_code=400, detail="No fields to update")
updated = await CandidateForms.update_form(self.session, form_id, fields)
if not updated:
raise HTTPException(status_code=404, detail="Form not found")
await HistoryRecorder(self.session).record(
HistoryEvent.FORM_UPDATED,
current_user=current_user,
inbox_id=updated.inbox_id,
manual_upload_candidate_id=updated.manual_upload_candidate_id,
entity_type="candidate_form",
entity_id=updated.id,
to_value=updated.form_type,
commit=True,
)
return (await self._serialize_rows([updated]))[0]
async def delete_form(self, form_id, current_user):
_user_id(current_user)
row = await CandidateForms.get_form_by_id(self.session, form_id)
if not row:
raise HTTPException(status_code=404, detail="Form not found")
await assert_manager_candidate_access(
self.session,
current_user,
job_post_id=row.job_post_id,
inbox_id=row.inbox_id,
manual_id=row.manual_upload_candidate_id,
)
row = await CandidateForms.soft_delete_form(self.session, form_id)
if not row:
raise HTTPException(status_code=404, detail="Form not found")
return {"id": str(row.id), "deleted": True}
class RequisitionForm:
def __init__(self, session: AsyncSession):
self.session = session
async def create_form(self, payload, current_user):
payload["created_by"] = _user_id(current_user)
row = await Requisition.insert_form(self.session, payload)
return serialize_requisition(row)
async def update_form(self, form_id, payload, current_user):
_user_id(current_user)
row = await Requisition.get_form_by_id(self.session, form_id)
if not row:
raise HTTPException(status_code=404, detail="Form not found")
if not payload:
raise HTTPException(status_code=400, detail="No fields to update")
updated = await Requisition.update_form(self.session, form_id, payload)
if not updated:
raise HTTPException(status_code=404, detail="Form not found")
return serialize_requisition(updated)
async def get_form_by_id(self, form_id, current_user):
# Admins see the full table. Managers still only see rows they opened.
# Job-post linkage is ignored here — that filter is search/picker only.
created_by = None if is_admin(current_user) else _user_id(current_user)
if form_id:
row = await Requisition.get_form_by_id(self.session, record_id=form_id, created_by=created_by)
if not row:
raise HTTPException(status_code=404, detail="Form not found")
return serialize_requisition(row)
rows = await Requisition.get_form_by_id(self.session, created_by=created_by)
return [serialize_requisition(r) for r in rows]
async def search(self, q, top=50, job_post_id=None):
rows = await Requisition.search(
self.session, q, top=top, job_post_id=job_post_id,
)
return [serialize_requisition_option(r) for r in rows]