diff --git a/backend/job/app.py b/backend/job/app.py
index 1d46b48..73751ed 100644
--- a/backend/job/app.py
+++ b/backend/job/app.py
@@ -138,6 +138,8 @@ class JobUpdate(BaseModel):
experience_min: int | None = None
experience_max: int | None = None
description: str | None = None
+ current_recruiter_id: UUID | None = None
+ hiring_manager_id: UUID | None = None
class JobStatusUpdate(BaseModel):
@@ -756,6 +758,7 @@ async def fetch_jobs(
department: str | None = Query(None),
requisition_status: str | None = Query(None),
employment_type: str | None = Query(None),
+ hiring_manager_id: str | None = Query(None),
# le=500 (not 100): the Jobs board loads a full client-side page for facets;
# a 200 ceiling used to 422 the SPA and render an empty requisition list.
top: int | None = Query(10, ge=1, le=500),
@@ -771,7 +774,8 @@ async def fetch_jobs(
service=JobPost(session=session)
data,total=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status,
- employment_type=employment_type,top=top,skip=skip,active_only=active_only,
+ employment_type=employment_type,hiring_manager_id=hiring_manager_id,
+ top=top,skip=skip,active_only=active_only,
)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:
@@ -786,6 +790,7 @@ async def export_jobs(
department: str | None = Query(None),
requisition_status: str | None = Query(None),
employment_type: str | None = Query(None),
+ hiring_manager_id: str | None = Query(None),
active_only: bool = Query(False),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_EXPORT)),
session: AsyncSession = Depends(get_session),
@@ -795,7 +800,8 @@ async def export_jobs(
service=JobPost(session=session)
data,_=await service.fetch_jobs(
search=search,department=department,requisition_status=requisition_status,
- employment_type=employment_type,top=None,skip=0,active_only=active_only,
+ employment_type=employment_type,hiring_manager_id=hiring_manager_id,
+ top=None,skip=0,active_only=active_only,
)
filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx"
return Response(
@@ -1168,12 +1174,16 @@ async def fetch_pipeline_transitions(
@router.get("/job/assignments/fetch")
async def fetch_job_assignments(
job_post_id:str=Query(...),
+ current_only:bool=Query(True),
+ assignment_role:str=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Assignment(session=session)
- data=await service.list_job_assignments(job_post_id)
+ data=await service.list_job_assignments(
+ job_post_id,current_only=current_only,assignment_role=assignment_role,
+ )
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
diff --git a/backend/job/assignment/models.py b/backend/job/assignment/models.py
index fe906b9..9d19580 100644
--- a/backend/job/assignment/models.py
+++ b/backend/job/assignment/models.py
@@ -40,17 +40,48 @@ class JobAssignments(SQLModel, table=True):
return result.scalars().first()
@classmethod
- async def fetch_by_job(cls, session: AsyncSession, job_post_id, *, current_only: bool = True):
+ async def fetch_by_job(
+ cls,
+ session: AsyncSession,
+ job_post_id,
+ *,
+ current_only: bool = True,
+ assignment_role: str | None = None,
+ ):
uid = cls._as_uuid(job_post_id)
if uid is None:
return []
statement = select(cls).where(cls.job_post_id == uid)
if current_only:
statement = statement.where(cls.valid_to.is_(None))
+ if assignment_role:
+ statement = statement.where(cls.assignment_role == assignment_role)
statement = statement.order_by(cls.valid_from.desc())
result = await session.execute(statement)
return list(result.scalars().all())
+ @classmethod
+ async def close_current(cls, session: AsyncSession, job_post_id, assignment_role):
+ """End every open interval of this role on the job. Returns how many closed."""
+ uid = cls._as_uuid(job_post_id)
+ if uid is None or not assignment_role:
+ return 0
+ statement = select(cls).where(
+ cls.job_post_id == uid,
+ cls.assignment_role == assignment_role,
+ cls.valid_to.is_(None),
+ )
+ result = await session.execute(statement)
+ rows = list(result.scalars().all())
+ if not rows:
+ return 0
+ now = _now()
+ for row in rows:
+ row.valid_to = now
+ session.add(row)
+ await session.commit()
+ return len(rows)
+
@classmethod
async def insert_assignment(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
diff --git a/backend/job/assignment/serializers.py b/backend/job/assignment/serializers.py
index c1f22fc..f393a5d 100644
--- a/backend/job/assignment/serializers.py
+++ b/backend/job/assignment/serializers.py
@@ -1,24 +1,34 @@
-def serialize_job_assignment(row) -> dict:
+def serialize_job_assignment(row, names=None) -> dict:
+ names = names or {}
+ user_key = str(row.user_id) if row.user_id else None
+ by_key = str(row.assigned_by) if row.assigned_by else None
return {
"id": str(row.id),
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
- "user_id": str(row.user_id) if row.user_id else None,
+ "user_id": user_key,
+ "user_name": names.get(user_key) if user_key else None,
"assignment_role": row.assignment_role,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
- "assigned_by": str(row.assigned_by) if row.assigned_by else None,
+ "assigned_by": by_key,
+ "assigned_by_name": names.get(by_key) if by_key else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
-def serialize_application_assignment(row) -> dict:
+def serialize_application_assignment(row, names=None) -> dict:
+ names = names or {}
+ user_key = str(row.user_id) if row.user_id else None
+ by_key = str(row.assigned_by) if row.assigned_by else None
return {
"id": str(row.id),
"inbox_id": row.inbox_id,
- "user_id": str(row.user_id) if row.user_id else None,
+ "user_id": user_key,
+ "user_name": names.get(user_key) if user_key else None,
"assignment_role": row.assignment_role,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,
"valid_to": row.valid_to.isoformat() if row.valid_to else None,
- "assigned_by": str(row.assigned_by) if row.assigned_by else None,
+ "assigned_by": by_key,
+ "assigned_by_name": names.get(by_key) if by_key else None,
"created_at": row.created_at.isoformat() if row.created_at else None,
}
diff --git a/backend/job/assignment/views.py b/backend/job/assignment/views.py
index b5be521..e4a01f2 100644
--- a/backend/job/assignment/views.py
+++ b/backend/job/assignment/views.py
@@ -3,58 +3,129 @@ from sqlalchemy.ext.asyncio import AsyncSession
from job.assignment.models import ApplicationAssignments, JobAssignments
from job.assignment.serializers import serialize_application_assignment, serialize_job_assignment
+from job.job_post.models import JobPosts
from role.models import EnumRoles, Roles
from users.models import Users
+# job_assignments.assignment_role → the users.role that may hold it.
+# primary_recruiter is swappable; hiring_manager is the requisition owner.
+JOB_ASSIGNMENT_ROLES = {
+ "primary_recruiter": EnumRoles.RECRUITER,
+ "hiring_manager": EnumRoles.HIRING_MANAGER,
+}
+JOB_OWNER_COLUMN = {
+ "primary_recruiter": "current_recruiter_id",
+ "hiring_manager": "hiring_manager_id",
+}
+
class Assignment:
def __init__(self,session:AsyncSession):
self.session=session
- async def _require_recruiter(self,user_id):
- role=await Roles.get_role_by_name(self.session,EnumRoles.RECRUITER.value)
+ async def require_role(self,user_id,role_enum,field_name):
+ role=await Roles.get_role_by_name(self.session,role_enum.value)
user=await Users.get_user_by_id(self.session,user_id)
if not role or not user or user.role_id!=role.id:
- raise HTTPException(status_code=422,detail="user_id must be a recruiter")
+ raise HTTPException(
+ status_code=422,
+ detail=f"{field_name} must be a {role_enum.value}",
+ )
+ if not user.is_active or user.is_deleted:
+ raise HTTPException(status_code=422,detail=f"{field_name} is not an active user")
return user
- async def list_job_assignments(self,job_post_id):
+ def _job_role(self,raw):
+ key=(raw or "primary_recruiter").strip()
+ if key=="recruiter":
+ key="primary_recruiter"
+ if key not in JOB_ASSIGNMENT_ROLES:
+ allowed=", ".join(sorted(JOB_ASSIGNMENT_ROLES))
+ raise HTTPException(
+ status_code=422,
+ detail=f"assignment_role must be one of {allowed}",
+ )
+ return key
+
+ async def record_job_owner(self,job_post_id,user_id,assignment_role,assigned_by):
+ """Close the open interval of this role, then open a new one.
+
+ user_id None = unassign (hiring_manager cannot be cleared; callers
+ must not pass None for that role). No-ops when the same person already
+ holds the open interval. Does not touch job_posts columns.
+ """
+ role=self._job_role(assignment_role)
+ job_uid=JobAssignments._as_uuid(job_post_id)
+ by_uid=JobAssignments._as_uuid(assigned_by)
+ if not job_uid or not by_uid:
+ raise HTTPException(status_code=422,detail="Invalid job_post_id or assigned_by")
+ current=await JobAssignments.fetch_by_job(
+ self.session,job_uid,current_only=True,assignment_role=role,
+ )
+ if user_id is None:
+ if role=="hiring_manager":
+ raise HTTPException(status_code=422,detail="hiring_manager_id is required")
+ await JobAssignments.close_current(self.session,job_uid,role)
+ return None
+ user_uid=JobAssignments._as_uuid(user_id)
+ if not user_uid:
+ raise HTTPException(status_code=422,detail="Invalid user_id")
+ if current and str(current[0].user_id)==str(user_uid):
+ return current[0]
+ await JobAssignments.close_current(self.session,job_uid,role)
+ return await JobAssignments.insert_assignment(self.session,{
+ "job_post_id":job_uid,
+ "user_id":user_uid,
+ "assignment_role":role,
+ "assigned_by":by_uid,
+ })
+
+ async def list_job_assignments(self,job_post_id,current_only=True,assignment_role=None):
if not job_post_id:
raise HTTPException(status_code=400,detail="job_post_id is required")
- rows=await JobAssignments.fetch_by_job(self.session,job_post_id)
- return [serialize_job_assignment(r) for r in rows]
+ role=self._job_role(assignment_role) if assignment_role else None
+ rows=await JobAssignments.fetch_by_job(
+ self.session,job_post_id,current_only=current_only,assignment_role=role,
+ )
+ names=await Users.names_by_ids(
+ self.session,[r.user_id for r in rows]+[r.assigned_by for r in rows],
+ )
+ return [serialize_job_assignment(r,names=names) for r in rows]
async def create_job_assignment(self,payload,current_user):
user_id=payload.get("user_id")
job_post_id=payload.get("job_post_id")
if not user_id or not job_post_id:
raise HTTPException(status_code=422,detail="user_id and job_post_id are required")
- await self._require_recruiter(user_id)
- fields={
- "job_post_id":JobAssignments._as_uuid(job_post_id),
- "user_id":JobAssignments._as_uuid(user_id),
- "assignment_role":payload.get("assignment_role") or "primary_recruiter",
- "assigned_by":JobAssignments._as_uuid(
- current_user.get("id") if isinstance(current_user,dict) else None
- ),
- }
- if not fields["job_post_id"] or not fields["user_id"] or not fields["assigned_by"]:
- raise HTTPException(status_code=422,detail="Invalid job_post_id, user_id, or assigned_by")
- row=await JobAssignments.insert_assignment(self.session,fields)
- return serialize_job_assignment(row)
+ job=await JobPosts.get_job_post_by_id(self.session,job_post_id)
+ if not job or job.is_deleted:
+ raise HTTPException(status_code=404,detail="Job post not found")
+ role=self._job_role(payload.get("assignment_role"))
+ await self.require_role(user_id,JOB_ASSIGNMENT_ROLES[role],"user_id")
+ assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
+ row=await self.record_job_owner(job_post_id,user_id,role,assigned_by)
+ column=JOB_OWNER_COLUMN[role]
+ await JobPosts.update_job_post(self.session,job_post_id,{column:user_id})
+ names=await Users.names_by_ids(
+ self.session,[row.user_id,row.assigned_by] if row else [],
+ )
+ return serialize_job_assignment(row,names=names) if row else None
async def list_application_assignments(self,inbox_id):
if inbox_id is None:
raise HTTPException(status_code=400,detail="inbox_id is required")
rows=await ApplicationAssignments.fetch_by_inbox(self.session,int(inbox_id))
- return [serialize_application_assignment(r) for r in rows]
+ names=await Users.names_by_ids(
+ self.session,[r.user_id for r in rows]+[r.assigned_by for r in rows],
+ )
+ return [serialize_application_assignment(r,names=names) for r in rows]
async def create_application_assignment(self,payload,current_user):
user_id=payload.get("user_id")
inbox_id=payload.get("inbox_id")
if not user_id or inbox_id is None:
raise HTTPException(status_code=422,detail="user_id and inbox_id are required")
- await self._require_recruiter(user_id)
+ await self.require_role(user_id,EnumRoles.RECRUITER,"user_id")
fields={
"inbox_id":int(inbox_id),
"user_id":ApplicationAssignments._as_uuid(user_id),
@@ -66,4 +137,5 @@ class Assignment:
if not fields["user_id"] or not fields["assigned_by"]:
raise HTTPException(status_code=422,detail="Invalid user_id or assigned_by")
row=await ApplicationAssignments.insert_assignment(self.session,fields)
- return serialize_application_assignment(row)
+ names=await Users.names_by_ids(self.session,[row.user_id,row.assigned_by])
+ return serialize_application_assignment(row,names=names)
diff --git a/backend/job/job_post/export.py b/backend/job/job_post/export.py
index 17d9199..0666f1d 100644
--- a/backend/job/job_post/export.py
+++ b/backend/job/job_post/export.py
@@ -36,6 +36,7 @@ COLUMNS = [
("Status", 10),
("Publishing", 12),
("Recruiter", 18),
+ ("Hiring Manager", 18),
("Created By", 18),
("Created", 13),
("Requirements", 46),
@@ -126,6 +127,7 @@ def build_jobs_workbook(rows) -> bytes:
STATUS_LABELS.get(status_key, status_key),
row.get("status") or "",
row.get("recruiter_name") or "",
+ row.get("hiring_manager_name") or "",
row.get("created_by_name") or "",
_created(row),
_bullets(row.get("requirements")),
@@ -145,7 +147,7 @@ def build_jobs_workbook(rows) -> bytes:
status_cell.alignment = center
if status_key in STATUS_COLORS:
status_cell.font = Font(bold=True, color=STATUS_COLORS[status_key])
- created_cell = ws.cell(row=r, column=13)
+ created_cell = ws.cell(row=r, column=14)
if created_cell.value is not None:
created_cell.number_format = "dd mmm yyyy"
for c in (14, 15, 16):
diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py
index 6765169..52f0fe2 100644
--- a/backend/job/job_post/models.py
+++ b/backend/job/job_post/models.py
@@ -20,12 +20,12 @@ class JobPosts(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
title: str = Field(index=True)
- # foreign_keys is required, not decoration: current_recruiter_id below is a
- # SECOND foreign key into users.id, so the join condition is ambiguous without
- # it and every mapper fails to initialize. `user` is the AUTHOR of the post —
- # current_recruiter_id is deliberately a bare column with no relationship of
- # its own, because Users already carries five selectin relations that load on
- # every authenticated request. Same pairing as Notes.user / Notes.author.
+ # foreign_keys is required, not decoration: current_recruiter_id and
+ # hiring_manager_id below are extra FKs into users.id, so the join is
+ # ambiguous without it and every mapper fails to initialize. `user` is the
+ # AUTHOR of the post. The recruiter and hiring-manager columns stay bare —
+ # Users already carries five selectin relations that load on every
+ # authenticated request. Same pairing as Notes.user / Notes.author.
user: Optional["Users"] = Relationship(
back_populates="job_posts",
sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"},
@@ -56,7 +56,12 @@ 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 current pointer.
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
+ # Who owns the requisition (stable). Required at create. History lives in
+ # job_assignments with assignment_role=hiring_manager.
+ hiring_manager_id: uuid.UUID | None = Field(default=None, foreign_key="users.id", index=True)
created_by: uuid.UUID = Field(foreign_key="users.id")
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@@ -136,6 +141,7 @@ class JobPosts(SQLModel, table=True):
department: str | None = None,
requisition_status: str | None = None,
employment_type: str | None = None,
+ hiring_manager_id: uuid.UUID | None = None,
):
if ids:
rows = await cls.get_by_ids(session, ids, active_only=active_only)
@@ -157,6 +163,8 @@ class JobPosts(SQLModel, table=True):
statement = statement.where(cls.requisition_status == requisition_status)
if employment_type:
statement = statement.where(cls.employment_type == employment_type)
+ if hiring_manager_id is not None:
+ statement = statement.where(cls.hiring_manager_id == hiring_manager_id)
count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.created_at.desc())
@@ -185,6 +193,24 @@ class JobPosts(SQLModel, table=True):
result = await session.execute(statement)
return list(result.scalars().all())
+ @classmethod
+ async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids):
+ """Open requisitions per hiring manager, keyed by users.id."""
+ uids = [u for u in (user_ids or []) if u]
+ if not uids:
+ return {}
+ statement = (
+ select(cls.hiring_manager_id, func.count())
+ .where(
+ cls.hiring_manager_id.in_(uids),
+ cls.requisition_status == "open",
+ cls.is_deleted == False, # noqa: E712
+ )
+ .group_by(cls.hiring_manager_id)
+ )
+ result = await session.execute(statement)
+ return {uid: int(n or 0) for uid, n in result.all()}
+
@classmethod
async def insert_job_post(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py
index 92c6e61..17b97ef 100644
--- a/backend/job/job_post/serializers.py
+++ b/backend/job/job_post/serializers.py
@@ -29,7 +29,7 @@ def serialize_job_post(row) -> dict:
}
-def serialize_job_row(row, *, recruiter_name=None, applicant_count=0) -> dict:
+def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, applicant_count=0) -> dict:
"""Requisition view of a job post, for the Jobs screen.
Deliberately separate from serialize_job_post: that payload is shared by the
@@ -58,6 +58,8 @@ def serialize_job_row(row, *, recruiter_name=None, applicant_count=0) -> dict:
"closed_at": row.closed_at.isoformat() if row.closed_at else None,
"current_recruiter_id": str(row.current_recruiter_id) if row.current_recruiter_id else None,
"recruiter_name": recruiter_name,
+ "hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None,
+ "hiring_manager_name": hiring_manager_name,
"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,
diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py
index 21f6414..18c06d0 100644
--- a/backend/job/job_post/views.py
+++ b/backend/job/job_post/views.py
@@ -3,6 +3,7 @@ import logging
import os
import uuid
from pathlib import Path
+from uuid import UUID
import httpx
from dotenv import load_dotenv
@@ -10,7 +11,9 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel, model_validator
from inbox.models import Inbox_Messages
+from job.assignment.views import Assignment
from job.job_post.models import JobPostImages,JobPosts,SocialPlatform
+from role.models import EnumRoles
from users.models import Users
from job.job_post.plugins import (
BufferError,
@@ -60,6 +63,8 @@ class JobPostCreate(BaseModel):
scheduler_time: time | None = time(0, 0, 0)
scheduler_date: date | None = None
due_at: str | None = None
+ hiring_manager_id: UUID
+ current_recruiter_id: UUID | None = None
@model_validator(mode="after")
def validate_mode_and_due_at(self):
@@ -138,7 +143,24 @@ class JobPost:
# Column default is "linkedin"; an unpublished requisition must not
# masquerade as a LinkedIn post.
fields["platform"]="internal"
+
+ assignment=Assignment(self.session)
+ hm=await assignment.require_role(
+ payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id",
+ )
+ fields["hiring_manager_id"]=hm.id
+ rec=None
+ if payload.get("current_recruiter_id"):
+ rec=await assignment.require_role(
+ payload.get("current_recruiter_id"),EnumRoles.RECRUITER,"current_recruiter_id",
+ )
+ fields["current_recruiter_id"]=rec.id
+
row=await JobPosts.insert_job_post(self.session,fields)
+ assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
+ await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by)
+ if rec:
+ await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by)
if not publish:
return serialize_job_post(row)
@@ -187,20 +209,27 @@ class JobPost:
return await JobPosts.list_departments(self.session,active_only=active_only)
async def fetch_jobs(self,search=None,department=None,requisition_status=None,
- employment_type=None,top=None,skip=0,active_only=True):
+ employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True):
+ hm_uid=None
+ if hiring_manager_id:
+ hm_uid=JobPosts._as_uuid(hiring_manager_id)
+ if hm_uid is None:
+ raise HTTPException(status_code=422,detail="hiring_manager_id must be a UUID")
rows,total=await JobPosts.fetch_job_posts(
self.session,search=search,top=top,skip=skip,active_only=active_only,
department=department,requisition_status=requisition_status,
- employment_type=employment_type,
+ employment_type=employment_type,hiring_manager_id=hm_uid,
)
names=await Users.names_by_ids(
- self.session,[r.current_recruiter_id for r in rows],
+ self.session,
+ [r.current_recruiter_id for r in rows]+[r.hiring_manager_id for r in rows],
)
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows])
return [
serialize_job_row(
r,
recruiter_name=names.get(str(r.current_recruiter_id)),
+ hiring_manager_name=names.get(str(r.hiring_manager_id)),
applicant_count=counts.get(str(r.id),0),
)
for r in rows
@@ -208,13 +237,21 @@ class JobPost:
async def _job_row(self,row):
names=await Users.names_by_ids(
- self.session,[row.current_recruiter_id] if row.current_recruiter_id else [],
+ self.session,
+ [row.current_recruiter_id,row.hiring_manager_id],
+ )
+ return serialize_job_row(
+ row,
+ recruiter_name=names.get(str(row.current_recruiter_id)),
+ hiring_manager_name=names.get(str(row.hiring_manager_id)),
)
- return serialize_job_row(row,recruiter_name=names.get(str(row.current_recruiter_id)))
async def update_job(self,job_post_id,payload,current_user):
if not current_user:
raise HTTPException(status_code=401,detail="Not authenticated")
+ existing=await JobPosts.get_job_post_by_id(self.session,job_post_id)
+ if not existing or existing.is_deleted:
+ raise HTTPException(status_code=404,detail="Job post not found")
allowed=("title","department","location","employment_type","vacancies",
"salary","experience_min","experience_max","description")
fields={k:payload[k] for k in allowed if k in payload}
@@ -229,11 +266,41 @@ class JobPost:
fields["salary"]=str(high)
if "department" in fields and fields["department"] is None:
fields["department"]=""
+
+ assignment=Assignment(self.session)
+ assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
+ hm_changed=False
+ rec_changed=False
+ if "hiring_manager_id" in payload:
+ raw=payload.get("hiring_manager_id")
+ if not raw:
+ raise HTTPException(status_code=422,detail="hiring_manager_id is required")
+ hm=await assignment.require_role(raw,EnumRoles.HIRING_MANAGER,"hiring_manager_id")
+ fields["hiring_manager_id"]=hm.id
+ hm_changed=str(existing.hiring_manager_id)!=str(hm.id)
+ if "current_recruiter_id" in payload:
+ raw=payload.get("current_recruiter_id")
+ if raw is None or raw=="":
+ fields["current_recruiter_id"]=None
+ rec_changed=existing.current_recruiter_id is not None
+ else:
+ rec=await assignment.require_role(raw,EnumRoles.RECRUITER,"current_recruiter_id")
+ fields["current_recruiter_id"]=rec.id
+ rec_changed=str(existing.current_recruiter_id)!=str(rec.id)
+
if not fields:
raise HTTPException(status_code=400,detail="No fields to update")
row=await JobPosts.update_job_post(self.session,job_post_id,fields)
if not row:
raise HTTPException(status_code=404,detail="Job post not found")
+ if hm_changed:
+ await assignment.record_job_owner(
+ job_post_id,fields["hiring_manager_id"],"hiring_manager",assigned_by,
+ )
+ if rec_changed:
+ await assignment.record_job_owner(
+ job_post_id,fields.get("current_recruiter_id"),"primary_recruiter",assigned_by,
+ )
return await self._job_row(row)
async def delete_job(self,job_post_id,current_user):
diff --git a/backend/migrations/manual/015_job_post_hiring_manager.sql b/backend/migrations/manual/015_job_post_hiring_manager.sql
new file mode 100644
index 0000000..9b984c5
--- /dev/null
+++ b/backend/migrations/manual/015_job_post_hiring_manager.sql
@@ -0,0 +1,12 @@
+-- 015_job_post_hiring_manager.sql
+-- Stable owner of a requisition. Distinct from current_recruiter_id (who is
+-- working the req now, and may change). Both people also get a job_assignments
+-- history row; this column is the current pointer used by Jobs lists and the
+-- Managers portal. Applied at startup by alembic_setup.run_manual_sql().
+-- Needed because prod boots with DB_AUTOGENERATE=false.
+
+ALTER TABLE app.job_posts
+ ADD COLUMN IF NOT EXISTS hiring_manager_id UUID REFERENCES app.users(id);
+
+CREATE INDEX IF NOT EXISTS ix_job_posts_hiring_manager_id
+ ON app.job_posts (hiring_manager_id);
diff --git a/backend/users/app.py b/backend/users/app.py
index 47d5bbd..b540928 100644
--- a/backend/users/app.py
+++ b/backend/users/app.py
@@ -246,7 +246,12 @@ async def delete_user(
@router.get("/managers/fetch")
async def fetch_managers(
current_user: dict = Depends(
- require_permission(PermissionTag.JOBS_VIEW, PermissionTag.CANDIDATES_VIEW, require_all=False)
+ require_permission(
+ PermissionTag.JOBS_VIEW,
+ PermissionTag.CANDIDATES_VIEW,
+ PermissionTag.JOB_BOARD_CREATE,
+ require_all=False,
+ )
),
session: AsyncSession = Depends(get_session),
):
diff --git a/backend/users/models.py b/backend/users/models.py
index 53b8500..3eff965 100644
--- a/backend/users/models.py
+++ b/backend/users/models.py
@@ -33,8 +33,8 @@ class Users(SQLModel, table=True):
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
# user row once per post. Without an explicit strategy the default is a lazy load,
# which raises MissingGreenlet the moment anything touches it under asyncio.
- # foreign_keys must match the other side: job_posts.current_recruiter_id is a
- # second FK into this table, so this relation has to say it means created_by.
+ # foreign_keys must match the other side: job_posts also has current_recruiter_id
+ # and hiring_manager_id into this table, so this relation has to say created_by.
job_posts: List[JobPosts] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[JobPosts.created_by]"},
diff --git a/backend/users/views.py b/backend/users/views.py
index 4abd595..319db4e 100644
--- a/backend/users/views.py
+++ b/backend/users/views.py
@@ -114,13 +114,13 @@ class User:
async def get_managers(self):
"""Hiring-manager directory for Jobs/Candidates callers who do not hold rbac_users.view."""
- from job.assignment.models import JobAssignments
+ from job.job_post.models import JobPosts
role=await Roles.get_role_by_name(self.session,EnumRoles.HIRING_MANAGER.value)
if role is None:
raise HTTPException(status_code=500,detail="Role hiring_manager is not seeded")
rows=await Users.get_users(self.session,top=500,role_id=role.id)
- counts=await JobAssignments.count_open_reqs_by_users(self.session,[u.id for u in rows])
+ counts=await JobPosts.count_open_reqs_by_hiring_managers(self.session,[u.id for u in rows])
data=[
{
"id": str(u.id),
diff --git a/frontend/src/api/assignments.js b/frontend/src/api/assignments.js
index ab0a530..78ce874 100644
--- a/frontend/src/api/assignments.js
+++ b/frontend/src/api/assignments.js
@@ -4,23 +4,28 @@ import { request } from '../lib/apiClient'
assignments.js — who owns a requisition, and who owns an application.
Two parallel tables behind four routes (backend/job/app.py):
- job_assignments — a recruiter on a JOB POST (jobs.view / jobs.edit)
- application_assignments — a recruiter on ONE APPLICATION (candidates.view / candidates.edit)
+ job_assignments — recruiter OR hiring manager on a JOB POST
+ application_assignments — a recruiter on ONE APPLICATION
Rows are valid-time intervals: `valid_to === null` is the assignment in force
- now, and the fetch routes return only those by default. There is no unassign
- or reassign route — `insert_assignment` closes the previous open interval and
- opens a new one, so assigning someone else IS the reassignment.
+ now. Fetch defaults to current-only; pass currentOnly: false for the history
+ log. Reassignment closes the previous open interval of the SAME role.
- The server rejects any user whose role is not `recruiter` with a 422
- (Assignment._require_recruiter), which is why every picker here is sourced
- from /tasks/assignees/fetch — the one endpoint that already returns exactly
- the active recruiter-role users, and needs no rbac_users.view to call.
+ Recruiter pickers use /tasks/assignees/fetch; hiring-manager pickers use
+ /managers/fetch. Neither needs rbac_users.view. The current pointers also
+ live on job_posts (current_recruiter_id, hiring_manager_id) and PATCH
+ /jobs/update is the Jobs-screen write path.
============================================================ */
-/** Current recruiter(s) on one requisition. */
-export function listJob(jobPostId) {
- return request('/job/assignments/fetch', { params: { job_post_id: jobPostId } })
+/** Current or historical owners of one requisition. */
+export function listJob(jobPostId, { currentOnly, assignmentRole } = {}) {
+ return request('/job/assignments/fetch', {
+ params: {
+ job_post_id: jobPostId,
+ current_only: currentOnly,
+ assignment_role: assignmentRole,
+ },
+ })
}
/** Assign a recruiter to a requisition. Supersedes whoever held it. */
@@ -60,7 +65,8 @@ export function toAssignmentView(row, namesById) {
return {
id: row.id,
userId: row.user_id,
- name: namesById?.get(String(row.user_id)) ?? null,
+ name: row.user_name || namesById?.get(String(row.user_id)) || null,
+ assignedByName: row.assigned_by_name ?? null,
role: row.assignment_role || 'primary_recruiter',
jobPostId: row.job_post_id ?? null,
inboxId: row.inbox_id ?? null,
diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js
index 4a268ea..bf6fa61 100644
--- a/frontend/src/api/jobs.js
+++ b/frontend/src/api/jobs.js
@@ -9,13 +9,14 @@ import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
* picker payload does not.
*/
export function list({ search, department, requisitionStatus, employmentType,
- top, skip, activeOnly } = {}) {
+ hiringManagerId, top, skip, activeOnly } = {}) {
return request('/jobs/fetch', {
params: {
search,
department,
requisition_status: requisitionStatus,
employment_type: employmentType,
+ hiring_manager_id: hiringManagerId,
top,
skip,
active_only: activeOnly,
@@ -48,6 +49,8 @@ export function toJobView(row) {
publishStatus: row.status,
recruiter: row.recruiter_name,
recruiterId: row.current_recruiter_id,
+ hiringManager: row.hiring_manager_name,
+ hiringManagerId: row.hiring_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.
diff --git a/frontend/src/api/users.js b/frontend/src/api/users.js
index a0c112a..82626cb 100644
--- a/frontend/src/api/users.js
+++ b/frontend/src/api/users.js
@@ -9,6 +9,10 @@ export function list({ record_id, search, top, skip, roleId } = {}) {
return request('/users/fetch', { params: { record_id, search, top, skip, role_id: roleId } })
}
+export function listManagers() {
+ return request('/managers/fetch')
+}
+
export function listPendingApprovals() {
return request('/users/pending-approvals')
}
diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js
index a8907f1..9d845ec 100644
--- a/frontend/src/lib/queryKeys.js
+++ b/frontend/src/lib/queryKeys.js
@@ -54,6 +54,7 @@ export const qk = {
managers: {
all: () => ['managers'],
list: (p = {}) => ['managers', 'list', p],
+ directory: () => ['managers', 'directory'],
},
orgSettings: {
all: () => ['orgSettings'],
diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx
index 64a8e64..be5b9ae 100644
--- a/frontend/src/screens/Jobs.jsx
+++ b/frontend/src/screens/Jobs.jsx
@@ -26,6 +26,7 @@ import * as jobsApi from '../api/jobs'
import * as jobPostsApi from '../api/jobPosts'
import * as assignmentsApi from '../api/assignments'
import * as tasksApi from '../api/tasks'
+import * as usersApi from '../api/users'
import { JOB_STATUSES } from '../api/jobs'
import { empTypes, fmtShort } from '../data/seed'
@@ -183,7 +184,7 @@ export default function Jobs() {
if (type && j.type !== type) return false
if (q) {
const term = q.toLowerCase()
- const hay = [j.title, j.department, j.recruiter, j.location]
+ const hay = [j.title, j.department, j.recruiter, j.hiringManager, j.location]
.filter(Boolean)
.join(' ')
.toLowerCase()
@@ -226,6 +227,8 @@ export default function Jobs() {
{ key: 'platform', label: 'Platform', sortable: true, sortValue: (j) => platformLabel(j.platform), render: (j) => j.platform ?
Could not load hiring managers.
+ )} +Recruiter list needs tasks.view — you can assign later.
+ )} +
- {friendlyAuthError(currentQuery.error, 'Assignments did not load.')}
- {' '}Needs the jobs.view permission.
-
- {currentQuery.isPending - ? 'Loading…' - : currentName - ? <>Owned by {currentName}{current?.role ? ` · ${current.role.replace(/_/g, ' ')}` : ''}> - : 'No recruiter assigned yet.'} -
+{job.hiringManager || '—'}
+ )} +{job.recruiter || 'No recruiter assigned yet.'}
+ )} +The recruiter list needs the tasks.view permission.
- The recruiter list needs the tasks.view permission.
+ {friendlyAuthError(historyQuery.error, 'History did not load.')}
Loading…
+ ) : history.length === 0 ? ( +No assignment history yet.
+ ) : ( +- Jobs are not linked to a hiring-manager id yet, so this list is the current open requisitions rather than this manager's own. -
No open requisitions
+ {mineQuery.isPending ? ( +Loading requisitions…
+ ) : openMine.length === 0 ? ( +No open requisitions for this manager
) : ( - jobs.filter((j) => j.status === 'Open').slice(0, 8).map((j) => ( + openMine.slice(0, 8).map((j) => (tasks.view permission.
+