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 count_open(self, current_user): # Same scope as the requisition list: admins count every row, others their own. created_by = None if is_admin(current_user) else _user_id(current_user) return await Requisition.count_open(self.session, created_by=created_by) 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]