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 return row
@classmethod @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. """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 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)) .where(cls.assigned_job_post_id.in_(uids))
.group_by(cls.assigned_job_post_id) .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()} return {str(job_id): int(n) for job_id, n in result.all()}
@classmethod @classmethod

View File

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

View File

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

View File

@ -1301,9 +1301,7 @@ class Interviews(SQLModel, table=True):
interview_type: str = Field(default="") interview_type: str = Field(default="")
interview_status: str = Field(default="") interview_status: str = Field(default="")
inbox_id: int | None = Field(default=None, foreign_key="inbox.id") 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") 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") job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
graph_event_id: str | None = Field(default=None) 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": ""}) department: str = Field(default="", sa_column_kwargs={"server_default": ""})
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"}) vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) 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( current_recruiter_ids: list[str] = Field(
default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"}, default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"},
) )
@ -92,35 +88,35 @@ class JobPosts(SQLModel, table=True):
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
@staticmethod # @staticmethod
def recruiter_ids_of(row) -> list[str]: # def recruiter_ids_of(row) -> list[str]:
"""UUID strings currently assigned as recruiters on a job row or mapping. # """UUID strings currently assigned as recruiters on a job row or mapping.
Prefers current_recruiter_ids; falls back to current_recruiter_id so a # Prefers current_recruiter_ids; falls back to current_recruiter_id so a
row that has not been backfilled still maps to one person. # row that has not been backfilled still maps to one person.
""" # """
if isinstance(row, dict): # if isinstance(row, dict):
raw = row.get("current_recruiter_ids") # raw = row.get("current_recruiter_ids")
fallback = row.get("current_recruiter_id") # fallback = row.get("current_recruiter_id")
else: # else:
raw = getattr(row, "current_recruiter_ids", None) # raw = getattr(row, "current_recruiter_ids", None)
fallback = getattr(row, "current_recruiter_id", None) # fallback = getattr(row, "current_recruiter_id", None)
out: list[str] = [] # out: list[str] = []
seen: set[str] = set() # seen: set[str] = set()
for item in raw or []: # for item in raw or []:
uid = JobPosts._as_uuid(item) # uid = JobPosts._as_uuid(item)
if uid is None: # if uid is None:
continue # continue
key = str(uid) # key = str(uid)
if key in seen: # if key in seen:
continue # continue
seen.add(key) # seen.add(key)
out.append(key) # out.append(key)
if not out: # if not out:
uid = JobPosts._as_uuid(fallback) # uid = JobPosts._as_uuid(fallback)
if uid is not None: # if uid is not None:
out.append(str(uid)) # out.append(str(uid))
return out # return out
@classmethod @classmethod
def has_recruiter(cls, recruiter_id): def has_recruiter(cls, recruiter_id):
@ -129,7 +125,6 @@ class JobPosts(SQLModel, table=True):
if uid is None: if uid is None:
return false() return false()
return or_( return or_(
cls.current_recruiter_id == uid,
cls.current_recruiter_ids.contains([str(uid)]), cls.current_recruiter_ids.contains([str(uid)]),
) )
@ -137,7 +132,7 @@ class JobPosts(SQLModel, table=True):
def no_recruiters(cls): def no_recruiters(cls):
"""SQL: neither the pointer nor the JSON list names anyone.""" """SQL: neither the pointer nor the JSON list names anyone."""
return and_( 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, 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) uid = cls._as_uuid(record_id)
if uid is None: if uid is None:
return 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() return result.scalars().first()
@classmethod @classmethod
@ -517,7 +512,6 @@ class JobPosts(SQLModel, table=True):
cls.department, cls.department,
cls.location, cls.location,
cls.requisition_status, cls.requisition_status,
cls.current_recruiter_id,
cls.current_recruiter_ids, cls.current_recruiter_ids,
cls.created_at, cls.created_at,
Recruiter.name.label("recruiter_name"), Recruiter.name.label("recruiter_name"),
@ -536,7 +530,7 @@ class JobPosts(SQLModel, table=True):
.select_from(cls) .select_from(cls)
.outerjoin(stats, stats.c.job_post_id == cls.id) .outerjoin(stats, stats.c.job_post_id == cls.id)
.outerjoin(reapplied_stats, reapplied_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 .where(cls.is_deleted == False) # noqa: E712
) )
if active_only: if active_only:

View File

@ -20,19 +20,17 @@ def serialize_job_post_title(row) -> dict:
} }
def _recruiter_payload(row, names=None): # def _recruiter_payload(row, names=None):
"""List of recruiter ids plus mapped names; first id stays the legacy pointer.""" # """List of recruiter ids plus mapped names; first id stays the legacy pointer."""
names = names or {} # names = names or []
ids = JobPosts.recruiter_ids_of(row) # # ids = JobPosts.recruiter_ids_of(row)
mapped = [names.get(i) for i in ids] # mapped = [names.get(i) for i in ids]
first = ids[0] if ids else None # return {
return { # "current_recruiter_ids": ids,
"current_recruiter_id": first, # "recruiter_name": next((n for n in mapped if n), None),
"current_recruiter_ids": ids, # "recruiter_names": [n for n in mapped if n],
"recruiter_name": next((n for n in mapped if n), None), # "recruiters": [{"id": i, "name": names.get(i)} for i in ids],
"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: 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. talent-pool filters key off it on attached job_posts.
""" """
req = getattr(row, "requisition", None) req = getattr(row, "requisition", None)
payload = _recruiter_payload(row, names) recruiter_names=names.get("recruiters")
if recruiter_name and not payload["recruiter_name"]: hiring_manager_name=names.get("hiring_manager",None)
payload["recruiter_name"] = recruiter_name
return { return {
"id": str(row.id), "id": str(row.id),
"title": row.title, "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, "employment_type": row.employment_type,
"vacancies": row.vacancies, "vacancies": row.vacancies,
"platform": row.platform or None, "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, "requisition_status": row.requisition_status,
"status": row.status, "status": row.status,
"experience_min": row.experience_min, "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, "description": row.description,
"is_active": row.is_active, "is_active": row.is_active,
"closed_at": row.closed_at.isoformat() if row.closed_at else None, "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_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
"requisition_title": req.position_title if req else None, "requisition_title": req.position_title if req else None,
"requisition_department": req.department 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_by_name": row.user.name if getattr(row, "user", None) else None,
"created_at": row.created_at.isoformat() if row.created_at 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, "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 from datetime import date, time
import logging import logging
import os import os
@ -45,28 +48,14 @@ MAX_JOB_IMAGE_BYTES=5*1024*1024
def _payload_recruiter_ids(payload): def _payload_recruiter_ids(payload):
"""Prefer current_recruiter_ids; fall back to current_recruiter_id. None = omitted.""" if payload.get("current_recruiter_ids"):
has_list="current_recruiter_ids" in payload and payload.get("current_recruiter_ids") is not None raw=payload.get("current_recruiter_ids")
has_one="current_recruiter_id" in payload return raw
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
def _recruiter_fields(users): def _recruiter_fields(users):
ids=[str(u.id) for u in users] ids=[str(u.id) for u in users]
return { return {
"current_recruiter_ids": ids, "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 scheduler_date: date | None = None
due_at: str | None = None due_at: str | None = None
hiring_manager_id: UUID | None = None hiring_manager_id: UUID | None = None
current_recruiter_id: UUID | None = None
current_recruiter_ids: list[UUID] | None = None current_recruiter_ids: list[UUID] | None = None
requisition_id: UUID | None = None requisition_id: UUID | None = None
@ -423,7 +411,7 @@ class JobPost:
for r in rows for r in rows
],total ],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 """Job profile page: the requisition row, its suggested candidates and the
Suggested / Top Match header stats one round trip. 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 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.""" score has none, so it borrows the newest completed row for the same email."""
uid=JobPosts._as_uuid(job_post_id) uid=JobPosts._as_uuid(job_post_id)
if uid is None: if uid is None:
raise HTTPException(status_code=422,detail="job_post_id must be a UUID") 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) 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}: 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") raise HTTPException(status_code=404,detail="Job post not found")
job=await JobPosts.get_job_post_by_id(self.session,uid) job=await JobPosts.get_job_post_by_id(self.session,uid)
if not job or job.is_deleted: if not job or job.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found") raise HTTPException(status_code=404,detail="Job post not found")
names=await Users.names_by_ids( recruiter_lst=job.current_recruiter_ids or []
self.session,
JobPosts.recruiter_ids_of(job)+[job.hiring_manager_id], 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]) counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[job.id],search,top,limit)
job_payload=serialize_job_row( job_payload=serialize_job_row(
job, job,
names=names, names=recruiter_x_manager_names["recruiters"],
hiring_manager_name=names.get(str(job.hiring_manager_id)), hiring_manager_name=recruiter_x_manager_names["hiring_manager"],
applicant_count=counts.get(str(job.id),0), applicant_count=counts.get(str(job.id),0),
) )

View File

@ -216,9 +216,6 @@ async def job_recruiter_ids(session, job):
ids = set() ids = set()
if job is None: if job is None:
return ids 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 []: for raw in getattr(job, "current_recruiter_ids", None) or []:
extra = _as_uuid(raw) extra = _as_uuid(raw)
if extra is not None: if extra is not None:

View File

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

View File

@ -64,7 +64,24 @@ function experienceLabel(min, max) {
return `${min ?? max}+ years` 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) { function recruiterIdsFrom(row) {
const map = recruiterMap(row)
if (map) return Object.keys(map).map(String)
const ids = Array.isArray(row?.current_recruiter_ids) const ids = Array.isArray(row?.current_recruiter_ids)
? row.current_recruiter_ids.filter(Boolean).map(String) ? row.current_recruiter_ids.filter(Boolean).map(String)
: [] : []
@ -73,6 +90,8 @@ function recruiterIdsFrom(row) {
} }
function recruiterNamesFrom(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) { if (Array.isArray(row?.recruiter_names) && row.recruiter_names.length) {
return row.recruiter_names.filter(Boolean) return row.recruiter_names.filter(Boolean)
} }
@ -82,10 +101,24 @@ function recruiterNamesFrom(row) {
return row?.recruiter_name ? [row.recruiter_name] : [] 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. */ /** API row -> what the Jobs table and detail modal render. */
export function toJobView(row) { export function toJobView(row) {
const recruiterIds = recruiterIdsFrom(row) const recruiterIds = recruiterIdsFrom(row)
const recruiterNames = recruiterNamesFrom(row) const recruiterNames = recruiterNamesFrom(row)
const manager = hiringManagerFrom(row)
return { return {
id: row.id, id: row.id,
title: row.title, title: row.title,
@ -101,8 +134,8 @@ export function toJobView(row) {
recruiterId: recruiterIds[0] || null, recruiterId: recruiterIds[0] || null,
recruiterIds, recruiterIds,
recruiterNames, recruiterNames,
hiringManager: row.hiring_manager_name, hiringManager: manager.name,
hiringManagerId: row.hiring_manager_id, hiringManagerId: manager.id,
createdByName: row.created_by_name, createdByName: row.created_by_name,
applicantCount: row.applicant_count ?? 0, applicantCount: row.applicant_count ?? 0,
// A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working. // A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.