HR-ATS-Portal/backend/job/job_post/serializers.py

194 lines
8.6 KiB
Python

from job.job_post.enums import RequisitionStatus
from job.job_post.models import JobPosts
def _status_label(value):
if value in (None, ""):
return None
parsed = RequisitionStatus.parse(value)
return parsed.label if parsed else value
def serialize_job_post_title(row) -> dict:
"""Inbox suggestion rail — title only until the recruiter expands the card."""
return {
"id": str(row.id),
"title": row.title,
"status": row.status,
"is_active": row.is_active,
"is_deleted": row.is_deleted,
}
def _recruiter_payload(row, names=None):
"""Recruiter ids plus their mapped names, for the payloads that still send the
flat shape (serialize_job_post, serialize_job_stats)."""
names = names or {}
ids = JobPosts.recruiter_ids_of(row)
mapped = [names.get(i) for i in ids]
# recruiter_name is the singular legacy field: the first id that resolved.
recruiter_name = None
for n in mapped:
if n:
recruiter_name = n
break
return {
"current_recruiter_ids": ids,
"recruiter_name": recruiter_name,
"recruiter_names": [n for n in mapped if n],
"recruiters": [{"id": i, "name": names.get(i)} for i in ids],
}
def serialize_job_post(row, *, names=None) -> dict:
return {
"id": str(row.id),
"title": row.title,
# Talent Pool / candidate filters key off attached job_posts.department.
"department": row.department or None,
"department_id": str(row.department_id) if row.department_id else None,
"employment_type": row.employment_type,
"location": row.location,
"experience_min": row.experience_min,
"experience_max": row.experience_max,
"requirements": list(row.requirements or []),
"optional_skills": list(row.optional_skills or []),
"salary": row.salary,
"description": row.description,
"post_text": row.post_text,
"channel_id": row.channel_id,
"platform": row.platform,
"is_active": row.is_active,
"is_deleted": row.is_deleted,
"buffer_post_id": row.buffer_post_id,
"buffer_external_link": row.buffer_external_link,
"buffer_sent_at": row.buffer_sent_at.isoformat() if row.buffer_sent_at else None,
"status": row.status,
"buffer_error": row.buffer_error,
"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,
"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,
**_recruiter_payload(row, names),
}
def serialize_job_row(row, *, people=None, applicant_count=0) -> dict:
"""Requisition view of a job post, for the Jobs screen and the job profile.
Deliberately separate from serialize_job_post: that payload is shared by the
inbox, candidate and matching paths. department is the one shared field —
talent-pool filters key off it on attached job_posts.
`people` is Users.job_people's shape — {"recruiters": {id: name},
"hiring_manager": {id: name}} — so the two roles stay apart in the response.
"""
req = getattr(row, "requisition", None)
people = people or {}
recruiters = people.get("recruiters") or {}
hiring_manager = people.get("hiring_manager") or None
return {
"id": str(row.id),
"title": row.title,
# The linked department wins; the free-text column is the fallback, and is
# still what fetch_jobs filters on, so reads and filters agree.
"department": (row.department_ref.name if row.department_ref else None) or row.department or None,
"department_id": str(row.department_id) if row.department_id else None,
"location": row.location,
"employment_type": row.employment_type,
"vacancies": row.vacancies,
"platform": row.platform or None,
"requisition_status": row.requisition_status,
"status": row.status,
"experience_min": row.experience_min,
"experience_max": row.experience_max,
"salary": row.salary,
"requirements": list(row.requirements or []),
"optional_skills": list(row.optional_skills or []),
"description": row.description,
"is_active": row.is_active,
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
"requisition_title": req.position_title if req else None,
"requisition_department": req.department if req 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,
"created_at": row.created_at.isoformat() if row.created_at else None,
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
"recruiters": recruiters,
"hiring_manager": hiring_manager,
}
def serialize_job_stats(row, *, names=None) -> dict:
"""One job post's live pipeline-stage counts. `row` is a mapping from fetch_job_stats."""
created_at = row.get("created_at")
payload = _recruiter_payload(row, names)
if not payload["recruiter_name"] and row.get("recruiter_name"):
payload["recruiter_name"] = row.get("recruiter_name")
return {
"job_post_id": str(row["job_post_id"]),
"title": row["title"],
"department": row["department"] or None,
"location": row["location"],
"requisition_status": row["requisition_status"],
**payload,
# Frontend computes days-open vs client clock; no server days_open field.
"created_at": created_at.isoformat() if created_at else None,
"total_applicants": int(row["total_applicants"] or 0),
"shortlisting": int(row["shortlisting"] or 0),
"screened": int(row["screened"] or 0),
"assessment": int(row["assessment"] or 0),
"interviewed": int(row["interviewed"] or 0),
"offered": int(row["offered"] or 0),
"on_hold": int(row["on_hold"] or 0),
"rejected": int(row["rejected"] or 0),
"approved": int(row["approved"] or 0),
"hired": int(row["hired"] or 0),
"reapplied": int(row.get("reapplied") or 0),
}
def serialize_status_history(row, *, changed_by_name=None) -> dict:
return {
"id": str(row.id),
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
"from_status": row.from_status,
"from_label": _status_label(row.from_status),
"to_status": row.to_status,
"to_label": _status_label(row.to_status),
"changed_by": str(row.changed_by) if row.changed_by else None,
"changed_by_name": changed_by_name,
"actor_kind": row.actor_kind,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
def serialize_suggested_candidate(row, *, name=None, email=None, candidate=None, source=None, optional_matched=None) -> dict:
"""One suggested candidate on the job profile: the newest ats_results row for a
person, with profile fields from its `candidates` row when one exists."""
score = row.overall_score
return {
"id": str(row.id),
"user_id": str(row.user_id) if row.user_id else None,
"candidate_id": str(row.candidate_id) if row.candidate_id else None,
"form_data_id": str(row.form_data_id) if row.form_data_id else None,
"inbox_id": row.inbox_id,
"name": name or (candidate.candidate_name if candidate else None) or email or None,
"email": email or (candidate.candidate_email if candidate else None) or None,
"current_title": candidate.job_title if candidate else None,
"current_company": candidate.current_company if candidate else None,
"years_experience": candidate.years_experience if candidate else None,
"match_score": round(score) if score is not None else None,
"band": row.band or None,
"matched_keywords": list(candidate.matched_keywords or []) if candidate else [],
"missing_keywords": list(candidate.missing_keywords or []) if candidate else [],
"optional_matched": list(optional_matched or []),
"summary": (candidate.summary_critique if candidate else None) or row.professional_summary or None,
"source": source,
"scored_candidate_id": str(candidate.id) if candidate else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
}