From 9b5c2c80537d6bd71d8f1408644dc2ff463caf36 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Tue, 1 Sep 2026 18:25:53 +0500 Subject: [PATCH 1/4] . --- backend/candidate_forms/app.py | 116 ++- backend/candidate_forms/enums.py | 31 + backend/candidate_forms/models.py | 236 ++++++- backend/candidate_forms/serializers.py | 62 ++ backend/candidate_forms/views.py | 136 ++-- backend/job/app.py | 1 + backend/job/assignment/views.py | 7 +- backend/job/job_post/models.py | 39 +- backend/job/job_post/serializers.py | 2 + backend/job/job_post/views.py | 74 +- .../manual/019_requisitions_rbac.sql | 68 ++ .../migrations/manual/020_requisitions.sql | 60 ++ .../manual/021_job_post_requisition.sql | 14 + .../022_job_post_requisition_unique.sql | 11 + .../manual/023_requisition_employmenttype.sql | 65 ++ backend/users/models.py | 2 +- backend/users/permissions.py | 11 +- frontend/nginx.conf | 2 +- frontend/src/App.jsx | 3 +- frontend/src/api/requisitions.js | 59 ++ frontend/src/app/routes.js | 3 +- frontend/src/auth/permissions.js | 2 +- frontend/src/lib/queryKeys.js | 6 + frontend/src/screens/CandidateForms.jsx | 299 +------- frontend/src/screens/CandidateProfile.jsx | 2 +- frontend/src/screens/Jobs.jsx | 111 ++- frontend/src/screens/Requisitions.jsx | 660 ++++++++++++++++++ frontend/src/screens/Settings.jsx | 4 +- frontend/vite.config.js | 2 +- 29 files changed, 1675 insertions(+), 413 deletions(-) create mode 100644 backend/candidate_forms/enums.py create mode 100644 backend/migrations/manual/019_requisitions_rbac.sql create mode 100644 backend/migrations/manual/020_requisitions.sql create mode 100644 backend/migrations/manual/021_job_post_requisition.sql create mode 100644 backend/migrations/manual/022_job_post_requisition_unique.sql create mode 100644 backend/migrations/manual/023_requisition_employmenttype.sql create mode 100644 frontend/src/api/requisitions.js create mode 100644 frontend/src/screens/Requisitions.jsx diff --git a/backend/candidate_forms/app.py b/backend/candidate_forms/app.py index 37d5f3a..e22d9bf 100644 --- a/backend/candidate_forms/app.py +++ b/backend/candidate_forms/app.py @@ -1,17 +1,53 @@ -from datetime import datetime - +from datetime import datetime, date +from dis import Positions +from typing import Type from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import JSONResponse +import uuid from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession - +from typing import Optional from candidate_forms.plugins import definitions_payload from candidate_forms.views import CandidateForm from db_setup import get_session from users.permissions import PermissionTag, require_permission +from candidate_forms.enums import EmploymentType, Position, ReplacementFor, InternalRecommendate +from candidate_forms.views import RequisitionForm router = APIRouter() +class RequisitionFormCreate(BaseModel): + form_type: str = "requisition" + position:Position + replacement_for:Optional[ReplacementFor] + refferal_by:Optional[InternalRecommendate] + initiated_by:Optional[str] + initiated_date:Optional[date] + recommended_by:Optional[str] + recommended_date:Optional[date] + approved_by_hr:Optional[bool] + approved_by_date_hr:Optional[date] + approved_by_vp:Optional[bool] + approved_by_date_vp:Optional[date] + approved_by_svp:Optional[bool] + approved_by_date_svp:Optional[date] + + +class RequisitionFormUpdate(BaseModel): + position:Optional[Position]=None + replacement_for:Optional[ReplacementFor]=None + refferal_by:Optional[InternalRecommendate]=None + initiated_by:Optional[str]=None + initiated_date:Optional[date]=None + recommended_by:Optional[str]=None + recommended_date:Optional[date]=None + approved_by_hr:Optional[bool]=None + approved_by_date_hr:Optional[date]=None + approved_by_vp:Optional[bool]=None + approved_by_date_vp:Optional[date]=None + approved_by_svp:Optional[bool]=None + approved_by_date_svp:Optional[date]=None + class FormCreate(BaseModel): form_type: str @@ -32,6 +68,80 @@ class FormUpdate(BaseModel): fields: dict | None = None recommendation: str | None = None +@router.get("/forms/requisition/search") +async def search_requisitions( + current_user: dict = Depends( + require_permission( + PermissionTag.REQUISITIONS_VIEW, + PermissionTag.JOB_BOARD_CREATE, + PermissionTag.JOBS_CREATE, + require_all=False, + ) + ), + q: str | None = Query(None), + top: int = Query(50, ge=1, le=100), + session: AsyncSession = Depends(get_session), +): + """Searchable picker for job create: `{position_title} - {department}`. + + `q` matches either field (ilike). Empty `q` returns recent rows. + """ + try: + service = RequisitionForm(session=session) + data = await service.search(q, top=top) + return JSONResponse(content={"data": data, "status_code": 200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/forms/requisition/fetch") +async def fetch_requisition_form( + current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)), + form_id:str=Query(None), + session:AsyncSession=Depends(get_session), +): + try: + service=RequisitionForm(session=session) + data=await service.get_form_by_id(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)) + +@router.post("/forms/requisition/create") +async def create_requisition_form( + payload: RequisitionFormCreate, + current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_CREATE)), + session:AsyncSession=Depends(get_session), +): + try: + service = RequisitionForm(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("/forms/requisition/update") +async def update_requisition_form( + payload: RequisitionFormUpdate, + current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_EDIT)), + form_id:str=Query(...), + session:AsyncSession=Depends(get_session), +): + try: + service=RequisitionForm(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.get("/forms/definitions") async def fetch_form_definitions( diff --git a/backend/candidate_forms/enums.py b/backend/candidate_forms/enums.py new file mode 100644 index 0000000..8cede5b --- /dev/null +++ b/backend/candidate_forms/enums.py @@ -0,0 +1,31 @@ +from enum import Enum +from pydantic import BaseModel +from typing import Optional +from datetime import date + +class EmploymentType(str,Enum): + PERMANENT = "permanent" + CONTRACT = "contract" + TEMPORARY = "temporary" + INTERNEE="internee" + +class Position(BaseModel): + department:Optional[str] + title:Optional[str] + date:Optional[date] + date_needed:Optional[date] + type:Optional[EmploymentType] + job_description:Optional[str] + +class InternalRecommendate(BaseModel): + employee_name:Optional[str]=None + employee_department:Optional[str]=None + +class ReplacementFor(BaseModel): + to_replace:Optional[str] + grade:Optional[str] + title:Optional[str] + date_separated:Optional[date] + justification:Optional[str] + budget:Optional[str] + recommended_grade:Optional[str] \ No newline at end of file diff --git a/backend/candidate_forms/models.py b/backend/candidate_forms/models.py index 1071df5..fd7612c 100644 --- a/backend/candidate_forms/models.py +++ b/backend/candidate_forms/models.py @@ -1,14 +1,214 @@ import uuid -from datetime import datetime, timezone +from datetime import datetime, date as Date, timezone +from typing import TYPE_CHECKING, Optional -from sqlalchemy import DateTime, JSON, func +from sqlalchemy import DateTime, Enum as SAEnum, JSON, func, or_ from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import Field, SQLModel, select +from sqlmodel import Field, Relationship, SQLModel, select + +from candidate_forms.enums import EmploymentType + +if TYPE_CHECKING: + from job.job_post.models import JobPosts def _now() -> datetime: return datetime.now(timezone.utc) +class Requisition(SQLModel, table=True): + __tablename__ = "requisitions" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + + department: Optional[str] = None + position_title: Optional[str] = None + date: Optional[Date] = None + date_needed: Optional[Date] = None + employment_type: Optional[EmploymentType] = Field( + default=None, + sa_type=SAEnum( + EmploymentType, + name="employmenttype", + schema="app", + native_enum=True, + values_callable=lambda enum: [member.value for member in enum], + ), + ) + job_description: Optional[str] = None + + employee_name: Optional[str] = None + employee_department: Optional[str] = None + + to_replace: Optional[str] = None + grade: Optional[str] = None + recruitment_title: Optional[str] = None + date_separated: Optional[Date] = None + justification: Optional[str] = None + budget: Optional[str] = None + recommended_grade: Optional[str] = None + + initiated_by: Optional[str] = None + initiated_date: Optional[Date] = None + recommended_by: Optional[str] = None + recommended_date: Optional[Date] = None + approved_by_hr: Optional[bool] = None + approved_by_date_hr: Optional[Date] = None + approved_by_vp: Optional[bool] = None + approved_by_date_vp: Optional[Date] = None + approved_by_svp: Optional[bool] = None + approved_by_date_svp: 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) + # Optional 1:1: job_posts.requisition_id points here. uselist=False so a + # requisition has at most one job post (enforced in DB by the unique FK). + job_post: Optional["JobPosts"] = Relationship( + back_populates="requisition", + sa_relationship_kwargs={"uselist": False, "lazy": "selectin"}, + ) + + @classmethod + async def get_form_by_id(cls, session: AsyncSession, record_id=None, created_by=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 record_id not in (None, ""): + + try: + uid = uuid.UUID(str(record_id)) + except (TypeError, ValueError): + return None + + qry = qry.where(cls.id == uid) + qry = qry.order_by(cls.created_at.desc(),cls.id.desc()) + 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 search(cls, session: AsyncSession, q: str | None = None, *, top: int = 50): + """Dropdown rows: match position_title or department (either side). + + Empty `q` returns the most recent non-deleted rows so the picker has a + list before the user types. Not scoped to created_by — job creators + need the org-wide list, not only requisitions they opened themselves. + """ + statement = select(cls).where(cls.is_deleted == False) # noqa: E712 + term = (q or "").strip() + if term: + like = f"%{term}%" + statement = statement.where( + or_(cls.position_title.ilike(like), cls.department.ilike(like)) + ) + limit = max(1, min(int(top or 50), 100)) + statement = statement.order_by(cls.created_at.desc(), cls.id.desc()).limit(limit) + result = await session.execute(statement) + return list(result.scalars().all()) + + @classmethod + async def insert_form(cls, session: AsyncSession, fields: dict): + position = fields.get("position") if fields.get("position") else {} + replacement = fields.get("replacement_for") if fields.get("replacement_for") else {} + referral = fields.get("refferal_by") if fields.get("refferal_by") else {} + row = cls( + department=position.get("department") if position.get("department") else None, + position_title=position.get("title") if position.get("title") else None, + date=position.get("date") if position.get("date") else None, + date_needed=position.get("date_needed") if position.get("date_needed") else None, + employment_type=EmploymentType(position.get("type")) if position.get("type") else None, + job_description=position.get("job_description") if position.get("job_description") else None, + employee_name=referral.get("employee_name") if referral.get("employee_name") else None, + employee_department=referral.get("employee_department") if referral.get("employee_department") else None, + to_replace=replacement.get("to_replace") if replacement.get("to_replace") else None, + grade=replacement.get("grade") if replacement.get("grade") else None, + recruitment_title=replacement.get("title") if replacement.get("title") else None, + date_separated=replacement.get("date_separated") if replacement.get("date_separated") else None, + justification=replacement.get("justification") if replacement.get("justification") else None, + budget=replacement.get("budget") if replacement.get("budget") else None, + recommended_grade=replacement.get("recommended_grade") if replacement.get("recommended_grade") else None, + initiated_by=fields.get("initiated_by") if fields.get("initiated_by") else None, + initiated_date=fields.get("initiated_date") if fields.get("initiated_date") else None, + recommended_by=fields.get("recommended_by") if fields.get("recommended_by") else None, + recommended_date=fields.get("recommended_date") if fields.get("recommended_date") else None, + approved_by_hr=fields.get("approved_by_hr") if fields.get("approved_by_hr") is not None else None, + approved_by_date_hr=fields.get("approved_by_date_hr") if fields.get("approved_by_date_hr") else None, + approved_by_vp=fields.get("approved_by_vp") if fields.get("approved_by_vp") is not None else None, + approved_by_date_vp=fields.get("approved_by_date_vp") if fields.get("approved_by_date_vp") else None, + approved_by_svp=fields.get("approved_by_svp") if fields.get("approved_by_svp") is not None else None, + approved_by_date_svp=fields.get("approved_by_date_svp") if fields.get("approved_by_date_svp") 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 "position" in fields: + position = fields.get("position") if fields.get("position") else {} + if "department" in position: + row.department = position.get("department") if position.get("department") else None + if "title" in position: + row.position_title = position.get("title") if position.get("title") else None + if "date" in position: + row.date = position.get("date") if position.get("date") else None + if "date_needed" in position: + row.date_needed = position.get("date_needed") if position.get("date_needed") else None + if "type" in position: + row.employment_type = EmploymentType(position.get("type")) if position.get("type") else None + if "job_description" in position: + row.job_description = position.get("job_description") if position.get("job_description") else None + if "replacement_for" in fields: + replacement = fields.get("replacement_for") if fields.get("replacement_for") else {} + if "to_replace" in replacement: + row.to_replace = replacement.get("to_replace") if replacement.get("to_replace") else None + if "grade" in replacement: + row.grade = replacement.get("grade") if replacement.get("grade") else None + if "title" in replacement: + row.recruitment_title = replacement.get("title") if replacement.get("title") else None + if "date_separated" in replacement: + row.date_separated = replacement.get("date_separated") if replacement.get("date_separated") else None + if "justification" in replacement: + row.justification = replacement.get("justification") if replacement.get("justification") else None + if "budget" in replacement: + row.budget = replacement.get("budget") if replacement.get("budget") else None + if "recommended_grade" in replacement: + row.recommended_grade = replacement.get("recommended_grade") if replacement.get("recommended_grade") else None + if "refferal_by" in fields: + referral = fields.get("refferal_by") if fields.get("refferal_by") else {} + if "employee_name" in referral: + row.employee_name = referral.get("employee_name") if referral.get("employee_name") else None + if "employee_department" in referral: + row.employee_department = referral.get("employee_department") if referral.get("employee_department") else None + if "initiated_by" in fields: + row.initiated_by = fields.get("initiated_by") if fields.get("initiated_by") else None + if "initiated_date" in fields: + row.initiated_date = fields.get("initiated_date") if fields.get("initiated_date") else None + if "recommended_by" in fields: + row.recommended_by = fields.get("recommended_by") if fields.get("recommended_by") else None + if "recommended_date" in fields: + row.recommended_date = fields.get("recommended_date") if fields.get("recommended_date") else None + if "approved_by_hr" in fields: + row.approved_by_hr = fields.get("approved_by_hr") if fields.get("approved_by_hr") is not None else None + if "approved_by_date_hr" in fields: + row.approved_by_date_hr = fields.get("approved_by_date_hr") if fields.get("approved_by_date_hr") else None + if "approved_by_vp" in fields: + row.approved_by_vp = fields.get("approved_by_vp") if fields.get("approved_by_vp") is not None else None + if "approved_by_date_vp" in fields: + row.approved_by_date_vp = fields.get("approved_by_date_vp") if fields.get("approved_by_date_vp") else None + if "approved_by_svp" in fields: + row.approved_by_svp = fields.get("approved_by_svp") if fields.get("approved_by_svp") is not None else None + if "approved_by_date_svp" in fields: + row.approved_by_date_svp = fields.get("approved_by_date_svp") if fields.get("approved_by_date_svp") else None + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row class CandidateForms(SQLModel, table=True): """One digitized hiring form (Annexure A requisition, or one of the two @@ -100,8 +300,20 @@ class CandidateForms(SQLModel, table=True): return list(result.scalars().all()), total @classmethod - async def insert_form(cls, session: AsyncSession, fields: dict): - row = cls(**fields) + async def insert_form(cls, session: AsyncSession, fields: dict): + row = cls( + form_type=fields.get("form_type"), + inbox_id=fields.get("inbox_id"), + manual_upload_candidate_id=fields.get("manual_upload_candidate_id"), + job_post_id=fields.get("job_post_id"), + interviewer_id=fields.get("interviewer_id"), + form_date=fields.get("form_date"), + sections=fields.get("sections"), + fields=fields.get("fields"), + overall_score=fields.get("overall_score"), + recommendation=fields.get("recommendation"), + created_by=fields.get("created_by"), + ) session.add(row) await session.commit() return await cls.get_form_by_id(session, row.id) @@ -111,8 +323,18 @@ class CandidateForms(SQLModel, table=True): row = await cls.get_form_by_id(session, record_id) if not row: return None - for key, value in fields.items(): - setattr(row, key, value) + if "interviewer_id" in fields: + row.interviewer_id = fields.get("interviewer_id") + if "form_date" in fields: + row.form_date = fields.get("form_date") + if "sections" in fields: + row.sections = fields.get("sections") + if "fields" in fields: + row.fields = fields.get("fields") + if "overall_score" in fields: + row.overall_score = fields.get("overall_score") + if "recommendation" in fields: + row.recommendation = fields.get("recommendation") row.updated_at = _now() session.add(row) await session.commit() diff --git a/backend/candidate_forms/serializers.py b/backend/candidate_forms/serializers.py index aa8362b..e465a1c 100644 --- a/backend/candidate_forms/serializers.py +++ b/backend/candidate_forms/serializers.py @@ -30,3 +30,65 @@ def serialize_form( "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } + + +def _date(value): + return value.isoformat() if value else None + + +def _enum(value): + if value is None: + return None + return getattr(value, "value", value) + + +def serialize_requisition_option(row) -> dict: + """Compact row for a searchable picker: `{job title} - {department}`.""" + title = (row.position_title or "").strip() + department = (row.department or "").strip() + return { + "id": str(row.id) if row.id else None, + "title": row.position_title, + "department": row.department, + "label": f"{title or 'Untitled'} - {department or '—'}", + } + + +def serialize_requisition(row) -> dict: + return { + "id": str(row.id) if row.id else None, + "position": { + "department": row.department, + "title": row.position_title, + "date": _date(row.date), + "date_needed": _date(row.date_needed), + "type": _enum(row.employment_type), + "job_description": row.job_description, + }, + "replacement_for": { + "to_replace": row.to_replace, + "grade": row.grade, + "title": row.recruitment_title, + "date_separated": _date(row.date_separated), + "justification": row.justification, + "budget": row.budget, + "recommended_grade": row.recommended_grade, + }, + "refferal_by": { + "employee_name": row.employee_name, + "employee_department": row.employee_department, + }, + "initiated_by": row.initiated_by, + "initiated_date": _date(row.initiated_date), + "recommended_by": row.recommended_by, + "recommended_date": _date(row.recommended_date), + "approved_by_hr": row.approved_by_hr, + "approved_by_date_hr": _date(row.approved_by_date_hr), + "approved_by_vp": row.approved_by_vp, + "approved_by_date_vp": _date(row.approved_by_date_vp), + "approved_by_svp": row.approved_by_svp, + "approved_by_date_svp": _date(row.approved_by_date_svp), + "created_by": 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/candidate_forms/views.py b/backend/candidate_forms/views.py index b0d93b9..7dda488 100644 --- a/backend/candidate_forms/views.py +++ b/backend/candidate_forms/views.py @@ -5,16 +5,17 @@ from datetime import timezone from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from candidate_forms.models import CandidateForms, _now +from candidate_forms.models import CandidateForms, Requisition, _now from candidate_forms.plugins import ( FORM_READY_STATUSES, FORM_TYPES, - RECOMMENDATIONS, combined_summary, - normalize_fields, - normalize_sections, ) -from candidate_forms.serializers import serialize_form +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.history.enums import HistoryEvent @@ -96,39 +97,6 @@ class CandidateForm: raise HTTPException(status_code=404, detail="Job post not found") return inbox_id, manual_id, job_post_id, stage - def _normalize_payload(self, form_type, payload): - """Shared create/update normalization. Returns the writable fields dict - for the keys present in `payload`.""" - fields = {} - if "sections" in payload: - try: - sections, overall = normalize_sections(form_type, payload.get("sections")) - except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) - fields["sections"] = sections - fields["overall_score"] = overall - if "fields" in payload: - try: - fields["fields"] = normalize_fields(form_type, payload.get("fields")) - except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) - if "recommendation" in payload: - recommendation = payload.get("recommendation") or None - if recommendation is not None and recommendation not in RECOMMENDATIONS: - raise HTTPException( - status_code=422, - detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}", - ) - fields["recommendation"] = recommendation - if "interviewer_id" in payload: - interviewer_id = _as_uuid(payload.get("interviewer_id")) - if payload.get("interviewer_id") and interviewer_id is None: - raise HTTPException(status_code=422, detail="Invalid interviewer_id") - fields["interviewer_id"] = interviewer_id - if "form_date" in payload: - fields["form_date"] = _aware(payload.get("form_date")) - return fields - 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] @@ -251,10 +219,6 @@ class CandidateForm: ) inbox_id, manual_id, job_post_id, stage = await self._validate_link(payload) if stage not in FORM_READY_STATUSES: - # A scheduled interview also unlocks the forms: the paperwork belongs - # to the interview, not to which kanban column the card sits in. - # (Interview records link only to inbox rows, so manual-upload - # candidates unlock by stage alone.) has_interview = False if inbox_id is not None: rows = await Interviews.get_interviews_by_inbox(self.session, inbox_id) @@ -269,28 +233,26 @@ class CandidateForm: ), ) - fields = { - "inbox_id": inbox_id, - "manual_upload_candidate_id": manual_id, - "job_post_id": job_post_id, - "form_type": form_type, - "created_by": _user_id(current_user), - } - fields.update( - self._normalize_payload( - form_type, - { - key: payload.get(key) - for key in ("sections", "fields", "recommendation", "interviewer_id", "form_date") - }, - ) - ) - if form_type != "requisition" and fields.get("interviewer_id") is None: - fields["interviewer_id"] = _user_id(current_user) - if fields.get("form_date") is None: - fields["form_date"] = _now() + 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() - row = await CandidateForms.insert_form(self.session, 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": payload.get("sections"), + "fields": payload.get("fields"), + "recommendation": payload.get("recommendation"), + "created_by": _user_id(current_user), + }, + ) await HistoryRecorder(self.session).record( HistoryEvent.FORM_CREATED, current_user=current_user, @@ -309,7 +271,17 @@ class CandidateForm: if not row: raise HTTPException(status_code=404, detail="Form not found") - fields = self._normalize_payload(row.form_type, payload) + 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: + fields["sections"] = payload.get("sections") + if "fields" in payload: + fields["fields"] = payload.get("fields") + if "recommendation" in payload: + fields["recommendation"] = payload.get("recommendation") if not fields: raise HTTPException(status_code=400, detail="No fields to update") @@ -334,3 +306,39 @@ class CandidateForm: 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): + created_by = _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): + rows = await Requisition.search(self.session, q, top=top) + return [serialize_requisition_option(r) for r in rows] \ No newline at end of file diff --git a/backend/job/app.py b/backend/job/app.py index 14928a7..376e17a 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -140,6 +140,7 @@ class JobUpdate(BaseModel): description: str | None = None current_recruiter_id: UUID | None = None hiring_manager_id: UUID | None = None + requisition_id: UUID | None = None class JobStatusUpdate(BaseModel): diff --git a/backend/job/assignment/views.py b/backend/job/assignment/views.py index e4a01f2..743145d 100644 --- a/backend/job/assignment/views.py +++ b/backend/job/assignment/views.py @@ -50,9 +50,8 @@ class Assignment: async def record_job_owner(self,job_post_id,user_id,assignment_role,assigned_by): """Close the open interval of this role, then open a new one. - user_id None = unassign (hiring_manager cannot be cleared; callers - must not pass None for that role). No-ops when the same person already - holds the open interval. Does not touch job_posts columns. + user_id None = unassign. No-ops when the same person already holds the + open interval. Does not touch job_posts columns. """ role=self._job_role(assignment_role) job_uid=JobAssignments._as_uuid(job_post_id) @@ -63,8 +62,6 @@ class Assignment: self.session,job_uid,current_only=True,assignment_role=role, ) if user_id is None: - if role=="hiring_manager": - raise HTTPException(status_code=422,detail="hiring_manager_id is required") await JobAssignments.close_current(self.session,job_uid,role) return None user_uid=JobAssignments._as_uuid(user_id) diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 364de81..f1e7194 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -9,6 +9,7 @@ from sqlmodel import Field, Relationship, SQLModel, select from job.job_post.enums import RequisitionStatus if TYPE_CHECKING: # runtime import would be circular: users.models imports this module + from candidate_forms.models import Requisition from users.models import Users @@ -22,12 +23,7 @@ class JobPosts(SQLModel, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) title: str = Field(index=True) - # foreign_keys is required, not decoration: current_recruiter_id and - # hiring_manager_id below are extra FKs into users.id, so the join is - # ambiguous without it and every mapper fails to initialize. `user` is the - # AUTHOR of the post. The recruiter and hiring-manager columns stay bare — - # Users already carries five selectin relations that load on every - # authenticated request. Same pairing as Notes.user / Notes.author. + user: Optional["Users"] = Relationship( back_populates="job_posts", sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"}, @@ -61,9 +57,19 @@ class JobPosts(SQLModel, table=True): # Who is working the req now (swappable). History lives in job_assignments # with assignment_role=primary_recruiter; this column is the current pointer. current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") - # Who owns the requisition (stable). Required at create. History lives in + # Who owns the requisition (stable). Optional. History lives in # job_assignments with assignment_role=hiring_manager. hiring_manager_id: uuid.UUID | None = Field(default=None, foreign_key="users.id", index=True) + # Annexure A employee requisition this job was opened from (optional 1:1). + # Distinct from requisition_status, which is the hiring lifecycle on this row. + # unique=True so two job posts cannot share one requisition; NULLs stay allowed. + requisition_id: uuid.UUID | None = Field( + default=None, foreign_key="requisitions.id", ondelete="SET NULL", unique=True, index=True, + ) + requisition: Optional["Requisition"] = Relationship( + back_populates="job_post", + sa_relationship_kwargs={"lazy": "selectin"}, + ) created_by: 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)) @@ -83,6 +89,20 @@ class JobPosts(SQLModel, table=True): result = await session.execute(select(cls).where(cls.id == uid)) return result.scalars().first() + @classmethod + async def get_by_requisition_id(cls, session: AsyncSession, requisition_id): + """Live job post already opened from this Annexure A requisition, if any.""" + uid = cls._as_uuid(requisition_id) + if uid is None: + return None + result = await session.execute( + select(cls).where( + cls.requisition_id == uid, + cls.is_deleted == False, # noqa: E712 + ) + ) + return result.scalars().first() + @classmethod async def get_active_job_posts(cls, session: AsyncSession): result = await session.execute( @@ -530,4 +550,7 @@ class SocialPlatform(SQLModel, table=True): return {r.alias: r.buffer_service for r in rows} -import users.models as _users_models \ No newline at end of file +# Requisition must be registered before Users relationships trigger mapper +# configure — JobPosts.requisition_id FKs to app.requisitions. +import candidate_forms.models as _requisition_models # noqa: E402, F401 +import users.models as _users_models # noqa: E402, F401 \ No newline at end of file diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 0610900..bf165fe 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -36,6 +36,7 @@ def serialize_job_post(row) -> dict: "created_by_name": row.user.name if getattr(row, "user", None) 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, + "requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None, } @@ -70,6 +71,7 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app "recruiter_name": recruiter_name, "hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None, "hiring_manager_name": hiring_manager_name, + "requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None, "applicant_count": applicant_count, "created_by": str(row.created_by) if row.created_by else None, "created_by_name": row.user.name if getattr(row, "user", None) else None, diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index a7b13dc..0b4aec6 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -8,6 +8,7 @@ from uuid import UUID import httpx from dotenv import load_dotenv from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, model_validator from inbox.models import Inbox_Messages @@ -64,8 +65,9 @@ class JobPostCreate(BaseModel): scheduler_time: time | None = time(0, 0, 0) scheduler_date: date | None = None due_at: str | None = None - hiring_manager_id: UUID + hiring_manager_id: UUID | None = None current_recruiter_id: UUID | None = None + requisition_id: UUID | None = None @model_validator(mode="after") def validate_mode_and_due_at(self): @@ -146,10 +148,12 @@ class JobPost: fields["platform"]="internal" assignment=Assignment(self.session) - hm=await assignment.require_role( - payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id", - ) - fields["hiring_manager_id"]=hm.id + hm=None + if payload.get("hiring_manager_id"): + hm=await assignment.require_role( + payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id", + ) + fields["hiring_manager_id"]=hm.id rec=None if payload.get("current_recruiter_id"): rec=await assignment.require_role( @@ -157,9 +161,33 @@ class JobPost: ) fields["current_recruiter_id"]=rec.id - row=await JobPosts.insert_job_post(self.session,fields) + if payload.get("requisition_id"): + from candidate_forms.models import Requisition + req=await Requisition.get_form_by_id( + self.session, record_id=str(payload["requisition_id"]), + ) + if not req: + raise HTTPException(status_code=404, detail="Requisition not found") + held=await JobPosts.get_by_requisition_id(self.session, req.id) + if held: + raise HTTPException( + status_code=409, + detail="This requisition is already linked to a job post", + ) + fields["requisition_id"]=req.id + + try: + row=await JobPosts.insert_job_post(self.session,fields) + except IntegrityError as e: + orig=str(getattr(e,"orig",e)).lower() + if "requisition" in orig: + raise HTTPException( + status_code=409,detail="This requisition is already linked to a job post", + ) from e + raise assigned_by=current_user.get("id") if isinstance(current_user,dict) else None - await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by) + if hm: + await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by) if rec: await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by) @@ -288,11 +316,23 @@ class JobPost: rec_changed=False if "hiring_manager_id" in payload: raw=payload.get("hiring_manager_id") - if not raw: - raise HTTPException(status_code=422,detail="hiring_manager_id is required") - hm=await assignment.require_role(raw,EnumRoles.HIRING_MANAGER,"hiring_manager_id") - fields["hiring_manager_id"]=hm.id - hm_changed=str(existing.hiring_manager_id)!=str(hm.id) + if raw is None or raw=="": + fields["hiring_manager_id"]=None + hm_changed=existing.hiring_manager_id is not None + else: + hm=await assignment.require_role(raw,EnumRoles.HIRING_MANAGER,"hiring_manager_id") + fields["hiring_manager_id"]=hm.id + hm_changed=str(existing.hiring_manager_id)!=str(hm.id) + if "requisition_id" in payload: + raw=payload.get("requisition_id") + if raw is None or raw=="": + fields["requisition_id"]=None + else: + from candidate_forms.models import Requisition + req=await Requisition.get_form_by_id(self.session,record_id=str(raw)) + if not req: + raise HTTPException(status_code=404,detail="Requisition not found") + fields["requisition_id"]=req.id if "current_recruiter_id" in payload: raw=payload.get("current_recruiter_id") if raw is None or raw=="": @@ -305,7 +345,15 @@ class JobPost: if not fields: raise HTTPException(status_code=400,detail="No fields to update") - row=await JobPosts.update_job_post(self.session,job_post_id,fields) + try: + row=await JobPosts.update_job_post(self.session,job_post_id,fields) + except IntegrityError as e: + orig=str(getattr(e,"orig",e)).lower() + if "requisition" in orig: + raise HTTPException( + status_code=409,detail="This requisition is already linked to a job post", + ) from e + raise if not row: raise HTTPException(status_code=404,detail="Job post not found") if hm_changed: diff --git a/backend/migrations/manual/019_requisitions_rbac.sql b/backend/migrations/manual/019_requisitions_rbac.sql new file mode 100644 index 0000000..859cb89 --- /dev/null +++ b/backend/migrations/manual/019_requisitions_rbac.sql @@ -0,0 +1,68 @@ +-- 019_requisitions_rbac.sql +-- Manual one-shot: the `requisitions` permission module (8 tags), a +-- `requisitions_management` bundle holding them, and the bundle attached to +-- the staff roles that fill Employee Requisition forms (Annexure A). Mirrors +-- 007's idempotent pattern; applied automatically at startup by +-- alembic_setup.run_manual_sql() and recorded in manual_migrations. +-- +-- The all_access bundle is a fixed id list seeded before this module existed, +-- so system_administrator gets requisitions access through THIS bundle, not +-- that one. Users must log in again after this applies — permissions are +-- resolved from the DB per request, but the frontend caches the list from +-- /users/me. + +-- ============================================================================= +-- 1. The 8 requisitions.* permission tags +-- ============================================================================= +INSERT INTO app.permission_tags + (tag_name, module, action, description, created_at, updated_at, is_active, is_deleted) +VALUES + ('requisitions.view', 'requisitions', 'view', NULL, NOW(), NOW(), true, false), + ('requisitions.create', 'requisitions', 'create', NULL, NOW(), NOW(), true, false), + ('requisitions.edit', 'requisitions', 'edit', NULL, NOW(), NOW(), true, false), + ('requisitions.delete', 'requisitions', 'delete', NULL, NOW(), NOW(), true, false), + ('requisitions.approve', 'requisitions', 'approve', NULL, NOW(), NOW(), true, false), + ('requisitions.export', 'requisitions', 'export', NULL, NOW(), NOW(), true, false), + ('requisitions.manage', 'requisitions', 'manage', NULL, NOW(), NOW(), true, false), + ('requisitions.configure', 'requisitions', 'configure', NULL, NOW(), NOW(), true, false) +ON CONFLICT (tag_name) DO NOTHING; + +-- ============================================================================= +-- 2. Bundle holding all eight requisitions tags +-- ============================================================================= +INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted) +SELECT + 'requisitions_management', + 'Employee requisition forms: view, create, edit and manage requisitions', + ( + SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb) + FROM app.permission_tags + WHERE is_deleted = false + AND module = 'requisitions' + ), + true, + NOW(), + NOW(), + true, + false +WHERE NOT EXISTS ( + SELECT 1 FROM app.permissions WHERE name = 'requisitions_management' +); + +-- ============================================================================= +-- 3. Attach the bundle to the staff roles (idempotent; same role list as 007) +-- ============================================================================= +UPDATE app.roles r +SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id), + updated_at = NOW() +FROM app.permissions p +WHERE p.name = 'requisitions_management' + AND r.role_name IN ( + 'system_administrator', + 'hr_administrator', + 'recruiter', + 'hiring_manager', + 'department_head', + 'ceo' + ) + AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id)); diff --git a/backend/migrations/manual/020_requisitions.sql b/backend/migrations/manual/020_requisitions.sql new file mode 100644 index 0000000..474bc45 --- /dev/null +++ b/backend/migrations/manual/020_requisitions.sql @@ -0,0 +1,60 @@ +-- 020_requisitions.sql +-- Employee Requisition (Annexure A) as its own table, separate from +-- candidate_forms (Interview Analysis / Cultural Fit). Nested request objects +-- flatten onto columns: position.title → position_title, replacement_for.title +-- → recruitment_title, refferal_by → employee_name / employee_department. +-- employment type is a CHECK over EmploymentType values, not a native PG enum +-- (point releases of the Python enum should not require a type ALTER). +-- +-- "date" and "type" are quoted: both are PostgreSQL keywords. +-- +-- Idempotent, applied automatically at startup by alembic_setup.run_manual_sql() +-- and recorded in manual_migrations. Matches Requisition in +-- backend/candidate_forms/models.py (needed here because prod boots with +-- DB_AUTOGENERATE=false and never autogenerates new tables). + +CREATE TABLE IF NOT EXISTS app.requisitions ( + id uuid PRIMARY KEY, + department varchar, + position_title varchar, + "date" date, + date_needed date, + "type" varchar + CHECK ("type" IS NULL OR "type" IN ( + 'permanent', 'contract', 'temporary', 'internee' + )), + job_description text, + + employee_name varchar, + employee_department varchar, + + to_replace varchar, + grade varchar, + recruitment_title varchar, + date_separated date, + justification text, + budget varchar, + recommended_grade varchar, + + initiated_by varchar, + initiated_date date, + recommended_by varchar, + recommended_date date, + approved_by_hr boolean, + approved_by_date_hr date, + approved_by_vp boolean, + approved_by_date_vp date, + approved_by_svp boolean, + approved_by_date_svp date, + + created_by uuid REFERENCES app.users(id), + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW(), + is_deleted boolean NOT NULL DEFAULT false +); + +CREATE INDEX IF NOT EXISTS ix_requisitions_created_by + ON app.requisitions (created_by); + +CREATE INDEX IF NOT EXISTS ix_requisitions_is_deleted + ON app.requisitions (is_deleted); diff --git a/backend/migrations/manual/021_job_post_requisition.sql b/backend/migrations/manual/021_job_post_requisition.sql new file mode 100644 index 0000000..550d460 --- /dev/null +++ b/backend/migrations/manual/021_job_post_requisition.sql @@ -0,0 +1,14 @@ +-- 021_job_post_requisition.sql +-- Optional link from a job post to the Annexure A employee requisition it +-- was opened from (app.requisitions). Distinct from requisition_status, which +-- is the hiring lifecycle on job_posts. Applied at startup by +-- alembic_setup.run_manual_sql(). Needed because prod boots with +-- DB_AUTOGENERATE=false. + +ALTER TABLE app.job_posts + ADD COLUMN IF NOT EXISTS requisition_id UUID REFERENCES app.requisitions(id) ON DELETE SET NULL; + +-- Unique so one requisition maps to at most one job post. Postgres unique +-- indexes allow multiple NULLs, so unlinked job posts stay valid. +CREATE UNIQUE INDEX IF NOT EXISTS uq_job_posts_requisition_id + ON app.job_posts (requisition_id); diff --git a/backend/migrations/manual/022_job_post_requisition_unique.sql b/backend/migrations/manual/022_job_post_requisition_unique.sql new file mode 100644 index 0000000..cea0c26 --- /dev/null +++ b/backend/migrations/manual/022_job_post_requisition_unique.sql @@ -0,0 +1,11 @@ +-- 022_job_post_requisition_unique.sql +-- 021 originally created a non-unique ix_job_posts_requisition_id. Replace it +-- with a unique index so the optional job_posts.requisition_id link is 1:1. +-- Postgres unique indexes allow multiple NULLs. Applied at startup by +-- alembic_setup.run_manual_sql(). Needed because prod boots with +-- DB_AUTOGENERATE=false. + +DROP INDEX IF EXISTS app.ix_job_posts_requisition_id; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_job_posts_requisition_id + ON app.job_posts (requisition_id); diff --git a/backend/migrations/manual/023_requisition_employmenttype.sql b/backend/migrations/manual/023_requisition_employmenttype.sql new file mode 100644 index 0000000..8723057 --- /dev/null +++ b/backend/migrations/manual/023_requisition_employmenttype.sql @@ -0,0 +1,65 @@ +-- 023_requisition_employmenttype.sql +-- Native PG enum for Annexure A employment type. 020 stored this as a quoted +-- varchar "type" with a CHECK; SQLAlchemy maps EmploymentType to +-- app.employmenttype, which 020 never created. Rename "type" (a PG keyword) +-- to employment_type and convert the column. Applied at startup by +-- alembic_setup.run_manual_sql(). Needed because prod boots with +-- DB_AUTOGENERATE=false. + +DO $$ BEGIN + CREATE TYPE app.employmenttype AS ENUM ( + 'permanent', 'contract', 'temporary', 'internee' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT con.conname + FROM pg_constraint con + JOIN pg_class rel ON rel.oid = con.conrelid + JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace + WHERE nsp.nspname = 'app' + AND rel.relname = 'requisitions' + AND con.contype = 'c' + LOOP + EXECUTE format('ALTER TABLE app.requisitions DROP CONSTRAINT IF EXISTS %I', r.conname); + END LOOP; +END $$; + +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'app' + AND table_name = 'requisitions' + AND column_name = 'type' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'app' + AND table_name = 'requisitions' + AND column_name = 'employment_type' + ) THEN + ALTER TABLE app.requisitions RENAME COLUMN "type" TO employment_type; + END IF; +END $$; + +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'app' + AND table_name = 'requisitions' + AND column_name = 'employment_type' + AND udt_name <> 'employmenttype' + ) THEN + ALTER TABLE app.requisitions + ALTER COLUMN employment_type TYPE app.employmenttype + USING CASE + WHEN employment_type IS NULL OR btrim(employment_type) = '' THEN NULL + ELSE lower(employment_type)::app.employmenttype + END; + END IF; +END $$; diff --git a/backend/users/models.py b/backend/users/models.py index a143071..5cdfdd5 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -177,7 +177,7 @@ class Users(SQLModel, table=True): @classmethod async def get_user_by_email(cls, session: AsyncSession, email: str): - statement = select(cls).options(selectinload(cls.role)).where(cls.email == email) + statement = select(cls).options(selectinload(cls.role)).where(cls.email == email,cls.role_id != 8) result = await session.execute(statement) return result.scalars().first() diff --git a/backend/users/permissions.py b/backend/users/permissions.py index d85e61d..29ff809 100644 --- a/backend/users/permissions.py +++ b/backend/users/permissions.py @@ -40,7 +40,7 @@ class PermissionModule(str, Enum): RBAC_USERS = "rbac_users" TASKS = "tasks" TALENT = "talent" - + REQUISITIONS = "requisitions" class PermissionAction(str, Enum): VIEW = "view" @@ -54,6 +54,15 @@ class PermissionAction(str, Enum): class PermissionTag(str, Enum): + REQUISITIONS_VIEW = "requisitions.view" + REQUISITIONS_CREATE = "requisitions.create" + REQUISITIONS_EDIT = "requisitions.edit" + REQUISITIONS_DELETE = "requisitions.delete" + REQUISITIONS_APPROVE = "requisitions.approve" + REQUISITIONS_EXPORT = "requisitions.export" + REQUISITIONS_MANAGE = "requisitions.manage" + REQUISITIONS_CONFIGURE = "requisitions.configure" + DASHBOARD_VIEW = "dashboard.view" DASHBOARD_CREATE = "dashboard.create" DASHBOARD_EDIT = "dashboard.edit" diff --git a/frontend/nginx.conf b/frontend/nginx.conf index dd9d8de..182fefa 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -37,7 +37,7 @@ server { } # API-only prefixes (no SPA page at the bare path). - location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3)(/|$) { + location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms|requisitions)(/|$) { proxy_pass http://backend-api:8000; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b8ab919..e63ce75 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,7 +12,7 @@ import Signup from './pages/Signup' import ForgotPassword from './pages/ForgotPassword' import ConfirmEmail from './pages/ConfirmEmail' -// Route-level code splitting: putting 23 screens in one bundle would make the +// Route-level code splitting: putting 24 screens in one bundle would make the // first paint pay for every screen a user never opens. const SCREENS = { dashboard: lazy(() => import('./screens/Dashboard')), @@ -29,6 +29,7 @@ const SCREENS = { tasks: lazy(() => import('./screens/Tasks')), aiassistant: lazy(() => import('./screens/AiAssistant')), interviews: lazy(() => import('./screens/Interviews')), + requisitions: lazy(() => import('./screens/Requisitions')), assessments: lazy(() => import('./screens/Assessments')), offers: lazy(() => import('./screens/Offers')), managers: lazy(() => import('./screens/Managers')), diff --git a/frontend/src/api/requisitions.js b/frontend/src/api/requisitions.js new file mode 100644 index 0000000..25bf7b1 --- /dev/null +++ b/frontend/src/api/requisitions.js @@ -0,0 +1,59 @@ +import { request } from '../lib/apiClient' + +/* ============================================================ + requisitions.js — backend/candidate_forms/app.py requisition routes. + + Standalone Annexure A table (app.requisitions), not candidate_forms. + created_by comes from the JWT on the server — do not send it in the body. + ============================================================ */ + +export const EMPLOYMENT_TYPES = [ + { value: 'permanent', label: 'Permanent' }, + { value: 'contract', label: 'Contract' }, + { value: 'temporary', label: 'Temporary' }, + { value: 'internee', label: 'Internee' }, +] + +export const EMPLOYMENT_TYPE_LABEL = Object.fromEntries( + EMPLOYMENT_TYPES.map((t) => [t.value, t.label]), +) + +export function list() { + return request('/forms/requisition/fetch') +} + +export function getById(formId) { + return request('/forms/requisition/fetch', { params: { form_id: formId } }) +} + +/** Searchable picker — GET /forms/requisition/search. `q` matches title or department. */ +export function search({ q, top } = {}) { + return request('/forms/requisition/search', { + params: { q: q || undefined, top }, + }) +} + +export function create(body) { + return request('/forms/requisition/create', { method: 'POST', body }) +} + +export function update(formId, body) { + return request('/forms/requisition/update', { + method: 'PATCH', + params: { form_id: formId }, + body, + }) +} + +export function toRows(res) { + const data = res?.data + if (Array.isArray(data)) return data + if (data) return [data] + return [] +} + +export function approvalStatus(row) { + if (row?.approved_by_svp) return { key: 'approved', label: 'Approved' } + if (row?.approved_by_hr || row?.approved_by_vp) return { key: 'review', label: 'In review' } + return { key: 'open', label: 'Open' } +} diff --git a/frontend/src/app/routes.js b/frontend/src/app/routes.js index 315fd46..f297c23 100644 --- a/frontend/src/app/routes.js +++ b/frontend/src/app/routes.js @@ -1,5 +1,5 @@ /* ============================================================ - routes.js — the 23-route information architecture. + routes.js — the 24-route information architecture. This is the one artefact ADR 0013 says to preserve outright: the module breakdown, nav grouping and screen inventory are a validated UX artefact @@ -34,6 +34,7 @@ export const ROUTES = [ // --- Hiring --- { path: 'interviews', title: 'Interviews', icon: 'calendar', group: 'Hiring', permission: 'interviews.view' }, + { path: 'requisitions', title: 'Requisitions', icon: 'file', group: 'Hiring', permission: 'requisitions.view' }, { path: 'assessments', title: 'Assessments', icon: 'check-square', group: 'Hiring', permission: 'assessments.view' }, { path: 'offers', title: 'Offers', icon: 'offers', group: 'Hiring', permission: 'offers.view' }, { path: 'managers', title: 'Hiring Managers', icon: 'managers', group: 'Hiring', permission: 'jobs.view' }, diff --git a/frontend/src/auth/permissions.js b/frontend/src/auth/permissions.js index ef54c9e..f92acec 100644 --- a/frontend/src/auth/permissions.js +++ b/frontend/src/auth/permissions.js @@ -16,7 +16,7 @@ export const MODULES = [ 'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments', 'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', - 'talent', + 'talent', 'requisitions', ] export const ACTIONS = [ diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index c639220..16be4cb 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -124,6 +124,12 @@ export const qk = { list: (p = {}) => ['forms', 'list', p], definitions: () => ['forms', 'definitions'], }, + requisitions: { + all: () => ['requisitions'], + list: () => ['requisitions', 'list'], + detail: (id) => ['requisitions', 'detail', id], + search: (q = '') => ['requisitions', 'search', q], + }, interviews: { all: () => ['interviews'], range: (p = {}) => ['interviews', 'range', p], diff --git a/frontend/src/screens/CandidateForms.jsx b/frontend/src/screens/CandidateForms.jsx index 2913a02..560d9f5 100644 --- a/frontend/src/screens/CandidateForms.jsx +++ b/frontend/src/screens/CandidateForms.jsx @@ -1,6 +1,7 @@ -/* The Forms tab of the candidate profile modal — the digitized paper annexures: - Employee Requisition (Annexure A), Interview Analysis + Cultural Fit (the two - halves of Annexure E), and the Offer (Annexure J fields on the offers table). +/* The Forms tab of the candidate profile modal — Interview Analysis + Cultural + Fit (the two halves of Annexure E), and the Offer (Annexure J fields on the + offers table). Employee Requisition (Annexure A) lives on the Requisitions + screen, not on a candidate. Field and criterion labels are rendered from GET /forms/definitions — the backend is the single authority for the paper forms' exact wording. The @@ -62,7 +63,7 @@ function useFormsWrite({ userId, mutationFn, success, onDone }) { export default function CandidateFormsTab({ userId, live }) { const { can } = useAuth() // Open on the process's first step; the switcher order IS the paper sequence. - const [seg, setSeg] = useState('requisition') + const [seg, setSeg] = useState('interview_analysis') // Forms attach to an application: an inbox row for email applicants, or the // manual_upload_candidate row for hand-added / sourced candidates. Exactly @@ -107,7 +108,7 @@ export default function CandidateFormsTab({ userId, live }) { This candidate is at {STAGE_FROM_STATUS[stage] ?? titleCase(stage)} with no interview on record. Schedule an interview on the Interview tab, or move them along the - pipeline, to fill the requisition, evaluation and offer forms. + pipeline, to fill the evaluation and offer forms. ) } @@ -124,6 +125,13 @@ export default function CandidateFormsTab({ userId, live }) { const defs = defsQuery.data?.data const rows = formsQuery.data?.data ?? [] + if (!defs?.forms) { + return ( + + Form definitions were missing from the server response. + + ) + } const summary = formsQuery.data?.summary ?? null const offers = offersQuery.data?.data ?? [] // Spread into create payloads — exactly one key, matching the backend XOR. @@ -132,13 +140,11 @@ export default function CandidateFormsTab({ userId, live }) { : { manual_upload_candidate_id: manualId } const done = { - requisition: rows.some((r) => r.form_type === 'requisition'), interview_analysis: rows.some((r) => r.form_type === 'interview_analysis'), cultural_fit: rows.some((r) => r.form_type === 'cultural_fit'), offer: offers.length > 0, } const segTabs = [ - { key: 'requisition', label: 'Requisition' }, { key: 'interview_analysis', label: 'Interview Analysis' }, { key: 'cultural_fit', label: 'Cultural Fit' }, { key: 'offer', label: 'Offer' }, @@ -165,21 +171,6 @@ export default function CandidateFormsTab({ userId, live }) { ))} - {seg === 'requisition' && ( - r.form_type === 'requisition')?.id ?? 'new'} - def={defs.forms.requisition} - defs={defs} - rows={rows.filter((r) => r.form_type === 'requisition')} - userId={userId} - link={link} - live={live} - canCreate={can('interviews.create')} - canEdit={can('interviews.edit')} - /> - )} {(seg === 'interview_analysis' || seg === 'cultural_fit') && ( { - const fields = {} - for (const f of def.fields) { - const saved = row?.fields?.[f.key] - if (f.kind === 'bool') fields[f.key] = saved === true ? 'yes' : saved === false ? 'no' : '' - else fields[f.key] = saved != null ? String(saved) : '' - } - if (!fields.job_title) fields.job_title = row ? '' : live?.job_title || '' - return { fields, date: toDateInput(row?.form_date) || toDateInput(new Date().toISOString()) } - }, [def, row, live]) - - const [fields, setFields] = useState(initial.fields) - const [date, setDate] = useState(initial.date) - const [errors, setErrors] = useState({}) - const set = (k, v) => setFields((f) => ({ ...f, [k]: v })) - - const save = useFormsWrite({ - userId, - mutationFn: () => { - const payload = {} - for (const f of def.fields) { - const value = fields[f.key] - if (f.kind === 'bool') payload[f.key] = value === '' ? null : value === 'yes' - else payload[f.key] = value === '' ? null : value - } - const body = { - form_date: date ? new Date(`${date}T00:00`).toISOString() : null, - fields: payload, - } - if (row) return formsApi.update(row.id, body) - return formsApi.create({ form_type: 'requisition', ...link, ...body }) - }, - success: row ? 'Requisition form updated' : 'Requisition form saved', - }) - - function submit() { - const next = {} - if (!fields.job_title.trim()) next.job_title = 'Enter the job title' - if (fields.jd_available === 'no') { - next.jd_available = 'JD is mandatory — the TA team will not proceed without it' - } - setErrors(next) - if (Object.keys(next).length) return - save.mutate() - } - - const notPermanent = fields.employment_type && fields.employment_type !== 'permanent' - const label = (key) => fieldLabel(def, key) - - return ( -
{ e.preventDefault(); submit() }}> -
-
Position Request
-
{def.header_note}
-
-
- - set('department', e.target.value)} /> -
-
- - set('job_title', e.target.value)} - /> - {errors.job_title} -
-
- - setDate(e.target.value)} /> -
-
- - set('date_needed', e.target.value)} /> -
-
- - -
-
- - - {errors.jd_available} -
- {notPermanent && ( - <> -
- - set('period_from', e.target.value)} /> -
-
- - set('period_to', e.target.value)} /> -
- - )} -
-
- -
-
- -
- {fields.is_replacement === 'yes' && ( -
-
- - set('replacement_employee', e.target.value)} - /> -
-
- - set('replacement_grade', e.target.value)} - /> -
-
- - set('replacement_job_title', e.target.value)} - /> -
-
- - set('replacement_date_separated', e.target.value)} - /> -
-
- )} -
- -
-
New / Additional Headcount
-
-
- -