reference _check
parent
1493af9fea
commit
9c11e8a104
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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))
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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}
|
||||
Loading…
Reference in New Issue