reference _check

reference_check
ahmed.mujtaba 2026-09-16 16:20:14 +05:00
parent 1493af9fea
commit 9c11e8a104
7 changed files with 430 additions and 0 deletions

View File

@ -26,6 +26,7 @@ from g_sheet.app import router as g_sheet_router
from s3.app import router as s3_router
from department.app import router as department_router
from verification_check.app import router as verification_check_router
from reference_check.app import router as reference_check_router
logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s")
logger=logging.getLogger("main")
@ -140,3 +141,4 @@ app.include_router(g_sheet_router)
app.include_router(s3_router)
app.include_router(department_router)
app.include_router(verification_check_router)
app.include_router(reference_check_router)

View File

@ -0,0 +1,49 @@
-- 043_reference_check.sql
-- Annexure G - Employment Reference Check Form as its own table
-- (backend/reference_check/models.py, ReferenceCheck). One row per referee, so
-- a candidate can have several. recruiter_id and candidate_id both point at
-- users.id (recruiters and candidates are users in this system); created_by is
-- the logged-in user who filled the form.
--
-- The table name is mixed case at the user's request, so it must stay quoted
-- ("Reference_Check") in any raw SQL. FK / index names follow the db_setup
-- NAMING_CONVENTION so a dev DB that autogenerated first is a no-op.
--
-- Idempotent, applied at startup by alembic_setup.run_manual_sql(). Needed
-- because prod boots with DB_AUTOGENERATE=false and never autogenerates tables.
CREATE TABLE IF NOT EXISTS app."Reference_Check" (
id uuid PRIMARY KEY,
recruiter_id uuid,
candidate_id uuid,
position_applied varchar,
referee_name varchar,
date_time timestamptz,
referee_title varchar,
referee_organization varchar,
relationship_duration varchar,
scope_of_relationship varchar,
work_approach varchar,
leadership varchar,
communication varchar,
agility varchar,
signoff_referee_name varchar,
signoff_designation varchar,
signoff_date date,
created_by uuid,
created_at timestamptz NOT NULL DEFAULT NOW(),
updated_at timestamptz NOT NULL DEFAULT NOW(),
is_deleted boolean NOT NULL DEFAULT false,
CONSTRAINT "fk_Reference_Check_recruiter_id_users"
FOREIGN KEY (recruiter_id) REFERENCES app.users (id),
CONSTRAINT "fk_Reference_Check_candidate_id_users"
FOREIGN KEY (candidate_id) REFERENCES app.users (id),
CONSTRAINT "fk_Reference_Check_created_by_users"
FOREIGN KEY (created_by) REFERENCES app.users (id)
);
CREATE INDEX IF NOT EXISTS "ix_Reference_Check_recruiter_id"
ON app."Reference_Check" (recruiter_id);
CREATE INDEX IF NOT EXISTS "ix_Reference_Check_candidate_id"
ON app."Reference_Check" (candidate_id);

View File

@ -0,0 +1,98 @@
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from db_setup import get_session
from reference_check.enums import ProfessionalFeedback, RecordOverview, RefereeSignOff
from reference_check.views import ReferenceCheckForm
from users.permissions import PermissionTag, require_permission
router = APIRouter()
class ReferenceCheckCreate(BaseModel):
recruiter_id:uuid.UUID
candidate_id:uuid.UUID
record_overview:Optional[RecordOverview]
professional_feedback:Optional[ProfessionalFeedback]
referee_signoff:Optional[RefereeSignOff]
class ReferenceCheckUpdate(BaseModel):
recruiter_id:Optional[uuid.UUID]=None
candidate_id:Optional[uuid.UUID]=None
record_overview:Optional[RecordOverview]=None
professional_feedback:Optional[ProfessionalFeedback]=None
referee_signoff:Optional[RefereeSignOff]=None
@router.get("/reference-check/fetch")
async def fetch_reference_check(
current_user:dict=Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
form_id:str=Query(None),
candidate_id:str=Query(None),
session:AsyncSession=Depends(get_session),
):
"""One row by form_id, else the list (optionally one candidate's referees).
Admins see every row; other roles stay scoped to created_by."""
try:
service=ReferenceCheckForm(session=session)
data=await service.get_form_by_id(form_id,candidate_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.post("/reference-check/create")
async def create_reference_check(
payload: ReferenceCheckCreate,
current_user:dict=Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session:AsyncSession=Depends(get_session),
):
try:
service = ReferenceCheckForm(session=session)
data = await service.create_form(payload.model_dump(exclude_unset=True), 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("/reference-check/update")
async def update_reference_check(
payload: ReferenceCheckUpdate,
current_user:dict=Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
form_id:str=Query(...),
session:AsyncSession=Depends(get_session),
):
try:
service=ReferenceCheckForm(session=session)
data=await service.update_form(form_id,payload.model_dump(exclude_unset=True),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.delete("/reference-check/delete")
async def delete_reference_check(
current_user:dict=Depends(require_permission(PermissionTag.CANDIDATES_DELETE)),
form_id:str=Query(...),
session:AsyncSession=Depends(get_session),
):
try:
service=ReferenceCheckForm(session=session)
data=await service.delete_form(form_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))

View File

@ -0,0 +1,29 @@
from pydantic import BaseModel
from typing import Optional
from datetime import date, datetime
# SECTION 1: META INFORMATION & RECORD OVERVIEW
class RecordOverview(BaseModel):
position_applied:Optional[str]=None
referee_name:Optional[str]=None
date_time:Optional[datetime]=None
referee_title:Optional[str]=None
referee_organization:Optional[str]=None
relationship_duration:Optional[str]=None
# SECTION 2: QUALITATIVE PROFESSIONAL FEEDBACK
class ProfessionalFeedback(BaseModel):
scope_of_relationship:Optional[str]=None
work_approach:Optional[str]=None
leadership:Optional[str]=None
communication:Optional[str]=None
agility:Optional[str]=None
# Sign-off block at the foot of the form
class RefereeSignOff(BaseModel):
referee_name:Optional[str]=None
designation:Optional[str]=None
signed_date:Optional[date]=None

View File

@ -0,0 +1,153 @@
import uuid
from datetime import datetime, date as Date, timezone
from typing import Optional
from sqlalchemy import DateTime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
def _now() -> datetime:
return datetime.now(timezone.utc)
class ReferenceCheck(SQLModel, table=True):
"""Annexure G - Employment Reference Check Form. One row per referee, so a
candidate can hold several. recruiter_id / candidate_id are both users.id."""
__tablename__ = "Reference_Check"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
recruiter_id: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id", index=True)
candidate_id: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id", index=True)
position_applied: Optional[str] = None
referee_name: Optional[str] = None
date_time: Optional[datetime] = Field(default=None, sa_type=DateTime(timezone=True))
referee_title: Optional[str] = None
referee_organization: Optional[str] = None
relationship_duration: Optional[str] = None
scope_of_relationship: Optional[str] = None
work_approach: Optional[str] = None
leadership: Optional[str] = None
communication: Optional[str] = None
agility: Optional[str] = None
signoff_referee_name: Optional[str] = None
signoff_designation: Optional[str] = None
signoff_date: Optional[Date] = None
created_by: Optional[uuid.UUID] = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_deleted: bool = Field(default=False)
@classmethod
async def get_form_by_id(cls, session: AsyncSession, record_id=None, created_by=None, candidate_id=None):
qry = select(cls).where(cls.is_deleted == False) # noqa: E712
if created_by is not None:
qry = qry.where(cls.created_by == created_by)
if candidate_id is not None:
qry = qry.where(cls.candidate_id == candidate_id)
if record_id not in (None, ""):
try:
uid = uuid.UUID(str(record_id))
except (TypeError, ValueError):
return None
qry = qry.where(cls.id == uid)
result = await session.execute(qry)
return result.scalars().first()
result = await session.execute(qry.order_by(cls.created_at.desc(), cls.id.desc()))
return list(result.scalars().all())
@classmethod
async def insert_form(cls, session: AsyncSession, fields: dict):
overview = fields.get("record_overview") if fields.get("record_overview") else {}
feedback = fields.get("professional_feedback") if fields.get("professional_feedback") else {}
signoff = fields.get("referee_signoff") if fields.get("referee_signoff") else {}
row = cls(
recruiter_id=fields.get("recruiter_id") if fields.get("recruiter_id") else None,
candidate_id=fields.get("candidate_id") if fields.get("candidate_id") else None,
position_applied=overview.get("position_applied") if overview.get("position_applied") else None,
referee_name=overview.get("referee_name") if overview.get("referee_name") else None,
date_time=overview.get("date_time") if overview.get("date_time") else None,
referee_title=overview.get("referee_title") if overview.get("referee_title") else None,
referee_organization=overview.get("referee_organization") if overview.get("referee_organization") else None,
relationship_duration=overview.get("relationship_duration") if overview.get("relationship_duration") else None,
scope_of_relationship=feedback.get("scope_of_relationship") if feedback.get("scope_of_relationship") else None,
work_approach=feedback.get("work_approach") if feedback.get("work_approach") else None,
leadership=feedback.get("leadership") if feedback.get("leadership") else None,
communication=feedback.get("communication") if feedback.get("communication") else None,
agility=feedback.get("agility") if feedback.get("agility") else None,
signoff_referee_name=signoff.get("referee_name") if signoff.get("referee_name") else None,
signoff_designation=signoff.get("designation") if signoff.get("designation") else None,
signoff_date=signoff.get("signed_date") if signoff.get("signed_date") else None,
created_by=fields.get("created_by") if fields.get("created_by") else None,
)
session.add(row)
await session.commit()
return await cls.get_form_by_id(session, row.id)
@classmethod
async def update_form(cls, session: AsyncSession, record_id, fields: dict):
row = await cls.get_form_by_id(session, record_id)
if not row:
return None
if "recruiter_id" in fields:
row.recruiter_id = fields.get("recruiter_id") if fields.get("recruiter_id") else None
if "candidate_id" in fields:
row.candidate_id = fields.get("candidate_id") if fields.get("candidate_id") else None
if "record_overview" in fields:
overview = fields.get("record_overview") if fields.get("record_overview") else {}
if "position_applied" in overview:
row.position_applied = overview.get("position_applied") if overview.get("position_applied") else None
if "referee_name" in overview:
row.referee_name = overview.get("referee_name") if overview.get("referee_name") else None
if "date_time" in overview:
row.date_time = overview.get("date_time") if overview.get("date_time") else None
if "referee_title" in overview:
row.referee_title = overview.get("referee_title") if overview.get("referee_title") else None
if "referee_organization" in overview:
row.referee_organization = overview.get("referee_organization") if overview.get("referee_organization") else None
if "relationship_duration" in overview:
row.relationship_duration = overview.get("relationship_duration") if overview.get("relationship_duration") else None
if "professional_feedback" in fields:
feedback = fields.get("professional_feedback") if fields.get("professional_feedback") else {}
if "scope_of_relationship" in feedback:
row.scope_of_relationship = feedback.get("scope_of_relationship") if feedback.get("scope_of_relationship") else None
if "work_approach" in feedback:
row.work_approach = feedback.get("work_approach") if feedback.get("work_approach") else None
if "leadership" in feedback:
row.leadership = feedback.get("leadership") if feedback.get("leadership") else None
if "communication" in feedback:
row.communication = feedback.get("communication") if feedback.get("communication") else None
if "agility" in feedback:
row.agility = feedback.get("agility") if feedback.get("agility") else None
if "referee_signoff" in fields:
signoff = fields.get("referee_signoff") if fields.get("referee_signoff") else {}
if "referee_name" in signoff:
row.signoff_referee_name = signoff.get("referee_name") if signoff.get("referee_name") else None
if "designation" in signoff:
row.signoff_designation = signoff.get("designation") if signoff.get("designation") else None
if "signed_date" in signoff:
row.signoff_date = signoff.get("signed_date") if signoff.get("signed_date") else None
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def soft_delete_form(cls, session: AsyncSession, record_id):
row = await cls.get_form_by_id(session, record_id)
if not row:
return None
row.is_deleted = True
row.updated_at = _now()
session.add(row)
await session.commit()
return row
import users.models as _users_models # noqa: E402, F401

View File

@ -0,0 +1,39 @@
def _date(value):
return value.isoformat() if value else None
def serialize_reference_check(row, names=None) -> dict:
"""`names` is {user_id: name} from one batched Users.names_by_ids lookup in
views, so the recruiter / candidate search dropdowns can preselect on edit."""
names = names or {}
return {
"id": str(row.id) if row.id else None,
"recruiter_id": str(row.recruiter_id) if row.recruiter_id else None,
"recruiter_name": names.get(str(row.recruiter_id)) if row.recruiter_id else None,
"candidate_id": str(row.candidate_id) if row.candidate_id else None,
"candidate_name": names.get(str(row.candidate_id)) if row.candidate_id else None,
"record_overview": {
"position_applied": row.position_applied,
"referee_name": row.referee_name,
"date_time": _date(row.date_time),
"referee_title": row.referee_title,
"referee_organization": row.referee_organization,
"relationship_duration": row.relationship_duration,
},
"professional_feedback": {
"scope_of_relationship": row.scope_of_relationship,
"work_approach": row.work_approach,
"leadership": row.leadership,
"communication": row.communication,
"agility": row.agility,
},
"referee_signoff": {
"referee_name": row.signoff_referee_name,
"designation": row.signoff_designation,
"signed_date": _date(row.signoff_date),
},
"created_by": str(row.created_by) if row.created_by else None,
"created_by_name": names.get(str(row.created_by)) if row.created_by else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
}

View File

@ -0,0 +1,60 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from candidate_forms.plugins import _as_uuid, _aware, _user_id
from reference_check.models import ReferenceCheck
from reference_check.serializers import serialize_reference_check
from users.models import Users
from users.permissions import is_admin
class ReferenceCheckForm:
def __init__(self, session: AsyncSession):
self.session = session
async def _serialize_rows(self, rows):
user_ids = set()
for row in rows:
user_ids |= {row.recruiter_id, row.candidate_id, row.created_by}
names = await Users.names_by_ids(self.session, user_ids)
return [serialize_reference_check(row, names) for row in rows]
async def create_form(self, payload, current_user):
if payload.get("record_overview") and "date_time" in payload["record_overview"]:
payload["record_overview"]["date_time"] = _aware(payload["record_overview"]["date_time"])
payload["created_by"] = _user_id(current_user)
row = await ReferenceCheck.insert_form(self.session, payload)
return (await self._serialize_rows([row]))[0]
async def update_form(self, form_id, payload, current_user):
_user_id(current_user)
row = await ReferenceCheck.get_form_by_id(self.session, form_id)
if not row:
raise HTTPException(status_code=404, detail="Reference check not found")
if not payload:
raise HTTPException(status_code=400, detail="No fields to update")
if payload.get("record_overview") and "date_time" in payload["record_overview"]:
payload["record_overview"]["date_time"] = _aware(payload["record_overview"]["date_time"])
updated = await ReferenceCheck.update_form(self.session, form_id, payload)
if not updated:
raise HTTPException(status_code=404, detail="Reference check not found")
return (await self._serialize_rows([updated]))[0]
async def get_form_by_id(self, form_id, candidate_id, current_user):
created_by = None if is_admin(current_user) else _user_id(current_user)
if form_id:
row = await ReferenceCheck.get_form_by_id(self.session, record_id=form_id, created_by=created_by)
if not row:
raise HTTPException(status_code=404, detail="Reference check not found")
return (await self._serialize_rows([row]))[0]
rows = await ReferenceCheck.get_form_by_id(
self.session, created_by=created_by, candidate_id=_as_uuid(candidate_id)
)
return await self._serialize_rows(rows)
async def delete_form(self, form_id, current_user):
_user_id(current_user)
row = await ReferenceCheck.soft_delete_form(self.session, form_id)
if not row:
raise HTTPException(status_code=404, detail="Reference check not found")
return {"id": str(row.id), "deleted": True}