Edit_Job_Profile
ahmed.mujtaba 2026-09-15 14:13:58 +05:00
parent 51bd8b56f2
commit 1fa25f2854
10 changed files with 138 additions and 104 deletions

View File

@ -1359,7 +1359,7 @@ class Inbox_Messages(SQLModel, table=True):
return row
@classmethod
async def counts_by_job_post_ids(cls, session: AsyncSession, job_post_ids) -> dict[str, int]:
async def counts_by_job_post_ids(cls, session: AsyncSession, job_post_ids,search=None,top=None,limit=None) -> dict[str, int]:
"""Resolve {job_post_id: applicant_count} for a page of rows in a single query.
Counts Inbox_Messages rows, not Inbox rows: one message fans out to several
@ -1373,6 +1373,12 @@ class Inbox_Messages(SQLModel, table=True):
.where(cls.assigned_job_post_id.in_(uids))
.group_by(cls.assigned_job_post_id)
)
if search:
result = result.where(cls.message_from.ilike(f"%{search}%"))
if top:
result = result.limit(top)
if limit:
result = result.limit(limit)
return {str(job_id): int(n) for job_id, n in result.all()}
@classmethod

View File

@ -153,7 +153,6 @@ class JobUpdate(BaseModel):
experience_min: int | None = None
experience_max: int | None = None
description: str | None = None
current_recruiter_id: UUID | None = None
current_recruiter_ids: list[UUID] | None = None
hiring_manager_id: UUID | None = None
requisition_id: UUID | None = None
@ -987,6 +986,9 @@ async def fetch_jobs(
@router.get("/jobs/profile/fetch")
async def fetch_job_profile(
job_post_id: str = Query(...),
search: str | None = Query(None),
top: int | None = Query(None, ge=1),
limit: int | None = Query(None, ge=1),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
@ -994,7 +996,7 @@ async def fetch_job_profile(
per person, best score first) and the Suggested / Top Match header stats."""
try:
service=JobPost(session=session)
data=await service.fetch_job_profile(job_post_id,current_user=current_user)
data=await service.fetch_job_profile(job_post_id,current_user=current_user,search=search,top=top,limit=limit)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise

View File

@ -15,7 +15,7 @@ JOB_ASSIGNMENT_ROLES = {
"hiring_manager": EnumRoles.HIRING_MANAGER,
}
JOB_OWNER_COLUMN = {
"primary_recruiter": "current_recruiter_id",
"primary_recruiter": "current_recruiter_ids",
"hiring_manager": "hiring_manager_id",
}

View File

@ -1301,9 +1301,7 @@ class Interviews(SQLModel, table=True):
interview_type: str = Field(default="")
interview_status: str = Field(default="")
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
# Optional denorm so Recruiter Hub can join interviews → job_posts.current_recruiter_id
# without walking inbox. Filled on create from the application's assigned job;
# migration 011 added the columns. user_id is the candidate, not the recruiter.
user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
graph_event_id: str | None = Field(default=None)

View File

@ -60,11 +60,7 @@ class JobPosts(SQLModel, table=True):
department: str = Field(default="", sa_column_kwargs={"server_default": ""})
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
# Who is working the req now (swappable). History lives in job_assignments
# with assignment_role=primary_recruiter; this column is the first / primary
# pointer so existing joins keep working. current_recruiter_ids is the full
# list (UUID strings) so more than one recruiter can sit on the same job.
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
current_recruiter_ids: list[str] = Field(
default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"},
)
@ -92,35 +88,35 @@ class JobPosts(SQLModel, table=True):
except (TypeError, ValueError):
return None
@staticmethod
def recruiter_ids_of(row) -> list[str]:
"""UUID strings currently assigned as recruiters on a job row or mapping.
# @staticmethod
# def recruiter_ids_of(row) -> list[str]:
# """UUID strings currently assigned as recruiters on a job row or mapping.
Prefers current_recruiter_ids; falls back to current_recruiter_id so a
row that has not been backfilled still maps to one person.
"""
if isinstance(row, dict):
raw = row.get("current_recruiter_ids")
fallback = row.get("current_recruiter_id")
else:
raw = getattr(row, "current_recruiter_ids", None)
fallback = getattr(row, "current_recruiter_id", None)
out: list[str] = []
seen: set[str] = set()
for item in raw or []:
uid = JobPosts._as_uuid(item)
if uid is None:
continue
key = str(uid)
if key in seen:
continue
seen.add(key)
out.append(key)
if not out:
uid = JobPosts._as_uuid(fallback)
if uid is not None:
out.append(str(uid))
return out
# Prefers current_recruiter_ids; falls back to current_recruiter_id so a
# row that has not been backfilled still maps to one person.
# """
# if isinstance(row, dict):
# raw = row.get("current_recruiter_ids")
# fallback = row.get("current_recruiter_id")
# else:
# raw = getattr(row, "current_recruiter_ids", None)
# fallback = getattr(row, "current_recruiter_id", None)
# out: list[str] = []
# seen: set[str] = set()
# for item in raw or []:
# uid = JobPosts._as_uuid(item)
# if uid is None:
# continue
# key = str(uid)
# if key in seen:
# continue
# seen.add(key)
# out.append(key)
# if not out:
# uid = JobPosts._as_uuid(fallback)
# if uid is not None:
# out.append(str(uid))
# return out
@classmethod
def has_recruiter(cls, recruiter_id):
@ -129,7 +125,6 @@ class JobPosts(SQLModel, table=True):
if uid is None:
return false()
return or_(
cls.current_recruiter_id == uid,
cls.current_recruiter_ids.contains([str(uid)]),
)
@ -137,7 +132,7 @@ class JobPosts(SQLModel, table=True):
def no_recruiters(cls):
"""SQL: neither the pointer nor the JSON list names anyone."""
return and_(
cls.current_recruiter_id.is_(None),
cls.current_recruiter_ids.is_(None),
func.coalesce(func.jsonb_array_length(cls.current_recruiter_ids), 0) == 0,
)
@ -146,7 +141,7 @@ class JobPosts(SQLModel, table=True):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
result = await session.execute(select(cls).where(cls.id == uid).order_by(cls.created_at.desc(),cls.id.desc()))
return result.scalars().first()
@classmethod
@ -517,7 +512,6 @@ class JobPosts(SQLModel, table=True):
cls.department,
cls.location,
cls.requisition_status,
cls.current_recruiter_id,
cls.current_recruiter_ids,
cls.created_at,
Recruiter.name.label("recruiter_name"),
@ -536,7 +530,7 @@ class JobPosts(SQLModel, table=True):
.select_from(cls)
.outerjoin(stats, stats.c.job_post_id == cls.id)
.outerjoin(reapplied_stats, reapplied_stats.c.job_post_id == cls.id)
.outerjoin(Recruiter, Recruiter.id == cls.current_recruiter_id)
.outerjoin(Recruiter, Recruiter.id.in_(cls.current_recruiter_ids))
.where(cls.is_deleted == False) # noqa: E712
)
if active_only:

View File

@ -20,19 +20,17 @@ def serialize_job_post_title(row) -> dict:
}
def _recruiter_payload(row, names=None):
"""List of recruiter ids plus mapped names; first id stays the legacy pointer."""
names = names or {}
ids = JobPosts.recruiter_ids_of(row)
mapped = [names.get(i) for i in ids]
first = ids[0] if ids else None
return {
"current_recruiter_id": first,
"current_recruiter_ids": ids,
"recruiter_name": next((n for n in mapped if n), None),
"recruiter_names": [n for n in mapped if n],
"recruiters": [{"id": i, "name": names.get(i)} for i in ids],
}
# def _recruiter_payload(row, names=None):
# """List of recruiter ids plus mapped names; first id stays the legacy pointer."""
# names = names or []
# # ids = JobPosts.recruiter_ids_of(row)
# mapped = [names.get(i) for i in ids]
# return {
# "current_recruiter_ids": ids,
# "recruiter_name": next((n for n in mapped if n), None),
# "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:
@ -77,9 +75,8 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na
talent-pool filters key off it on attached job_posts.
"""
req = getattr(row, "requisition", None)
payload = _recruiter_payload(row, names)
if recruiter_name and not payload["recruiter_name"]:
payload["recruiter_name"] = recruiter_name
recruiter_names=names.get("recruiters")
hiring_manager_name=names.get("hiring_manager",None)
return {
"id": str(row.id),
"title": row.title,
@ -89,8 +86,6 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na
"employment_type": row.employment_type,
"vacancies": row.vacancies,
"platform": row.platform or None,
# Two different lifecycles, never conflate: requisition_status is hiring
# (open/closed/on_hold), status is Buffer publishing (draft/scheduled/...).
"requisition_status": row.requisition_status,
"status": row.status,
"experience_min": row.experience_min,
@ -101,9 +96,6 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na
"description": row.description,
"is_active": row.is_active,
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
**payload,
"hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None,
"hiring_manager_name": hiring_manager_name,
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
"requisition_title": req.position_title if req else None,
"requisition_department": req.department if req else None,
@ -112,6 +104,8 @@ def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_na
"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": recruiter_names,
"hiring_manager": hiring_manager_name,
}

View File

@ -1,3 +1,6 @@
from typing import Any
from datetime import date, time
import logging
import os
@ -45,28 +48,14 @@ MAX_JOB_IMAGE_BYTES=5*1024*1024
def _payload_recruiter_ids(payload):
"""Prefer current_recruiter_ids; fall back to current_recruiter_id. None = omitted."""
has_list="current_recruiter_ids" in payload and payload.get("current_recruiter_ids") is not None
has_one="current_recruiter_id" in payload
if has_list:
raw=payload.get("current_recruiter_ids") or []
if not isinstance(raw,(list,tuple)):
raw=[raw]
ids=list(raw)
if not ids and has_one and payload.get("current_recruiter_id") not in (None,""):
ids=[payload.get("current_recruiter_id")]
return ids
if has_one:
raw=payload.get("current_recruiter_id")
return [] if raw in (None,"") else [raw]
return None
if payload.get("current_recruiter_ids"):
raw=payload.get("current_recruiter_ids")
return raw
def _recruiter_fields(users):
ids=[str(u.id) for u in users]
return {
"current_recruiter_ids": ids,
"current_recruiter_id": users[0].id if users else None,
}
@ -97,7 +86,6 @@ class JobPostCreate(BaseModel):
scheduler_date: date | None = None
due_at: str | None = None
hiring_manager_id: UUID | None = None
current_recruiter_id: UUID | None = None
current_recruiter_ids: list[UUID] | None = None
requisition_id: UUID | None = None
@ -423,7 +411,7 @@ class JobPost:
for r in rows
],total
async def fetch_job_profile(self,job_post_id,current_user=None):
async def fetch_job_profile(self,job_post_id,current_user=None,search=None,top=None,limit=None):
"""Job profile page: the requisition row, its suggested candidates and the
Suggested / Top Match header stats one round trip.
@ -431,24 +419,29 @@ class JobPost:
and keywords come from that score's candidates row; a user- or form-identity
score has none, so it borrows the newest completed row for the same email."""
uid=JobPosts._as_uuid(job_post_id)
if uid is None:
raise HTTPException(status_code=422,detail="job_post_id must be a UUID")
# it's a system admin job profile page, so we don't need to restrict the ids
restrict=await self._restrict_ids_for_requisition_scope(current_user)
if restrict is not None and str(uid) not in {str(i) for i in restrict}:
raise HTTPException(status_code=404,detail="Job post not found")
job=await JobPosts.get_job_post_by_id(self.session,uid)
if not job or job.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
names=await Users.names_by_ids(
self.session,
JobPosts.recruiter_ids_of(job)+[job.hiring_manager_id],
)
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[job.id])
recruiter_lst=job.current_recruiter_ids or []
recruiter_x_manager_names=await Users.names_by_ids(self.session,recruiter_lst,job.hiring_manager_id,search,top,limit)
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[job.id],search,top,limit)
job_payload=serialize_job_row(
job,
names=names,
hiring_manager_name=names.get(str(job.hiring_manager_id)),
names=recruiter_x_manager_names["recruiters"],
hiring_manager_name=recruiter_x_manager_names["hiring_manager"],
applicant_count=counts.get(str(job.id),0),
)

View File

@ -216,9 +216,6 @@ async def job_recruiter_ids(session, job):
ids = set()
if job is None:
return ids
uid = _as_uuid(getattr(job, "current_recruiter_id", None))
if uid is not None:
ids.add(uid)
for raw in getattr(job, "current_recruiter_ids", None) or []:
extra = _as_uuid(raw)
if extra is not None:

View File

@ -154,20 +154,37 @@ class Users(SQLModel, table=True):
return [row[0] for row in result.all()]
@classmethod
async def names_by_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
"""Resolve {user_id: name} in a single query.
async def names_by_ids(cls, session: AsyncSession, user_ids,manager_id=None,search=None,top=None,limit=None) -> dict[str, str]:
COLUMN select, not the Users entity: `select(cls)` would pull the five
selectin relations (role, job_posts, inbox, feedback, notes) for a
two-column lookup.
"""
data = {"recruiters": {}, "hiring_manager": {}}
uids = {u for u in (user_ids or []) if u}
if not uids:
return {}
result = await session.execute(
select(cls.id, cls.name).where(cls.id.in_(uids))
)
return {str(uid): name for uid, name in result.all()}
if search:
result = result.where(cls.name.ilike(f"%{search}%"))
if top:
result = result.limit(top)
if limit:
result = result.limit(limit)
recruiter_result=result.all()
data["recruiters"] = {str(uid): name for uid, name in recruiter_result}
if manager_id:
result=await session.execute(
select(cls.id, cls.name).where(cls.id==manager_id,cls.role_id==4,cls.is_deleted==False)
)
if search:
result = result.where(cls.name.ilike(f"%{search}%"))
if top:
result = result.limit(top)
if limit:
result = result.limit(limit)
manager_result=result.all()
data["hiring_manager"] = {str(uid): name for uid, name in manager_result}
return data
@classmethod
async def get_by_ids(cls, session: AsyncSession, ids):

View File

@ -64,7 +64,24 @@ function experienceLabel(min, max) {
return `${min ?? max}+ years`
}
/* Job ownership arrives in one of two shapes. serialize_job_row (GET /jobs/fetch,
GET /jobs/profile/fetch) sends the two roles as separate {id: name} objects:
"recruiters": {"ed9e…": "Nida Khan"}, "hiring_manager": {"77aa…": "Amara Osei"}
serialize_job_post the inbox / matching / picker payload still sends the
older flat keys (current_recruiter_ids, recruiter_names, recruiters as an
ARRAY of {id, name}, hiring_manager_id / hiring_manager_name). Both are read
here so one mapper serves every caller; the Array check is what tells the two
`recruiters` shapes apart. */
function recruiterMap(row) {
const value = row?.recruiters
return value && typeof value === 'object' && !Array.isArray(value) ? value : null
}
function recruiterIdsFrom(row) {
const map = recruiterMap(row)
if (map) return Object.keys(map).map(String)
const ids = Array.isArray(row?.current_recruiter_ids)
? row.current_recruiter_ids.filter(Boolean).map(String)
: []
@ -73,6 +90,8 @@ function recruiterIdsFrom(row) {
}
function recruiterNamesFrom(row) {
const map = recruiterMap(row)
if (map) return Object.values(map).filter(Boolean)
if (Array.isArray(row?.recruiter_names) && row.recruiter_names.length) {
return row.recruiter_names.filter(Boolean)
}
@ -82,10 +101,24 @@ function recruiterNamesFrom(row) {
return row?.recruiter_name ? [row.recruiter_name] : []
}
/** {id, name} of the hiring manager, from either payload shape. */
function hiringManagerFrom(row) {
const value = row?.hiring_manager
if (value && typeof value === 'object') {
const [id, name] = Object.entries(value)[0] ?? []
if (id) return { id: String(id), name: name ?? null }
}
return {
id: row?.hiring_manager_id ? String(row.hiring_manager_id) : null,
name: row?.hiring_manager_name ?? null,
}
}
/** API row -> what the Jobs table and detail modal render. */
export function toJobView(row) {
const recruiterIds = recruiterIdsFrom(row)
const recruiterNames = recruiterNamesFrom(row)
const manager = hiringManagerFrom(row)
return {
id: row.id,
title: row.title,
@ -101,8 +134,8 @@ export function toJobView(row) {
recruiterId: recruiterIds[0] || null,
recruiterIds,
recruiterNames,
hiringManager: row.hiring_manager_name,
hiringManagerId: row.hiring_manager_id,
hiringManager: manager.name,
hiringManagerId: manager.id,
createdByName: row.created_by_name,
applicantCount: row.applicant_count ?? 0,
// A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.