From f9eb9f1f24697cdf419fc2aff35d2b7719861908 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Mon, 24 Aug 2026 20:14:36 +0500 Subject: [PATCH] Candidate hiring forms (Annexure A/E/J), full-page profile, UX audit fixes Backend: new candidate_forms domain (requisition, interview analysis, cultural fit) with XOR inbox/manual keys, server-recomputed section averages and combined summary, INTERVIEW-stage gate (409), history events, INTERVIEWS_* permissions + 008 RBAC seed; offers table gains the seven Annexure-J fields. Frontend: Forms tab in the candidate profile (paper-exact labels from /forms/definitions, rating tables, score summary tiles, completion dots); profile converted to a full page at /candidate/:userId opened from Candidates, Talent Pool and Pipeline; live Advance Stage now calls PATCH /candidate/stage; workflow-ordered tabs; responsive pass verified by headless-Edge screenshots at 375-2400px; Stars import crash fix in Interviews; Matching tab strip wraps on phones. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 + backend/candidate_forms/app.py | 122 ++ backend/candidate_forms/models.py | 134 +++ backend/candidate_forms/plugins.py | 362 ++++++ backend/candidate_forms/serializers.py | 32 + backend/candidate_forms/views.py | 338 ++++++ backend/job/history/enums.py | 2 + backend/main.py | 2 + .../manual/008_hiring_forms_rbac.sql | 52 + backend/offer/app.py | 14 + backend/offer/models.py | 8 + backend/offer/plugins.py | 4 +- backend/offer/serializers.py | 7 + backend/offer/views.py | 2 + backend/tests/test_candidate_forms.py | 162 +++ frontend/dist/index.html | 4 +- frontend/src/App.jsx | 11 + frontend/src/api/forms.js | 62 + frontend/src/api/offers.js | 8 + frontend/src/lib/queryKeys.js | 5 + frontend/src/screens/CandidateForms.jsx | 1029 +++++++++++++++++ frontend/src/screens/CandidatePage.jsx | 24 + frontend/src/screens/CandidateProfile.jsx | 201 +++- frontend/src/screens/Candidates.jsx | 15 +- frontend/src/screens/Interviews.jsx | 2 +- frontend/src/screens/Matching.jsx | 1 + frontend/src/screens/Pipeline.jsx | 6 +- frontend/src/screens/TalentPool.jsx | 13 +- frontend/src/styles/styles.css | 95 ++ 29 files changed, 2665 insertions(+), 55 deletions(-) create mode 100644 backend/candidate_forms/app.py create mode 100644 backend/candidate_forms/models.py create mode 100644 backend/candidate_forms/plugins.py create mode 100644 backend/candidate_forms/serializers.py create mode 100644 backend/candidate_forms/views.py create mode 100644 backend/migrations/manual/008_hiring_forms_rbac.sql create mode 100644 backend/tests/test_candidate_forms.py create mode 100644 frontend/src/api/forms.js create mode 100644 frontend/src/screens/CandidateForms.jsx create mode 100644 frontend/src/screens/CandidatePage.jsx diff --git a/.gitignore b/.gitignore index d4f06bd..15acc6b 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ tests/ tests/** */tests/** */tests/**/* +# Paper form source documents (Annexure A/E/J) — reference material, not code. +# Root-anchored: backend/candidate_forms/ is the forms domain package and IS tracked. +/candidate_forms/ diff --git a/backend/candidate_forms/app.py b/backend/candidate_forms/app.py new file mode 100644 index 0000000..37d5f3a --- /dev/null +++ b/backend/candidate_forms/app.py @@ -0,0 +1,122 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +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 + +router = APIRouter() + + +class FormCreate(BaseModel): + form_type: str + inbox_id: int | None = None + manual_upload_candidate_id: str | None = None + job_post_id: str | None = None + interviewer_id: str | None = None + form_date: datetime | None = None + sections: list | None = None + fields: dict | None = None + recommendation: str | None = None + + +class FormUpdate(BaseModel): + interviewer_id: str | None = None + form_date: datetime | None = None + sections: list | None = None + fields: dict | None = None + recommendation: str | None = None + + +@router.get("/forms/definitions") +async def fetch_form_definitions( + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_VIEW)), +): + try: + return JSONResponse(content={"data": definitions_payload(), "status_code": 200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/forms/fetch") +async def fetch_forms( + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_VIEW)), + form_id: str | None = Query(None), + inbox_id: int | None = Query(None), + manual_upload_candidate_id: str | None = Query(None), + job_post_id: str | None = Query(None), + form_type: str | None = Query(None), + top: int | None = Query(None), + skip: int = Query(0, ge=0), + session: AsyncSession = Depends(get_session), +): + try: + service = CandidateForm(session=session) + data, summary, total = await service.get_forms( + form_id, inbox_id, manual_upload_candidate_id, job_post_id, form_type, top, skip, + ) + return JSONResponse( + content={"data": data, "summary": summary, "total": total, "status_code": 200} + ) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/forms/create") +async def create_form( + payload: FormCreate, + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_CREATE)), + session: AsyncSession = Depends(get_session), +): + try: + service = CandidateForm(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/update") +async def update_form( + payload: FormUpdate, + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_EDIT)), + form_id: str = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service = CandidateForm(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("/forms/delete") +async def delete_form( + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_DELETE)), + form_id: str = Query(...), + session: AsyncSession = Depends(get_session), +): + try: + service = CandidateForm(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/candidate_forms/models.py b/backend/candidate_forms/models.py new file mode 100644 index 0000000..1071df5 --- /dev/null +++ b/backend/candidate_forms/models.py @@ -0,0 +1,134 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime, JSON, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, SQLModel, select + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class CandidateForms(SQLModel, table=True): + """One digitized hiring form (Annexure A requisition, or one of the two + Annexure E evaluation forms). Exactly one of inbox_id / + manual_upload_candidate_id links it to an application; `sections` holds the + rated grids with server-recomputed averages, `fields` the scalar entries.""" + + __tablename__ = "candidate_forms" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + inbox_id: int | None = Field(default=None, index=True, foreign_key="inbox.id") + manual_upload_candidate_id: uuid.UUID | None = Field( + default=None, index=True, foreign_key="manual_upload_candidate.id" + ) + job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id") + form_type: str = Field(index=True) + interviewer_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") + form_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + sections: list | None = Field(default=None, sa_type=JSON) + fields: dict | None = Field(default=None, sa_type=JSON) + overall_score: float | None = Field(default=None) + recommendation: str | None = Field(default=None) + 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)) + is_deleted: bool = Field(default=False) + + @staticmethod + def _as_uuid(record_id) -> uuid.UUID | None: + if record_id in (None, ""): + return None + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_form_by_id(cls, session: AsyncSession, record_id): + uid = cls._as_uuid(record_id) + if uid is None: + return None + result = await session.execute( + select(cls).where(cls.id == uid, cls.is_deleted == False) # noqa: E712 + ) + return result.scalars().first() + + @classmethod + async def fetch_forms( + cls, + session: AsyncSession, + *, + form_id=None, + inbox_id=None, + manual_upload_candidate_id=None, + job_post_id=None, + form_type=None, + top: int | None = None, + skip: int = 0, + ): + if form_id: + row = await cls.get_form_by_id(session, form_id) + if row is None: + return [], 0 + return [row], 1 + + statement = select(cls).where(cls.is_deleted == False) # noqa: E712 + if inbox_id is not None: + statement = statement.where(cls.inbox_id == int(inbox_id)) + if manual_upload_candidate_id is not None: + uid = cls._as_uuid(manual_upload_candidate_id) + if uid is None: + return [], 0 + statement = statement.where(cls.manual_upload_candidate_id == uid) + if job_post_id is not None: + uid = cls._as_uuid(job_post_id) + if uid is None: + return [], 0 + statement = statement.where(cls.job_post_id == uid) + if form_type: + statement = statement.where(cls.form_type == form_type) + count_statement = select(func.count()).select_from(statement.subquery()) + total = (await session.execute(count_statement)).scalar_one() + statement = statement.order_by(cls.created_at.desc()) + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return list(result.scalars().all()), total + + @classmethod + async def insert_form(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + 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 + for key, value in fields.items(): + setattr(row, key, value) + 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/candidate_forms/plugins.py b/backend/candidate_forms/plugins.py new file mode 100644 index 0000000..9ab696d --- /dev/null +++ b/backend/candidate_forms/plugins.py @@ -0,0 +1,362 @@ +"""Pure helpers for the hiring forms domain — no FastAPI, no DB. + +FORM_DEFINITIONS is the single authority for section/criterion/field keys AND +their on-screen labels, which reproduce the paper annexures verbatim (Annexure A +Employee Requisition Form, Annexure E Interview Evaluation Form). The frontend +renders labels from /forms/definitions, and criterion labels are denormalized +into every saved row so historical records survive future renames. +""" + +FORM_TYPES = ("requisition", "interview_analysis", "cultural_fit") + +RATING_MIN = 1 +RATING_MAX = 4 +RATING_LABELS = { + 1: "Below Average (1)", + 2: "Average (2)", + 3: "Good (3)", + 4: "Excellent (4)", +} +RATING_SCALE_NOTE = ( + "Rating Scale: 1 = Below Average | 2 = Average | 3 = Good | 4 = Excellent. " + "Tick the box that applies for each criterion." +) + +RECOMMENDATIONS = ( + "selected", + "hold", + "next_round", + "not_selected", + "other_position", + "offer_placement", +) +RECOMMENDATION_LABELS = { + "selected": "Selected", + "hold": "Hold for now", + "next_round": "Shortlist for next round", + "not_selected": "Not selected", + "other_position": "Consider for other position", + "offer_placement": "Offer Placement", +} + +# INTERVIEW onward; APPROVED is the legacy spelling the UI maps to Hired. +FORM_READY_STATUSES = ("INTERVIEW", "OFFER", "HIRED", "APPROVED") + +EMPLOYMENT_TYPES = ("permanent", "temporary", "contract", "internee") +EMPLOYMENT_TYPE_LABELS = { + "permanent": "Permanent", + "temporary": "Temporary", + "contract": "Contract", + "internee": "Internee", +} + +_EVALUATION_HEADER_FIELDS = [ + {"key": "interviewer_name", "label": "Interviewer Name", "kind": "text"}, + {"key": "department", "label": "Department/Division", "kind": "text"}, + {"key": "position_title", "label": "Position Interviewed For", "kind": "text"}, +] + +_EVALUATION_FOOTER_FIELDS = [ + {"key": "strengths", "label": "Key Strengths", "kind": "textarea"}, + {"key": "concerns", "label": "Main Concerns or Gaps", "kind": "textarea"}, + { + "key": "overall_observation", + "label": "Overall Observation of the Candidate", + "kind": "textarea", + }, +] + +FORM_DEFINITIONS = { + "interview_analysis": { + "title": "Interview Analysis", + "source": "Annexure E - Interview Evaluation Form", + "scale_note": RATING_SCALE_NOTE, + "sections": [ + { + "key": "technical", + "title": "TECHNICAL COMPETENCY ASSESSMENT", + "average_label": "TECHNICAL SECTION AVERAGE", + "criteria": [ + {"key": "core_job_knowledge", "label": "Core Job Knowledge & Domain Expertise"}, + {"key": "relevant_experience", "label": "Depth of Relevant Experience"}, + {"key": "problem_solving", "label": "Problem Solving & Analytical Reasoning"}, + {"key": "tools_proficiency", "label": "Technical Tools & Systems Proficiency"}, + {"key": "quality_of_work", "label": "Quality of Work & Attention to Detail"}, + ], + }, + { + "key": "behavioral", + "title": "BEHAVIORAL COMPETENCY ASSESSMENT", + "average_label": "BEHAVIORAL SECTION AVERAGE", + "criteria": [ + {"key": "communication", "label": "Communication & Clarity of Expression"}, + {"key": "active_listening", "label": "Active Listening & Comprehension"}, + {"key": "ownership", "label": "Ownership & Accountability"}, + {"key": "resilience", "label": "Resilience Under Pressure"}, + {"key": "learning_agility", "label": "Learning Agility & Coachability"}, + ], + }, + ], + "fields": ( + _EVALUATION_HEADER_FIELDS + + [ + {"key": "summary", "label": "BRIEF SUMMARY OF THE CANDIDATE", "kind": "textarea"}, + {"key": "technical_note", "label": "Technical Competency — Notes", "kind": "text"}, + {"key": "behavioral_note", "label": "Behavioral Competency — Notes", "kind": "text"}, + ] + + _EVALUATION_FOOTER_FIELDS + ), + "has_recommendation": True, + }, + "cultural_fit": { + "title": "Cultural Fit", + "source": "Annexure E - Interview Evaluation Form", + "scale_note": RATING_SCALE_NOTE, + "sections": [ + { + "key": "cultural", + "title": "CULTURAL FIT", + "average_label": "CULTURAL FIT SECTION", + "criteria": [ + {"key": "company_values", "label": "Alignment with Company Values"}, + {"key": "professionalism", "label": "Professionalism & Integrity"}, + {"key": "collaboration", "label": "Collaboration & Team Orientation"}, + {"key": "adaptability", "label": "Adaptability to Change"}, + {"key": "work_ethic", "label": "Work Ethic & Reliability"}, + ], + }, + ], + "fields": ( + _EVALUATION_HEADER_FIELDS + + [{"key": "cultural_note", "label": "Cultural Fit — Notes", "kind": "text"}] + + _EVALUATION_FOOTER_FIELDS + ), + "has_recommendation": True, + }, + "requisition": { + "title": "Employee Requisition", + "source": "Annexure A - Employee Requisition Form", + "header_note": "To: Human Resource Department", + "sections": [], + "fields": [ + {"key": "department", "label": "From: (Dept.)", "kind": "text"}, + {"key": "job_title", "label": "Job Title", "kind": "text"}, + {"key": "date_needed", "label": "Date Needed", "kind": "date"}, + { + "key": "employment_type", + "label": "Permanent / Temporary / Contract / Internee", + "kind": "select", + "options": list(EMPLOYMENT_TYPES), + }, + {"key": "period_from", "label": "If not permanent, specify the period — From", "kind": "date"}, + {"key": "period_to", "label": "If not permanent, specify the period — To", "kind": "date"}, + { + "key": "jd_available", + "label": ( + "JD Available (JD is mandatory, TA team will not proceed with " + "sourcing until JD is provided)" + ), + "kind": "bool", + }, + {"key": "is_replacement", "label": "IF A REPLACEMENT, COMPLETE THE FOLLOWING", "kind": "bool"}, + {"key": "replacement_employee", "label": "Employee to be replaced", "kind": "text"}, + {"key": "replacement_grade", "label": "Grade", "kind": "text"}, + {"key": "replacement_job_title", "label": "Job Title (replaced employee)", "kind": "text"}, + {"key": "replacement_date_separated", "label": "Date Separated", "kind": "date"}, + { + "key": "headcount_justification", + "label": "IN CASE OF NEW/ADDITIONAL HEADCOUNT PLEASE PROVIDE JUSTIFICATION", + "kind": "textarea", + }, + {"key": "proposed_budget", "label": "PROPOSE BUDGET", "kind": "text"}, + {"key": "recommended_grade", "label": "RECOMMENDED GRADE", "kind": "text"}, + {"key": "internal_recommendation", "label": "INCASE OF INTERNAL RECOMMENDATE", "kind": "bool"}, + {"key": "recommended_employee_name", "label": "EMPLOYEE NAME", "kind": "text"}, + {"key": "recommended_employee_department", "label": "EMPLOYEE DEPARTMENT", "kind": "text"}, + {"key": "initiated_by", "label": "Initiated By — Name", "kind": "text"}, + {"key": "initiated_date", "label": "Initiated By — Date", "kind": "date"}, + {"key": "recommended_by", "label": "Recommended By — Name (Director)", "kind": "text"}, + {"key": "recommended_date", "label": "Recommended By — Date", "kind": "date"}, + {"key": "approved_by", "label": "Approved By — Name (Director HR)", "kind": "text"}, + {"key": "approved_date", "label": "Approved By — Date", "kind": "date"}, + {"key": "vp_approved_by", "label": "Approved By — Name (VP/SVP)", "kind": "text"}, + {"key": "vp_approved_date", "label": "Approved By — Date (VP/SVP)", "kind": "date"}, + ], + "field_enums": {"employment_type": EMPLOYMENT_TYPES}, + "has_recommendation": False, + }, +} + + +def definitions_payload() -> dict: + """The response body for GET /forms/definitions.""" + return { + "form_types": list(FORM_TYPES), + "forms": FORM_DEFINITIONS, + "rating_labels": {str(k): v for k, v in RATING_LABELS.items()}, + "recommendations": list(RECOMMENDATIONS), + "recommendation_labels": dict(RECOMMENDATION_LABELS), + "employment_types": list(EMPLOYMENT_TYPES), + "employment_type_labels": dict(EMPLOYMENT_TYPE_LABELS), + "form_ready_statuses": list(FORM_READY_STATUSES), + } + + +def _coerce_rating(value): + if value in (None, ""): + return None + try: + number = float(value) + except (TypeError, ValueError): + raise ValueError(f"rating must be a number, got {value!r}") + if number != int(number): + raise ValueError(f"rating must be a whole number, got {value!r}") + rating = int(number) + if rating < RATING_MIN or rating > RATING_MAX: + raise ValueError(f"rating must be between {RATING_MIN} and {RATING_MAX}, got {rating}") + return rating + + +def _mean(values, digits=2): + values = [v for v in values if v is not None] + if not values: + return None + return round(sum(values) / len(values), digits) + + +def normalize_sections(form_type: str, sections): + """Validate submitted rated sections against the form definition and + recompute all derived numbers. Returns (normalized_sections, overall_score). + + Every definition section is emitted in definition order with denormalized + labels; submitted per-criterion ratings are merged in; client-sent averages + are discarded and recomputed (mean of the non-null ratings, 2 dp). The + overall score is the mean of the section averages. Raises ValueError on + unknown section/criterion keys or out-of-range ratings (422 material). + """ + definition = FORM_DEFINITIONS.get(form_type) + if definition is None: + raise ValueError(f"unknown form_type {form_type!r}") + if not definition["sections"]: + return None, None + if sections is None: + sections = [] + if not isinstance(sections, list): + raise ValueError("sections must be a list") + + known_sections = {s["key"]: s for s in definition["sections"]} + submitted = {} + for entry in sections: + if not isinstance(entry, dict): + raise ValueError("each section must be an object") + key = entry.get("key") + if key not in known_sections: + raise ValueError(f"unknown section {key!r} for {form_type}") + criteria = entry.get("criteria") or [] + if not isinstance(criteria, list): + raise ValueError("section criteria must be a list") + known_criteria = {c["key"] for c in known_sections[key]["criteria"]} + ratings = {} + for criterion in criteria: + if not isinstance(criterion, dict): + raise ValueError("each criterion must be an object") + ckey = criterion.get("key") + if ckey not in known_criteria: + raise ValueError(f"unknown criterion {ckey!r} in section {key!r}") + ratings[ckey] = _coerce_rating(criterion.get("rating")) + submitted[key] = ratings + + normalized = [] + section_averages = [] + for section_def in definition["sections"]: + ratings = submitted.get(section_def["key"], {}) + criteria = [ + { + "key": c["key"], + "label": c["label"], + "rating": ratings.get(c["key"]), + } + for c in section_def["criteria"] + ] + average = _mean([c["rating"] for c in criteria]) + if average is not None: + section_averages.append(average) + normalized.append( + { + "key": section_def["key"], + "title": section_def["title"], + "criteria": criteria, + "average": average, + } + ) + return normalized, _mean(section_averages) + + +def normalize_fields(form_type: str, fields): + """Keep only the definition's field keys, validate enums, coerce booleans.""" + definition = FORM_DEFINITIONS.get(form_type) + if definition is None: + raise ValueError(f"unknown form_type {form_type!r}") + if fields is None: + return {} + if not isinstance(fields, dict): + raise ValueError("fields must be an object") + + known = {f["key"]: f for f in definition["fields"]} + enums = definition.get("field_enums", {}) + normalized = {} + for key, value in fields.items(): + spec = known.get(key) + if spec is None: + continue + if value in (None, ""): + normalized[key] = None + continue + if key in enums: + value = str(value).strip().lower() + if value not in enums[key]: + raise ValueError(f"{key} must be one of {', '.join(enums[key])}") + elif spec["kind"] == "bool": + if isinstance(value, str): + value = value.strip().lower() in ("true", "yes", "1", "on") + else: + value = bool(value) + else: + value = str(value).strip() or None + normalized[key] = value + return normalized + + +def combined_summary(rows): + """Annexure E's OVERALL SCORE SUMMARY across the two evaluation forms. + + `rows` are candidate_forms records (attribute access: form_type, created_at, + sections). The latest interview_analysis row supplies the technical and + behavioral averages, the latest cultural_fit row the cultural average. + The combined overall (mean of the three section averages, 2 dp) appears + only once all three exist. Returns None when neither evaluation exists. + """ + latest = {} + for row in rows: + if row.form_type not in ("interview_analysis", "cultural_fit"): + continue + current = latest.get(row.form_type) + if current is None or (row.created_at and current.created_at and row.created_at > current.created_at): + latest[row.form_type] = row + if not latest: + return None + + averages = {"technical": None, "behavioral": None, "cultural": None} + for row in latest.values(): + for section in row.sections or []: + key = section.get("key") + if key in averages: + averages[key] = section.get("average") + + complete = all(v is not None for v in averages.values()) + return { + "technical_avg": averages["technical"], + "behavioral_avg": averages["behavioral"], + "cultural_avg": averages["cultural"], + "combined_overall": _mean(list(averages.values())) if complete else None, + } diff --git a/backend/candidate_forms/serializers.py b/backend/candidate_forms/serializers.py new file mode 100644 index 0000000..aa8362b --- /dev/null +++ b/backend/candidate_forms/serializers.py @@ -0,0 +1,32 @@ +def serialize_form( + row, + *, + candidate_name=None, + job_title=None, + interviewer_name=None, + created_by_name=None, +) -> dict: + """`candidate_name` / `job_title` / user names come from one batched lookup + in views — never a lazy per-row load.""" + return { + "id": str(row.id) if row.id else None, + "inbox_id": row.inbox_id, + "manual_upload_candidate_id": ( + str(row.manual_upload_candidate_id) if row.manual_upload_candidate_id else None + ), + "job_post_id": str(row.job_post_id) if row.job_post_id else None, + "candidate_name": candidate_name, + "job_title": job_title, + "form_type": row.form_type, + "interviewer_id": str(row.interviewer_id) if row.interviewer_id else None, + "interviewer_name": interviewer_name, + "form_date": row.form_date.isoformat() if row.form_date else None, + "sections": list(row.sections) if row.sections else None, + "fields": dict(row.fields) if row.fields else {}, + "overall_score": row.overall_score, + "recommendation": row.recommendation, + "created_by": str(row.created_by) if row.created_by else None, + "created_by_name": created_by_name, + "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 new file mode 100644 index 0000000..447a797 --- /dev/null +++ b/backend/candidate_forms/views.py @@ -0,0 +1,338 @@ +import logging +import uuid +from datetime import timezone + +from fastapi import HTTPException +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from candidate_forms.models import CandidateForms, _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 inbox.models import Inbox +from job.candidate.models import Manual_UPLOAD_CANDIDATE +from job.history.enums import HistoryEvent +from job.history.views import HistoryRecorder +from job.job_post.models import JobPosts +from users.models import Users + +logger = logging.getLogger("candidate_forms") + + +def _as_uuid(value): + if value in (None, ""): + return None + try: + return uuid.UUID(str(value)) + except (TypeError, ValueError): + return None + + +def _user_id(current_user): + if not current_user or not current_user.get("id"): + raise HTTPException(status_code=401, detail="Not authenticated") + uid = _as_uuid(current_user["id"]) + if uid is None: + raise HTTPException(status_code=401, detail="Invalid user id") + return uid + + +def _aware(value): + if value is not None and getattr(value, "tzinfo", None) is None: + return value.replace(tzinfo=timezone.utc) + return value + + +def _stage_value(status) -> str: + return str(getattr(status, "value", status) or "").upper() + + +class CandidateForm: + def __init__(self, session: AsyncSession): + self.session = session + + async def _validate_link(self, payload): + """Exactly one of inbox_id / manual_upload_candidate_id; both rows must + exist. Returns (inbox_id, manual_id, job_post_id, current_stage).""" + inbox_id = payload.get("inbox_id") + manual_id = _as_uuid(payload.get("manual_upload_candidate_id")) + has_inbox = inbox_id is not None + has_manual = manual_id is not None + if has_inbox == has_manual: + raise HTTPException( + status_code=422, + detail="Exactly one of inbox_id or manual_upload_candidate_id is required", + ) + if has_inbox: + try: + inbox_id = int(inbox_id) + except (TypeError, ValueError): + raise HTTPException(status_code=422, detail="Invalid inbox_id") + link = await Inbox.get_inbox_with_message(self.session, inbox_id) + if link is None: + raise HTTPException(status_code=404, detail="Inbox record not found") + stage = _stage_value( + link.messages.application_status if link.messages is not None else None + ) + else: + inbox_id = None + manual = await Manual_UPLOAD_CANDIDATE.get_by_id(self.session, manual_id) + if manual is None: + raise HTTPException(status_code=404, detail="Manual upload candidate not found") + stage = _stage_value(manual.status) + + job_post_id = _as_uuid(payload.get("job_post_id")) + if payload.get("job_post_id") and job_post_id is None: + raise HTTPException(status_code=422, detail="Invalid job_post_id") + if job_post_id is not None: + post = await JobPosts.get_job_post_by_id(self.session, str(job_post_id)) + if not post or post.is_deleted: + raise HTTPException(status_code=404, detail="Job post not found") + return inbox_id, manual_id, job_post_id, stage + + 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] + job_ids = [r.job_post_id for r in rows if r.job_post_id] + + inbox_by_id = {} + if inbox_ids: + result = await self.session.execute( + select(Inbox) + .options(selectinload(Inbox.messages), selectinload(Inbox.user)) + .where(Inbox.id.in_(inbox_ids)) + ) + inbox_by_id = {row.id: row for row in result.scalars().all()} + for row in inbox_by_id.values(): + msg = row.messages + if msg is not None and msg.assigned_job_post_id: + job_ids.append(msg.assigned_job_post_id) + + manual_by_id = {} + if manual_ids: + result = await self.session.execute( + select(Manual_UPLOAD_CANDIDATE).where(Manual_UPLOAD_CANDIDATE.id.in_(manual_ids)) + ) + manual_by_id = {row.id: row for row in result.scalars().all()} + for row in manual_by_id.values(): + if row.job_post_id: + job_ids.append(row.job_post_id) + + jobs_by_id = {} + uids = [j for j in set(job_ids) if j] + if uids: + result = await self.session.execute(select(JobPosts).where(JobPosts.id.in_(uids))) + jobs_by_id = {row.id: row for row in result.scalars().all()} + + user_ids = {r.interviewer_id for r in rows if r.interviewer_id} + user_ids |= {r.created_by for r in rows if r.created_by} + users_by_id = {} + if user_ids: + result = await self.session.execute( + select(Users.id, Users.name).where(Users.id.in_(user_ids)) + ) + users_by_id = {uid: name for uid, name in result.all()} + return inbox_by_id, manual_by_id, jobs_by_id, users_by_id + + def _labels(self, row, inbox_by_id, manual_by_id, jobs_by_id): + candidate_name = None + job_title = None + if row.job_post_id and row.job_post_id in jobs_by_id: + job_title = jobs_by_id[row.job_post_id].title + if row.inbox_id is not None: + link = inbox_by_id.get(row.inbox_id) + if link is not None: + if link.user is not None: + candidate_name = link.user.name + msg = link.messages + if job_title is None and msg is not None and msg.assigned_job_post_id: + job = jobs_by_id.get(msg.assigned_job_post_id) + if job is not None: + job_title = job.title + if row.manual_upload_candidate_id: + manual = manual_by_id.get(row.manual_upload_candidate_id) + if manual is not None: + candidate_name = candidate_name or manual.candidate_name or None + if job_title is None and manual.job_post_id: + job = jobs_by_id.get(manual.job_post_id) + if job is not None: + job_title = job.title + return candidate_name, job_title + + async def _serialize_rows(self, rows): + inbox_by_id, manual_by_id, jobs_by_id, users_by_id = await self._context_maps(rows) + out = [] + for row in rows: + name, title = self._labels(row, inbox_by_id, manual_by_id, jobs_by_id) + out.append( + serialize_form( + row, + candidate_name=name, + job_title=title, + interviewer_name=users_by_id.get(row.interviewer_id), + created_by_name=users_by_id.get(row.created_by), + ) + ) + return out + + async def get_forms( + self, + form_id=None, + inbox_id=None, + manual_upload_candidate_id=None, + job_post_id=None, + form_type=None, + top=None, + skip=0, + ): + if form_type and form_type not in FORM_TYPES: + raise HTTPException( + status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}" + ) + rows, total = await CandidateForms.fetch_forms( + self.session, + form_id=form_id, + inbox_id=inbox_id, + manual_upload_candidate_id=manual_upload_candidate_id, + job_post_id=job_post_id, + form_type=form_type, + top=top, + skip=skip or 0, + ) + + summary = None + if inbox_id is not None or manual_upload_candidate_id is not None: + if form_type: + # The filtered fetch may not include both evaluation forms. + summary_rows, _ = await CandidateForms.fetch_forms( + self.session, + inbox_id=inbox_id, + manual_upload_candidate_id=manual_upload_candidate_id, + ) + else: + summary_rows = rows + summary = combined_summary(summary_rows) + return await self._serialize_rows(rows), summary, total + + async def create_form(self, payload, current_user): + form_type = (payload.get("form_type") or "").strip() + if form_type not in FORM_TYPES: + raise HTTPException( + status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}" + ) + inbox_id, manual_id, job_post_id, stage = await self._validate_link(payload) + if stage not in FORM_READY_STATUSES: + raise HTTPException( + status_code=409, + detail=( + "Forms unlock at the Interview stage — this candidate is at " + f"{stage or 'Shortlist'}" + ), + ) + + 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() + + row = await CandidateForms.insert_form(self.session, fields) + await HistoryRecorder(self.session).record( + HistoryEvent.FORM_CREATED, + current_user=current_user, + inbox_id=inbox_id, + manual_upload_candidate_id=manual_id, + entity_type="candidate_form", + entity_id=row.id, + to_value=form_type, + commit=True, + ) + return (await self._serialize_rows([row]))[0] + + async def update_form(self, form_id, payload, current_user): + _user_id(current_user) + row = await CandidateForms.get_form_by_id(self.session, form_id) + if not row: + raise HTTPException(status_code=404, detail="Form not found") + + fields = self._normalize_payload(row.form_type, payload) + if not fields: + raise HTTPException(status_code=400, detail="No fields to update") + + updated = await CandidateForms.update_form(self.session, form_id, fields) + if not updated: + raise HTTPException(status_code=404, detail="Form not found") + await HistoryRecorder(self.session).record( + HistoryEvent.FORM_UPDATED, + current_user=current_user, + inbox_id=updated.inbox_id, + manual_upload_candidate_id=updated.manual_upload_candidate_id, + entity_type="candidate_form", + entity_id=updated.id, + to_value=updated.form_type, + commit=True, + ) + return (await self._serialize_rows([updated]))[0] + + async def delete_form(self, form_id, current_user): + _user_id(current_user) + row = await CandidateForms.soft_delete_form(self.session, form_id) + if not row: + raise HTTPException(status_code=404, detail="Form not found") + return {"id": str(row.id), "deleted": True} diff --git a/backend/job/history/enums.py b/backend/job/history/enums.py index 5a3cdc1..8d955d6 100644 --- a/backend/job/history/enums.py +++ b/backend/job/history/enums.py @@ -18,3 +18,5 @@ class HistoryEvent(str, Enum): CANDIDATE_IMPORTED = "candidate.imported" DOCUMENT_UPLOADED = "document.uploaded" ATS_SCORED = "ats.scored" + FORM_CREATED = "form.created" + FORM_UPDATED = "form.updated" diff --git a/backend/main.py b/backend/main.py index 9f221c8..ab61501 100644 --- a/backend/main.py +++ b/backend/main.py @@ -20,6 +20,7 @@ from saved_search.app import router as saved_search_router from search.app import router as search_router from interview.app import router as interview_router from talent.app import router as talent_router +from candidate_forms.app import router as candidate_forms_router logging.basicConfig(level=logging.INFO,format="%(levelname)-8s %(name)s: %(message)s") logger=logging.getLogger("main") @@ -106,3 +107,4 @@ app.include_router(saved_search_router) app.include_router(search_router) app.include_router(interview_router) app.include_router(talent_router) +app.include_router(candidate_forms_router) diff --git a/backend/migrations/manual/008_hiring_forms_rbac.sql b/backend/migrations/manual/008_hiring_forms_rbac.sql new file mode 100644 index 0000000..c3cabce --- /dev/null +++ b/backend/migrations/manual/008_hiring_forms_rbac.sql @@ -0,0 +1,52 @@ +-- 008_hiring_forms_rbac.sql +-- Manual one-shot: a `hiring_forms` bundle granting interviews.create/edit/delete +-- so staff roles can fill and amend the digitized hiring forms (Annexure A +-- requisition, Annexure E interview analysis + cultural fit) served by the +-- candidate_forms domain. The interviews.* tags themselves were seeded by 001; +-- the analytics_dashboard bundle only carries interviews.view, which is why the +-- write tags need this bundle. Mirrors 007's idempotent pattern; applied +-- automatically at startup by alembic_setup.run_manual_sql() and recorded in +-- manual_migrations. +-- +-- 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. Bundle holding the interviews write tags +-- ============================================================================= +INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted) +SELECT + 'hiring_forms', + 'Fill and amend candidate hiring forms (requisition, interview analysis, cultural fit)', + ( + SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb) + FROM app.permission_tags + WHERE is_deleted = false + AND tag_name IN ('interviews.create', 'interviews.edit', 'interviews.delete') + ), + true, + NOW(), + NOW(), + true, + false +WHERE NOT EXISTS ( + SELECT 1 FROM app.permissions WHERE name = 'hiring_forms' +); + +-- ============================================================================= +-- 2. 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 = 'hiring_forms' + 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/offer/app.py b/backend/offer/app.py index 6bc8fff..d4132d9 100644 --- a/backend/offer/app.py +++ b/backend/offer/app.py @@ -27,6 +27,13 @@ class OfferCreate(BaseModel): equity_instrument: str | None = None start_date: datetime | None = None expiry_date: datetime | None = None + cadre: str | None = None + gross_salary_in_words: str | None = None + subsidized_services: str | None = None + probation_period: str | None = None + notice_period: str | None = None + work_location: str | None = None + work_timings: str | None = None change_reason: str | None = None @@ -41,6 +48,13 @@ class OfferUpdate(BaseModel): equity_instrument: str | None = None start_date: datetime | None = None expiry_date: datetime | None = None + cadre: str | None = None + gross_salary_in_words: str | None = None + subsidized_services: str | None = None + probation_period: str | None = None + notice_period: str | None = None + work_location: str | None = None + work_timings: str | None = None sent_at: datetime | None = None responded_at: datetime | None = None closed_at: datetime | None = None diff --git a/backend/offer/models.py b/backend/offer/models.py index d65707e..a6da752 100644 --- a/backend/offer/models.py +++ b/backend/offer/models.py @@ -25,6 +25,14 @@ class Offers(SQLModel, table=True): annual_bonus_pct: float | None = Field(default=None) equity_units: int | None = Field(default=None) equity_instrument: str | None = Field(default=None) + # Annexure J (offer email format) fields. + cadre: str | None = Field(default=None) + gross_salary_in_words: str | None = Field(default=None) + subsidized_services: str | None = Field(default=None) + probation_period: str | None = Field(default=None) + notice_period: str | None = Field(default=None) + work_location: str | None = Field(default=None) + work_timings: str | None = Field(default=None) start_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) expiry_date: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) diff --git a/backend/offer/plugins.py b/backend/offer/plugins.py index 8f210ac..006bdb0 100644 --- a/backend/offer/plugins.py +++ b/backend/offer/plugins.py @@ -1,4 +1,6 @@ def non_validation_values(): fields=("base_salary","currency","salary_period","signing_bonus","annual_bonus_pct", - "equity_units","equity_instrument","start_date","expiry_date") + "equity_units","equity_instrument","start_date","expiry_date", + "cadre","gross_salary_in_words","subsidized_services","probation_period", + "notice_period","work_location","work_timings") return fields \ No newline at end of file diff --git a/backend/offer/serializers.py b/backend/offer/serializers.py index b1b0660..165f0b4 100644 --- a/backend/offer/serializers.py +++ b/backend/offer/serializers.py @@ -12,6 +12,13 @@ def serialize_offer(row) -> dict: "annual_bonus_pct": row.annual_bonus_pct, "equity_units": row.equity_units, "equity_instrument": row.equity_instrument, + "cadre": row.cadre, + "gross_salary_in_words": row.gross_salary_in_words, + "subsidized_services": row.subsidized_services, + "probation_period": row.probation_period, + "notice_period": row.notice_period, + "work_location": row.work_location, + "work_timings": row.work_timings, "start_date": row.start_date.isoformat() if row.start_date else None, "expiry_date": row.expiry_date.isoformat() if row.expiry_date else None, "sent_at": row.sent_at.isoformat() if row.sent_at else None, diff --git a/backend/offer/views.py b/backend/offer/views.py index 9595ae0..7c08a76 100644 --- a/backend/offer/views.py +++ b/backend/offer/views.py @@ -90,6 +90,8 @@ class Offer: "status","base_salary","currency","salary_period","signing_bonus","annual_bonus_pct", "equity_units","equity_instrument","start_date","expiry_date","sent_at", "responded_at","closed_at","issued_by","inbox_id","job_post_id","candidate_user_id", + "cadre","gross_salary_in_words","subsidized_services","probation_period", + "notice_period","work_location","work_timings", ): if key not in payload: continue diff --git a/backend/tests/test_candidate_forms.py b/backend/tests/test_candidate_forms.py new file mode 100644 index 0000000..49e1a7a --- /dev/null +++ b/backend/tests/test_candidate_forms.py @@ -0,0 +1,162 @@ +"""Hermetic tests for the candidate_forms pure logic (plugins.py) — no DB, no +FastAPI. The rated-section math, unknown-key handling, and the Annexure E +combined summary are the parts a typo would silently corrupt.""" + +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace + +import pytest + +from candidate_forms.plugins import ( + FORM_DEFINITIONS, + FORM_READY_STATUSES, + combined_summary, + definitions_payload, + normalize_fields, + normalize_sections, +) + + +def _sections(form_type: str, ratings_by_section: dict[str, dict[str, int | None]]): + return [ + {"key": key, "criteria": [{"key": ck, "rating": rv} for ck, rv in ratings.items()]} + for key, ratings in ratings_by_section.items() + ] + + +class TestNormalizeSections: + def test_recomputes_averages_and_overall(self): + sections = _sections( + "interview_analysis", + { + "technical": {"core_job_knowledge": 4, "relevant_experience": 3}, + "behavioral": {"communication": 2}, + }, + ) + normalized, overall = normalize_sections("interview_analysis", sections) + by_key = {s["key"]: s for s in normalized} + assert by_key["technical"]["average"] == 3.5 + assert by_key["behavioral"]["average"] == 2 + assert overall == 2.75 + + def test_client_sent_averages_are_discarded(self): + sections = _sections("cultural_fit", {"cultural": {"company_values": 4}}) + sections[0]["average"] = 1.0 # lying client + normalized, overall = normalize_sections("cultural_fit", sections) + assert normalized[0]["average"] == 4 + assert overall == 4 + + def test_emits_every_definition_criterion_with_labels(self): + normalized, overall = normalize_sections("interview_analysis", []) + assert [s["key"] for s in normalized] == ["technical", "behavioral"] + technical = normalized[0] + assert len(technical["criteria"]) == 5 + assert technical["criteria"][0]["label"] == "Core Job Knowledge & Domain Expertise" + assert technical["average"] is None + assert overall is None + + def test_unknown_section_rejected(self): + with pytest.raises(ValueError): + normalize_sections("cultural_fit", _sections("cultural_fit", {"technical": {}})) + + def test_unknown_criterion_rejected(self): + bad = _sections("cultural_fit", {"cultural": {"made_up": 3}}) + with pytest.raises(ValueError): + normalize_sections("cultural_fit", bad) + + @pytest.mark.parametrize("rating", [0, 5, -1, "high", 3.5]) + def test_out_of_range_ratings_rejected(self, rating): + bad = _sections("cultural_fit", {"cultural": {"company_values": rating}}) + with pytest.raises(ValueError): + normalize_sections("cultural_fit", bad) + + def test_string_and_null_ratings_coerced(self): + sections = _sections( + "cultural_fit", {"cultural": {"company_values": "3", "professionalism": None}} + ) + normalized, _ = normalize_sections("cultural_fit", sections) + ratings = {c["key"]: c["rating"] for c in normalized[0]["criteria"]} + assert ratings["company_values"] == 3 + assert ratings["professionalism"] is None + + def test_requisition_has_no_sections(self): + assert normalize_sections("requisition", None) == (None, None) + + +class TestNormalizeFields: + def test_unknown_keys_dropped_and_bools_coerced(self): + out = normalize_fields( + "requisition", + {"department": " IT ", "jd_available": "Yes", "bogus": "x", "is_replacement": False}, + ) + assert out == {"department": "IT", "jd_available": True, "is_replacement": False} + + def test_employment_type_enum_enforced(self): + assert normalize_fields("requisition", {"employment_type": "Contract"}) == { + "employment_type": "contract" + } + with pytest.raises(ValueError): + normalize_fields("requisition", {"employment_type": "freelance"}) + + def test_evaluation_note_fields_exist(self): + out = normalize_fields( + "interview_analysis", {"technical_note": "solid", "behavioral_note": "calm"} + ) + assert out == {"technical_note": "solid", "behavioral_note": "calm"} + assert normalize_fields("cultural_fit", {"cultural_note": "fits"}) == { + "cultural_note": "fits" + } + + +class TestCombinedSummary: + def _row(self, form_type, day, ratings_by_section): + sections, _ = normalize_sections(form_type, _sections(form_type, ratings_by_section)) + return SimpleNamespace( + form_type=form_type, created_at=datetime(2026, 1, day), sections=sections + ) + + def test_combined_needs_all_three_sections(self): + ia = self._row( + "interview_analysis", + 1, + {"technical": {"core_job_knowledge": 4}, "behavioral": {"communication": 2}}, + ) + assert combined_summary([ia])["combined_overall"] is None + + cf = self._row("cultural_fit", 2, {"cultural": {"company_values": 3}}) + summary = combined_summary([ia, cf]) + assert summary == { + "technical_avg": 4, + "behavioral_avg": 2, + "cultural_avg": 3, + "combined_overall": 3.0, + } + + def test_latest_row_per_type_wins(self): + old = self._row("cultural_fit", 1, {"cultural": {"company_values": 1}}) + new = self._row("cultural_fit", 5, {"cultural": {"company_values": 4}}) + assert combined_summary([old, new])["cultural_avg"] == 4 + + def test_no_evaluations_returns_none(self): + req = SimpleNamespace(form_type="requisition", created_at=datetime(2026, 1, 1), sections=None) + assert combined_summary([req]) is None + assert combined_summary([]) is None + + +class TestDefinitions: + def test_paper_parity_criterion_counts(self): + ia = FORM_DEFINITIONS["interview_analysis"] + cf = FORM_DEFINITIONS["cultural_fit"] + assert [len(s["criteria"]) for s in ia["sections"]] == [5, 5] + assert [len(s["criteria"]) for s in cf["sections"]] == [5] + + def test_stage_gate_vocabulary(self): + assert set(FORM_READY_STATUSES) == {"INTERVIEW", "OFFER", "HIRED", "APPROVED"} + + def test_payload_is_json_shaped(self): + payload = definitions_payload() + assert set(payload["form_types"]) == {"requisition", "interview_analysis", "cultural_fit"} + assert payload["recommendation_labels"]["next_round"] == "Shortlist for next round" + assert payload["rating_labels"]["1"] == "Below Average (1)" diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 612dd93..df37875 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -23,8 +23,8 @@ - - + +
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 96a71f7..b8ab919 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -42,6 +42,9 @@ const SCREENS = { help: lazy(() => import('./screens/Help')), } +// Detail pages live outside the ROUTES table (no sidebar entry, parameterized path). +const CandidatePage = lazy(() => import('./screens/CandidatePage')) + export default function App() { return ( @@ -82,6 +85,14 @@ export default function App() { /> ) })} + + + + } + /> } /> diff --git a/frontend/src/api/forms.js b/frontend/src/api/forms.js new file mode 100644 index 0000000..e5d310f --- /dev/null +++ b/frontend/src/api/forms.js @@ -0,0 +1,62 @@ +import { request } from '../lib/apiClient' + +/* ============================================================ + forms.js — backend/candidate_forms/app.py. + + The digitized hiring forms: Annexure A (Employee Requisition), and the two + halves of Annexure E — Interview Analysis (technical + behavioral) and + Cultural Fit. Dual-key like assessments: exactly one of inbox_id / + manual_upload_candidate_id. + + Permissioned with the interviews module tags (interviews.view to read, + interviews.create to fill, interviews.edit to amend). Creating is + stage-gated SERVER-side: the application must be at INTERVIEW / OFFER / + HIRED (or legacy APPROVED), else 409 — the UI hint mirrors, never replaces, + that rule. + + Field and criterion labels come from GET /forms/definitions, which is the + single authority for the paper forms' exact wording — do not hardcode + labels in components. + ============================================================ */ + +export const FORM_TYPES = ['requisition', 'interview_analysis', 'cultural_fit'] + +/** Mirror of backend candidate_forms/plugins.py FORM_READY_STATUSES. */ +export const FORM_READY_STATUSES = ['INTERVIEW', 'OFFER', 'HIRED', 'APPROVED'] + +export function definitions() { + return request('/forms/definitions') +} + +export function list({ formId, inboxId, manualUploadCandidateId, jobPostId, formType, top, skip } = {}) { + return request('/forms/fetch', { + params: { + form_id: formId, + inbox_id: inboxId, + manual_upload_candidate_id: manualUploadCandidateId, + job_post_id: jobPostId, + form_type: formType, + top, + skip, + }, + }) +} + +export function create(body) { + return request('/forms/create', { method: 'POST', body }) +} + +export function update(formId, body) { + return request('/forms/update', { + method: 'PATCH', + params: { form_id: formId }, + body, + }) +} + +export function remove(formId) { + return request('/forms/delete', { + method: 'DELETE', + params: { form_id: formId }, + }) +} diff --git a/frontend/src/api/offers.js b/frontend/src/api/offers.js index 310941a..21417d1 100644 --- a/frontend/src/api/offers.js +++ b/frontend/src/api/offers.js @@ -124,6 +124,14 @@ export function toOfferView(row, { people, jobTitles } = {}) { equity: equityLabel(row.equity_units, row.equity_instrument), equityUnits: row.equity_units ?? null, equityInstrument: row.equity_instrument || null, + // Annexure J (offer email format) fields. + cadre: row.cadre || null, + grossSalaryInWords: row.gross_salary_in_words || null, + subsidizedServices: row.subsidized_services || null, + probationPeriod: row.probation_period || null, + noticePeriod: row.notice_period || null, + workLocation: row.work_location || null, + workTimings: row.work_timings || null, startDate: row.start_date ? new Date(row.start_date) : null, expiry: row.expiry_date ? new Date(row.expiry_date) : null, sent: row.sent_at ? new Date(row.sent_at) : null, diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index bfc4303..1c750f2 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -98,6 +98,11 @@ export const qk = { recruiters: (p = {}) => ['analytics', 'recruiters', p], }, offers: { all: () => ['offers'], list: (p = {}) => ['offers', 'list', p] }, + forms: { + all: () => ['forms'], + list: (p = {}) => ['forms', 'list', p], + definitions: () => ['forms', 'definitions'], + }, interviews: { all: () => ['interviews'], range: (p = {}) => ['interviews', 'range', p], diff --git a/frontend/src/screens/CandidateForms.jsx b/frontend/src/screens/CandidateForms.jsx new file mode 100644 index 0000000..391cd71 --- /dev/null +++ b/frontend/src/screens/CandidateForms.jsx @@ -0,0 +1,1029 @@ +/* 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). + + Field and criterion labels are rendered from GET /forms/definitions — the + backend is the single authority for the paper forms' exact wording. The + interviewer fills a form; anyone with interviews.edit can amend it later + (deliberately no author lock — HR corrects transcription mistakes). + + Availability is stage-gated to INTERVIEW / OFFER / HIRED (+ legacy APPROVED): + the server rejects earlier stages with a 409, the gate here is just the + friendly version. Forms attach to an application — the inbox row for email + applicants, the manual_upload_candidate row for hand-added candidates. + + Layout system: .hf-* classes in styles.css. Rated criteria render as the + paper's own table (scale header, radio-dot cells, the SECTION AVERAGE foot); + the score summary is a stat-tile row with the combined overall as the hero. */ + +import { useMemo, useState } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' + +import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives' +import { useToast } from '../ui/Toast' +import { useAuth } from '../auth/AuthContext' +import { qk } from '../lib/queryKeys' +import { friendlyAuthError } from '../lib/errors' +import * as formsApi from '../api/forms' +import * as offersApi from '../api/offers' +import { STAGE_FROM_STATUS } from '../api/pipeline' + +const WORK_LOCATIONS = ['Maymar Office', 'Head Office'] +const WORK_TIMINGS = ['Morning', 'Afternoon', 'Evening', 'Night'] + +function titleCase(status) { + const s = String(status || '') + return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : 'Shortlist' +} + +function toDateInput(value) { + if (!value) return '' + const d = new Date(value) + return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10) +} + +/** Same shape as the profile's useProfileWrite, plus the forms/offers caches. */ +function useFormsWrite({ userId, mutationFn, success, onDone }) { + const qc = useQueryClient() + const { toast } = useToast() + return useMutation({ + mutationFn, + onSuccess: async () => { + await qc.invalidateQueries({ queryKey: qk.forms.all() }) + await qc.invalidateQueries({ queryKey: qk.offers.all() }) + await qc.invalidateQueries({ queryKey: ['candidates', 'history', userId] }) + toast(success, 'success') + onDone?.() + }, + onError: (err) => toast(friendlyAuthError(err, 'Could not save the form. Please try again.'), 'error'), + }) +} + +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') + + // Forms attach to an application: an inbox row for email applicants, or the + // manual_upload_candidate row for hand-added / sourced candidates. Exactly + // one of these keys is sent (backend XOR). + const inboxId = live?.inbox_id ?? null + const manualId = !inboxId ? (live?.manual_upload_candidate_id ?? null) : null + const listParams = inboxId ? { inboxId } : { manualUploadCandidateId: manualId } + const hasApplication = Boolean(inboxId || manualId) + const stage = String(live?.application_status || '').toUpperCase() + const unlocked = formsApi.FORM_READY_STATUSES.includes(stage) + + const defsQuery = useQuery({ + queryKey: qk.forms.definitions(), + queryFn: formsApi.definitions, + enabled: hasApplication && unlocked, + staleTime: Infinity, + }) + const formsQuery = useQuery({ + queryKey: qk.forms.list(listParams), + queryFn: () => formsApi.list(listParams), + enabled: hasApplication && unlocked, + }) + // Hoisted above OfferSection so the switcher can show the offer's done-dot. + const offersQuery = useQuery({ + queryKey: qk.offers.list({ inboxId }), + queryFn: () => offersApi.list({ inboxId }), + enabled: Boolean(inboxId) && unlocked, + }) + + if (!hasApplication) { + return ( + + Hiring forms hang off an application record, and this candidate has none yet. + + ) + } + if (!unlocked) { + return ( + + This candidate is at {STAGE_FROM_STATUS[stage] ?? titleCase(stage)}. Move them along + the pipeline to fill the requisition, evaluation and offer forms. + + ) + } + if (defsQuery.isPending || formsQuery.isPending) { + return Fetching form definitions. + } + if (defsQuery.isError || formsQuery.isError) { + return ( + + {friendlyAuthError(defsQuery.error || formsQuery.error, 'Please try again.')} + + ) + } + + const defs = defsQuery.data?.data + const rows = formsQuery.data?.data ?? [] + const summary = formsQuery.data?.summary ?? null + const offers = offersQuery.data?.data ?? [] + // Spread into create payloads — exactly one key, matching the backend XOR. + const link = inboxId + ? { inbox_id: Number(inboxId) } + : { 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' }, + ] + + const evalCount = rows.filter( + (r) => r.form_type === 'interview_analysis' || r.form_type === 'cultural_fit', + ).length + + return ( + <> + +
+ {segTabs.map((t) => ( + + ))} +
+ + {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') && ( + r.form_type === seg)} + userId={userId} + link={link} + live={live} + canCreate={can('interviews.create')} + canEdit={can('interviews.edit')} + /> + )} + {seg === 'offer' && ( + + )} + + ) +} + +/* ------------------------------------------------------------------ + Annexure E's OVERALL SCORE SUMMARY — three section tiles plus the combined + overall as the hero. Values are magnitudes on a fixed 1–4 scale, so each + tile carries a thin single-hue meter; numbers stay in text ink. */ + +function ScoreTile({ label, value, hero, sub }) { + const pct = value != null ? Math.max(0, Math.min(100, (value / 4) * 100)) : 0 + return ( +
+
{label}
+
+ {value != null ? value : '—'} + {value != null && / 4} +
+
+ {sub &&
{sub}
} +
+ ) +} + +function SummaryStrip({ summary, evalCount }) { + if (!summary || !evalCount) return null + return ( + <> +
+ + + + +
+
+ Scores come from the {evalCount === 1 ? 'evaluation form' : `${evalCount} evaluation forms`} filed + for this candidate — nothing is scored until an interviewer submits one. +
+ + ) +} + +/* ------------------------------------------------------------------ + Shared bits */ + +function fieldLabel(def, key) { + return def.fields.find((f) => f.key === key)?.label ?? key +} + +function sectionAverage(ratings) { + const values = Object.values(ratings).filter((v) => v != null) + if (!values.length) return null + return Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 100) / 100 +} + +function FormRowList({ rows, defs, onEdit, canEdit }) { + if (!rows.length) return null + return ( +
+ {rows.map((r) => ( +
+
+
{r.interviewer_name || r.created_by_name || 'Unknown'}
+
+ {toDateInput(r.form_date) || toDateInput(r.created_at)} + {r.updated_at && r.updated_at !== r.created_at ? ' · revised' : ''} +
+
+
+ {r.overall_score != null && {r.overall_score} / 4} + {r.recommendation && ( + + {defs.recommendation_labels[r.recommendation] ?? r.recommendation} + + )} + {canEdit && ( + + )} +
+
+ ))} +
+ ) +} + +/* The paper's rating grid: scale header, one radio-dot per cell, average foot. */ +function RatingTable({ section, defs, ratings, onRate }) { + const average = sectionAverage(ratings) + return ( +
+
+
Criteria
+ {[1, 2, 3, 4].map((n) => ( +
+ {defs.rating_labels[String(n)]} + {n} +
+ ))} +
+ {section.criteria.map((c) => ( +
+
{c.label}
+ {[1, 2, 3, 4].map((n) => ( +
+
+ ))} +
+ ))} +
+
{section.average_label || 'Section average'}
+
{average ?? '—'}
+
+
+ ) +} + +/* ------------------------------------------------------------------ + Interview Analysis / Cultural Fit — data-driven off the definition's + sections; both types share this component. */ + +function RatedEvaluationForm({ formType, def, defs, rows, userId, link, live, canCreate, canEdit }) { + const { user } = useAuth() + const [editing, setEditing] = useState(null) // null = closed, 'new' = create, else a row + + const blank = useMemo(() => { + const fields = {} + for (const f of def.fields) fields[f.key] = '' + fields.interviewer_name = user?.name || '' + fields.position_title = live?.job_title || '' + const ratings = {} + for (const s of def.sections) { + ratings[s.key] = {} + for (const c of s.criteria) ratings[s.key][c.key] = null + } + return { fields, ratings, recommendation: '', date: toDateInput(new Date().toISOString()) } + }, [def, user, live]) + + const initial = useMemo(() => { + if (!editing || editing === 'new') return blank + const fields = { ...blank.fields } + for (const key of Object.keys(fields)) { + if (editing.fields?.[key] != null) fields[key] = String(editing.fields[key]) + } + const ratings = {} + for (const s of def.sections) { + ratings[s.key] = { ...blank.ratings[s.key] } + } + for (const s of editing.sections ?? []) { + for (const c of s.criteria ?? []) { + if (ratings[s.key] && c.key in ratings[s.key]) ratings[s.key][c.key] = c.rating ?? null + } + } + return { + fields, + ratings, + recommendation: editing.recommendation || '', + date: toDateInput(editing.form_date) || blank.date, + } + }, [editing, blank, def]) + + return ( + <> + {rows.length ? ( + setEditing(r)} /> + ) : ( + !editing && ( + + Fill it during or right after the interview — it replaces the paper form. + + ) + )} + + {editing ? ( + setEditing(null)} + /> + ) : ( + canCreate && ( + // Centered and full-size under the empty state so the CTA reads as + // part of it; compact and left-aligned once a list sits above it. +
+ +
+ ) + )} + + ) +} + +function EvaluationEditor({ formType, def, defs, row, initial, userId, link, live, onClose }) { + const [fields, setFields] = useState(initial.fields) + const [ratings, setRatings] = useState(initial.ratings) + const [recommendation, setRecommendation] = useState(initial.recommendation) + const [date, setDate] = useState(initial.date) + const setField = (k, v) => setFields((f) => ({ ...f, [k]: v })) + const setRating = (sectionKey, critKey, value) => + setRatings((r) => ({ + ...r, + [sectionKey]: { ...r[sectionKey], [critKey]: r[sectionKey][critKey] === value ? null : value }, + })) + + const save = useFormsWrite({ + userId, + mutationFn: () => { + const body = { + form_date: date ? new Date(`${date}T00:00`).toISOString() : null, + sections: def.sections.map((s) => ({ + key: s.key, + criteria: s.criteria.map((c) => ({ key: c.key, rating: ratings[s.key][c.key] })), + })), + fields, + recommendation: recommendation || null, + } + if (row) return formsApi.update(row.id, body) + return formsApi.create({ form_type: formType, ...link, ...body }) + }, + success: row ? `${def.title} form updated` : `${def.title} form saved`, + onDone: onClose, + }) + + const sectionNoteKey = { technical: 'technical_note', behavioral: 'behavioral_note', cultural: 'cultural_note' } + + return ( +
{ e.preventDefault(); save.mutate() }}> +
+
Interview Details Summary
+
+
+ + +
+
+ + setField('interviewer_name', e.target.value)} + /> +
+
+ + setField('department', e.target.value)} /> +
+
+ + setField('position_title', e.target.value)} + /> +
+
+ + setDate(e.target.value)} /> +
+ {'summary' in fields && ( +
+ +