From 9c11e8a104a840103a3089de9c4259a7956f74a7 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 16 Sep 2026 16:20:14 +0500 Subject: [PATCH] reference _check --- backend/main.py | 2 + .../migrations/manual/043_reference_check.sql | 49 ++++++ backend/reference_check/app.py | 98 +++++++++++ backend/reference_check/enums.py | 29 ++++ backend/reference_check/models.py | 153 ++++++++++++++++++ backend/reference_check/serializers.py | 39 +++++ backend/reference_check/views.py | 60 +++++++ 7 files changed, 430 insertions(+) create mode 100644 backend/migrations/manual/043_reference_check.sql create mode 100644 backend/reference_check/enums.py create mode 100644 backend/reference_check/models.py create mode 100644 backend/reference_check/serializers.py create mode 100644 backend/reference_check/views.py diff --git a/backend/main.py b/backend/main.py index e3eb2c3..cd66a12 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/migrations/manual/043_reference_check.sql b/backend/migrations/manual/043_reference_check.sql new file mode 100644 index 0000000..cc6784d --- /dev/null +++ b/backend/migrations/manual/043_reference_check.sql @@ -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); diff --git a/backend/reference_check/app.py b/backend/reference_check/app.py index e69de29..8c8b951 100644 --- a/backend/reference_check/app.py +++ b/backend/reference_check/app.py @@ -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)) diff --git a/backend/reference_check/enums.py b/backend/reference_check/enums.py new file mode 100644 index 0000000..5894eb5 --- /dev/null +++ b/backend/reference_check/enums.py @@ -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 diff --git a/backend/reference_check/models.py b/backend/reference_check/models.py new file mode 100644 index 0000000..a850cbf --- /dev/null +++ b/backend/reference_check/models.py @@ -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 diff --git a/backend/reference_check/serializers.py b/backend/reference_check/serializers.py new file mode 100644 index 0000000..53ffa2d --- /dev/null +++ b/backend/reference_check/serializers.py @@ -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, + } diff --git a/backend/reference_check/views.py b/backend/reference_check/views.py new file mode 100644 index 0000000..f13199b --- /dev/null +++ b/backend/reference_check/views.py @@ -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}