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

552 lines
23 KiB
Python

from inbox.models import Inbox
from typing import Any,List,Dict
from job.candidate.plugins import documents_from_message, source_from_message_to
def _first_file_path(value):
if not value:
return None
return str(value).split(",")[0].strip() or None
from job.interviews.serializers import serialize_interview
from job.activity.serializers import serialize_activity
from job.feedback.serializers import serialize_feedback
from job.job_post.serializers import serialize_job_post
def _id_str(value):
if value in (None,""):
return None
return str(value)
def _id_list(value):
if not value:
return []
if isinstance(value,(list,tuple)):
return [str(v) for v in value if v not in (None,"")]
return [str(value)]
def serialize_candidate(row) -> dict:
return {
"id": str(row.id),
"job_id": str(row.job_id),
"source": row.source,
"filename": row.filename,
"file_path": row.file_path,
"content_sha256": row.content_sha256,
"candidate_email": row.candidate_email,
"candidate_name": row.candidate_name,
"job_title": row.job_title,
"current_company": row.current_company,
"years_experience": row.years_experience,
"match_score": row.match_score,
"matched_keywords": list(row.matched_keywords or []),
"missing_keywords": list(row.missing_keywords or []),
"summary_critique": row.summary_critique,
"professional_summary": row.professional_summary or None,
"linkedin_url": row.linkedin_url or None,
"inbox_message_id": str(row.inbox_message_id) if getattr(row, "inbox_message_id", None) else None,
"manual_upload_candidate_id": str(row.manual_upload_candidate_id) if getattr(row, "manual_upload_candidate_id", None) else None,
"status": row.status,
"error_code": row.error_code,
"error_message": row.error_message,
"model": row.model,
"created_by": str(row.created_by),
"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 serialize_matching_candidate(row, job_post=None) -> Dict[str,Any]:
"""CV-bank origin row for Job Matching. assigned_job_post_id is job_posts.id."""
name=(row.candidate_name or "").strip() or (row.candidate_email or "").strip() or (row.file_name or "").strip() or "Unknown"
job_payload=serialize_job_post(job_post) if job_post else None
return {
"id":str(row.id),
"name":name,
"email":(row.candidate_email or "").strip() or None,
"file_name":(row.file_name or "").strip() or None,
"file_path":(row.file_path or "").strip() or None,
"resume_text":row.full_text or None,
"linkedin_url":row.linkedin_url or None,
"apply_via":row.apply_via,
"status":row.status or None,
"user_id":str(row.user_id) if row.user_id else None,
"assigned_job_post_id":str(row.job_post_id) if row.job_post_id else None,
"assigned_job_post":job_payload,
"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 serialize_bank_candidate(row, *, rank_score=None) -> Dict[str,Any]:
"""A CV held with no job, for the CV Bank screen.
Same shape as serialize_bank_silver_medalist so the table renders one row
type regardless of which population the candidate came from. `id` is
prefixed because the two sources have different key spaces and would
otherwise collide in a merged list.
rank_score is optional keyword overlap used by /cv-bank/suggestions, not
by the CV Bank table. ai_score is filled after serialize by joining the
latest candidates row for this email — this function leaves it None.
Speculative uploads have no inbox suggestions; suggested_job_post_ids is [].
"""
name=(row.candidate_name or "").strip() or (row.candidate_email or "").strip() or (row.file_name or "").strip() or "Unknown"
assigned=_id_str(row.job_post_id)
return {
"id":f"bank:{row.id}",
"record_id":str(row.id),
"bank_source":"speculative",
"name":name,
"email":(row.candidate_email or "").strip() or None,
"phone":(row.candidate_phone or "").strip() or None,
"file_name":(row.file_name or "").strip() or None,
"file_path":(row.file_path or "").strip() or None,
"linkedin_url":row.linkedin_url or None,
"current_company":(row.current_company or "").strip() or None,
"current_position":(row.current_position or "").strip() or None,
"education":(row.education or "").strip() or None,
"city":(getattr(row,"city",None) or "").strip() or None,
"skills":list(row.skills or []),
"years_experience":row.years_experience,
"ai_score":None,
"recommendation":None,
"rank_score":rank_score,
"last_job_title":None,
"bank_reason":(row.bank_reason or "").strip() or None,
"bank_expires_at":row.bank_expires_at.isoformat() if row.bank_expires_at else None,
"user_id":str(row.user_id) if row.user_id else None,
"message_id":None,
"assigned_job_post_id":assigned,
"assigned_job_title":None,
"scored_job_post_id":None,
"scored_job_title":None,
"suggested_job_post_ids":[],
"suggested_jobs":[],
"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 serialize_bank_silver_medalist(row, *, rank_score=None) -> Dict[str,Any]:
"""A past applicant who scored well and did not get the job.
Read from the live application tables rather than copied into the bank, so
there is no second source of truth to keep in sync. `row` is the flat
mapping produced by Inbox.list_silver_medalists.
"""
def get(key):
value=row.get(key)
return value.strip() if isinstance(value,str) else value
name=(get("name") or "") or (get("email") or "") or "Unknown"
expires=get("bank_expires_at")
created=get("created_at")
assigned=_id_str(get("assigned_job_post_id") or get("last_job_post_id"))
suggested=_id_list(row.get("suggested_job_post_ids"))
last_title=get("last_job_title") or None
return {
"id":f"app:{get('inbox_id')}",
"record_id":str(get("inbox_id")),
"bank_source":"silver_medalist",
"name":name,
"email":get("email") or None,
"phone":get("phone") or None,
"file_name":get("file_name") or None,
"file_path":get("file_path") or None,
"linkedin_url":get("linkedin_url") or None,
"current_company":get("current_company") or None,
"current_position":get("current_title") or None,
"education":get("education") or None,
"city":get("city") or None,
# Inbox applications never ran the skills extraction — their structured
# signal is the ATS score, which is stronger than a keyword list.
"skills":list(row.get("matched_keywords") or []),
"years_experience":get("years_experience"),
"ai_score":get("ai_score"),
"recommendation":get("recommendation"),
"rank_score":rank_score,
"last_job_title":last_title,
"bank_reason":"silver_medalist",
"bank_expires_at":expires.isoformat() if hasattr(expires,"isoformat") else expires,
"user_id":str(get("user_id")) if get("user_id") else None,
"message_id":_id_str(get("message_id")),
"assigned_job_post_id":assigned,
"assigned_job_title":last_title,
"scored_job_post_id":assigned,
"scored_job_title":last_title,
"suggested_job_post_ids":suggested,
"suggested_jobs":[],
"created_at":created.isoformat() if hasattr(created,"isoformat") else created,
"updated_at":None,
}
def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
return {
"id":str(row.id) if row.id else None,
"candidate_email":row.candidate_email,
"candidate_name":row.candidate_name,
"candidate_phone":row.candidate_phone,
"job_post_id":str(row.job_post_id) if row.job_post_id else None,
"full_text":row.full_text,
"linkedin_url":row.linkedin_url or None,
"current_company":row.current_company,
"current_position":row.current_position,
"apply_via":row.apply_via,
"user_id":str(row.user_id) if row.user_id else None,
"platform":row.platform,
"created_by":str(row.created_by) if row.created_by else None,
"experience":row.experience,
"status":row.status,
"referral_by":row.referral_by,
"file_name":row.file_name,
"file_path":row.file_path,
"favorite":row.favorite,
"rating":row.rating,
"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 serialize_candidate_profile(
link:Inbox|List[Inbox]|Dict[str,Any]|List[Dict[str,Any]],
*,
detail:bool=False,
) -> Dict[str,Any]|List[Dict[str,Any]]:
if isinstance(link,list):
return [serialize_candidate_profile(item,detail=detail) for item in link]
if isinstance(link,dict):
return link
user = link.user
message = link.messages
payload = {
"inbox_id": link.id,
"manual_upload_candidate_id": None,
"user_id": str(link.user_id) if link.user_id else None,
"candidate_id": None,
"name": user.name if user else None,
"email": user.email if user else None,
"linkedin_url": (user.linkedin_url if user else None) or None,
"is_active": user.is_active if user else None,
"message_id": str(link.message_id) if link.message_id else None,
"created_at": link.created_at.isoformat() if link.created_at else None,
"application_status": message.application_status if message else None,
"experience": message.experience if message else None,
"current_employment": message.current_employment if message else None,
"current_title": message.current_title if message else None,
"resume_text": message.resume_text if message else None,
"suggested_job_post_ids": list(message.suggested_job_post_ids or []) if message else [],
"assigned_job_post_id": str(message.assigned_job_post_id) if message and message.assigned_job_post_id else None,
"match_summary": message.match_summary if message else None,
"match_reasoning": message.match_reasoning if message else None,
"match_status": message.match_status if message else None,
"match_error": message.match_error if message else None,
"matched_at": message.matched_at.isoformat() if message and message.matched_at else None,
"professional_summary": (
(message.professional_summary if message else None)
or (user.professional_summary if user else None)
),
"file_path": _first_file_path(message.file_path if message else None),
"job_posts": [],
}
if not detail:
return payload
payload.update({
"favorite": link.favorite,
"rating": link.rating,
"phone": message.candidate_phone_number if message else None,
"education": message.candidate_education if message else None,
"currentCompany": message.current_employment if message else None,
"current_title": message.current_title if message else None,
"stage": message.application_status if message else None,
"source": source_from_message_to(message.message_to if message else None),
"applied": message.message_received_time if message else None,
"documents": documents_from_message(
message.file_name if message else None,
message.file_path if message else None,
),
"recruiter": None,
"recruiter_id": None,
"job_title": None,
"ai_score": None,
"recommendation": None,
"sub_scores": None,
"interviews": [serialize_interview(r) for r in (link.interviews or [])],
"activity": [serialize_activity(r) for r in (link.activity or [])],
"feedback": [serialize_feedback(r) for r in (link.feedback or [])],
"notes": [],
})
return payload
def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
"""Same key vocabulary as serialize_candidate_profile(detail=True).
Manual uploads never go through inbox, so current_position maps onto
current_title here and the agent/ATS fields stay empty.
"""
job_payload = serialize_job_post(job_post) if job_post else None
created = row.created_at.isoformat() if row.created_at else None
file_name = (row.file_name or "").strip() or None
file_path = (row.file_path or "").strip() or None
documents = [{"name": file_name or "", "path": file_path or ""}] if (file_name or file_path) else []
company = (row.current_company or "").strip() or None
position = (row.current_position or "").strip() or None
return {
"inbox_id": None,
"manual_upload_candidate_id": str(row.id),
"user_id": str(user.id) if user else (str(row.user_id) if row.user_id else None),
"candidate_id": None,
"name": (user.name if user else None) or row.candidate_name or None,
"email": (user.email if user else None) or row.candidate_email or None,
"linkedin_url": (user.linkedin_url if user else None) or row.linkedin_url or None,
"is_active": user.is_active if user else None,
"message_id": None,
"created_at": created,
"application_status": row.status or None,
"experience": (row.experience or "").strip() or (str(row.years_experience) if row.years_experience is not None else None),
"current_employment": company,
"current_title": position,
"resume_text": row.full_text or None,
"suggested_job_post_ids": [],
"assigned_job_post_id": str(row.job_post_id) if row.job_post_id else None,
"match_summary": None,
"match_reasoning": None,
"match_status": None,
"match_error": None,
"matched_at": None,
"professional_summary": row.professional_summary or (user.professional_summary if user else None),
"job_posts": [job_payload] if job_payload else [],
"favorite": None,
"rating": None,
"phone": (row.candidate_phone or "").strip() or None,
"education": (row.education or "").strip() or None,
"currentCompany": company,
"stage": row.status or None,
"source": (row.platform or "").strip() or None,
"applied": created,
"file_path": file_path,
"documents": documents,
"recruiter": job_payload.get("created_by_name") if job_payload else None,
"recruiter_id": job_payload.get("created_by") if job_payload else None,
"job_title": job_payload.get("title") if job_payload else None,
"ai_score": None,
"recommendation": None,
"sub_scores": None,
"interviews": [],
"activity": [],
"feedback": [],
"notes": [],
"assigned_job_post": job_payload,
"matched_keywords": [],
"missing_keywords": [],
"summary_critique": None,
"scored_at": None,
}
def serialize_manual_candidate_list(profile: Dict[str, Any]) -> Dict[str, Any]:
"""List shape of serialize_manual_candidate_profile — drop heavy detail."""
return {
"inbox_id": None,
"manual_upload_candidate_id": profile.get("manual_upload_candidate_id"),
"user_id": profile.get("user_id"),
"candidate_id": None,
"name": profile.get("name"),
"email": profile.get("email"),
"is_active": profile.get("is_active"),
"message_id": None,
"created_at": profile.get("created_at"),
"application_status": profile.get("application_status"),
"experience": profile.get("experience"),
"current_employment": profile.get("current_employment"),
"current_title": profile.get("current_title"),
"resume_text": None,
"suggested_job_post_ids": profile.get("suggested_job_post_ids") or [],
"assigned_job_post_id": profile.get("assigned_job_post_id"),
"job_posts": profile.get("job_posts") or [],
"assigned_job_post": profile.get("assigned_job_post"),
"job_title": profile.get("job_title"),
"recruiter": profile.get("recruiter"),
"recruiter_id": profile.get("recruiter_id"),
"source": profile.get("source"),
"file_path": profile.get("file_path"),
"ai_score": None,
"recommendation": None,
}
def serialize_form_candidate_list(row) -> Dict[str, Any]:
"""GET /candidate/fetch list row for an unpromoted FormData application.
processing_state is an inbox tab, not Candidate_application_Status, so it
is not copied onto application_status — CLOSED/rejected-tab values would
paint every sheet row as Rejected on Candidates.
"""
assigned = row.assigned_job_post_id or row.job_post_id
suggested = [str(v) for v in (row.suggested_job_post_ids or []) if v not in (None, "")]
created = row.created_at.isoformat() if row.created_at else None
name = (row.name or "").strip() or None
email = (row.candidate_email or "").strip() or None
return {
"inbox_id": None,
"form_data_id": str(row.id),
"manual_upload_candidate_id": None,
"user_id": None,
"candidate_id": None,
"name": name,
"email": email,
"is_active": None,
"message_id": None,
"created_at": created,
"application_status": None,
"experience": (row.experience or "").strip() or None,
"current_employment": (row.current_company or "").strip() or None,
"current_title": (row.position_applied_for or "").strip() or None,
"resume_text": None,
"suggested_job_post_ids": suggested,
"assigned_job_post_id": str(assigned) if assigned else None,
"job_posts": [],
"assigned_job_post": None,
"job_title": (row.position_applied_for or "").strip() or None,
"recruiter": None,
"recruiter_id": None,
"source": "Form",
"file_path": None,
"ai_score": None,
"recommendation": None,
}
def serialize_manager_candidate(row, *, source) -> dict:
"""One application on a hiring-manager's job — list row, not the profile."""
inbox_id = row.get("inbox_id")
manual_id = row.get("id") if source == "manual" else None
job_post_id = row.get("assigned_job_post_id") or row.get("job_post_id")
user_id = row.get("user_id")
ats = row.get("ats_result") or {}
score = ats.get("overall_score")
band = (ats.get("band") or "").strip() or None
if score is not None and not band:
band = "Strong Match" if score >= 82 else "Potential Match" if score >= 65 else "Weak Match"
return {
"id": user_id or (f"inbox:{inbox_id}" if inbox_id is not None else f"manual:{manual_id}"),
"user_id": user_id,
"name": row.get("name"),
"email": row.get("email") or row.get("candidate_email"),
"job_post_id": job_post_id,
"job_title": row.get("title"),
"application_status": row.get("application_status"),
"inbox_id": inbox_id,
"manual_upload_candidate_id": str(manual_id) if manual_id else None,
"created_at": row.get("created_at"),
"source": source,
"ai_score": score,
"recommendation": band,
}
_WRONG_FORMAT_MATCH = frozenset({"no_text", "failed", "dlq"})
def is_assigned_application(row) -> bool:
"""True when the row is an application to a real job, not an unassigned email.
Sheet forms name a role in job_title even before a job post is linked.
Unassigned inbox mail is still a kept attempt — see is_kept_application.
"""
if not isinstance(row, dict):
return False
if row.get("job_post_id"):
return True
if row.get("source") == "form" and row.get("job_title"):
return True
return False
def is_kept_application(row) -> bool:
"""True when the row is a real application, including unassigned inbox mail.
A CV attachment counts even when text extraction failed (`no_text`) — they
still applied. Body-only mail and classifier drops stay in history but do
not count as a reapplication. Two On-Hold emails from the same person do.
"""
if not isinstance(row, dict):
return False
if row.get("source") == "filtered":
return False
if row.get("source") == "inbox" and row.get("attachment") is False:
return False
if row.get("source") == "inbox" and row.get("attachment") is True:
return True
return rejection_reason(row) != "wrong_format"
def rejection_reason(row) -> str | None:
"""Why an unassigned attempt never reached a job — or None if it is still open.
Wrong format: no CV, unreadable PDF, matcher failed, or the classifier
kept the mail out of the inbox. Assigned rows keep their pipeline status.
"""
if not isinstance(row, dict) or is_assigned_application(row):
return None
if row.get("rejection_reason") == "wrong_format":
return "wrong_format"
if row.get("source") == "filtered":
return "wrong_format"
match = str(row.get("match_status") or "").strip().lower()
if match in _WRONG_FORMAT_MATCH:
return "wrong_format"
if row.get("source") == "inbox" and row.get("attachment") is False:
return "wrong_format"
return None
def serialize_application_history_item(row) -> dict:
"""One prior application / score / sheet row for a reapplicant lookup."""
reason = rejection_reason(row)
status = row.get("status")
if reason == "wrong_format":
status = "WRONG_FORMAT"
return {
"source": row.get("source"),
"inbox_id": row.get("inbox_id"),
"message_id": row.get("message_id"),
"upstream_id": row.get("upstream_id"),
"manual_upload_candidate_id": row.get("manual_upload_candidate_id"),
"form_data_id": row.get("form_data_id"),
"candidate_id": row.get("candidate_id"),
"user_id": str(row.get("user_id")) if row.get("user_id") else None,
"job_post_id": row.get("job_post_id"),
"job_title": row.get("job_title"),
"status": status,
"applied_at": row.get("applied_at"),
"rejection_reason": reason,
"match_status": row.get("match_status"),
"attachment": row.get("attachment"),
}
def serialize_application_history(email, *, user=None, present_in=None, applications=None) -> dict:
items = [
item for item in (
serialize_application_history_item(row) for row in (applications or [])
)
if is_kept_application(item)
]
found = bool(user or present_in or items)
return {
"email": email,
"found": found,
"present_in": list(present_in or []),
"user": (
{"id": str(user.id), "name": user.name, "email": user.email}
if user is not None else None
),
"is_reapplicant": len(items) > 1,
"applications": items,
}