HR-ATS-Portal/backend/candidate_forms/plugins.py

363 lines
15 KiB
Python

"""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,
}