Edit_Job_Profile
parent
51bd8b56f2
commit
1fa25f2854
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue