Edit_Job_Profile
ahmed.mujtaba 2026-09-15 15:52:53 +05:00
parent 1fa25f2854
commit 43852e22e0
15 changed files with 1067 additions and 160 deletions

View File

@ -13,6 +13,12 @@ Django-shaped aliases (same behaviour, different names):
python alembic_setup.py upgrade # apply versions/*.py (like migrate)
python alembic_setup.py stamp -r f3a7e5b34c86 # bookmark only; no DDL
One command for every pending change schema from all models, manual SQL, RBAC:
python alembic_setup.py sync --dry-run > review.sql # print the SQL, run nothing
python alembic_setup.py sync # apply it (drops skipped)
python alembic_setup.py sync --allow-drops # also drop tables/columns/indexes
Named `alembic_setup` rather than `alembic` because `backend/` is on `sys.path`,
so a module called `alembic.py` would shadow the installed package.
"""
@ -21,6 +27,7 @@ from __future__ import annotations
import argparse
import asyncio
import io
import logging
import sys
from contextlib import asynccontextmanager
@ -32,6 +39,7 @@ from alembic import command
from alembic.autogenerate import compare_metadata, produce_migrations, render_python_code
from alembic.config import Config
from alembic.operations import Operations
from alembic.operations import ops as alembic_ops
from alembic.runtime.migration import MigrationContext
from alembic.script import ScriptDirectory
from alembic.script.revision import ResolutionError
@ -40,7 +48,15 @@ from alembic.util.exc import CommandError
from sqlalchemy import MetaData, text
from sqlalchemy.engine import Connection
from db_setup import BASE_DIR, Base, close_db, database_url, get_engine, get_settings
from db_setup import (
BASE_DIR,
Base,
close_db,
create_schemas,
database_url,
get_engine,
get_settings,
)
logger = logging.getLogger("db.alembic")
@ -182,6 +198,7 @@ def config(connection: Connection | None = None) -> Config:
VERSION_TABLE = "alembic_version"
MANUAL_TABLE = "manual_migrations"
RBAC_LEDGER_TABLE = "rbac_sync_ledger" # mirrors role.plugins.RBAC_LEDGER_TABLE
def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to: Any) -> bool:
@ -194,7 +211,7 @@ def _include_object(obj: Any, name: str, type_: str, reflected: bool, compare_to
return False
if type_ != "table":
return True
if name in (VERSION_TABLE, MANUAL_TABLE): # migration bookkeeping; never ours to alter
if name in (VERSION_TABLE, MANUAL_TABLE, RBAC_LEDGER_TABLE): # bookkeeping; never ours to alter
return False
return not s.db_schemas or (obj.schema or s.db_default_schema) in s.db_schemas
@ -334,24 +351,56 @@ async def downgrade(revision: str = "-1") -> None:
logger.info("downgraded to %s", revision)
def _apply_upgrade_ops(connection: Connection) -> int:
"""Apply ORM→DB diffs in-process without writing a revision file."""
_DESTRUCTIVE_OPS = (
alembic_ops.DropTableOp,
alembic_ops.DropColumnOp,
alembic_ops.DropIndexOp,
alembic_ops.DropConstraintOp,
)
def _pending_ops(connection: Connection) -> list[Any]:
"""The ORM→DB diff as a flat list of Alembic operations, in revision-file order."""
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
ctx = MigrationContext.configure(connection, opts=opts)
script = produce_migrations(ctx, target_metadata())
if script.upgrade_ops.is_empty():
return 0
operations = Operations(ctx)
applied = 0
stack = [script.upgrade_ops]
while stack:
elem = stack.pop(0)
flat: list[Any] = []
def walk(elem: Any) -> None:
if hasattr(elem, "ops"):
stack.extend(elem.ops)
for child in elem.ops:
walk(child)
else:
operations.invoke(elem)
applied += 1
return applied
flat.append(elem)
walk(script.upgrade_ops)
return flat
def _invoke_ops(connection: Connection, ops: list[Any]) -> int:
opts = {k: v for k, v in context_options().items() if k != "process_revision_directives"}
operations = Operations(MigrationContext.configure(connection, opts=opts))
for op in ops:
operations.invoke(op)
return len(ops)
def _render_sql(ops: list[Any]) -> str:
"""Render operations as PostgreSQL DDL without touching a database (Alembic offline mode)."""
buf = io.StringIO()
ctx = MigrationContext.configure(
dialect_name="postgresql",
opts={"as_sql": True, "output_buffer": buf, "literal_binds": True},
)
operations = Operations(ctx)
for op in ops:
operations.invoke(op)
return buf.getvalue().strip()
def _apply_upgrade_ops(connection: Connection) -> int:
"""Apply ORM→DB diffs in-process without writing a revision file."""
return _invoke_ops(connection, _pending_ops(connection))
async def apply_model_drift() -> bool:
@ -458,6 +507,107 @@ async def run_manual_sql() -> None:
logger.info("applied manual migration %s", path.name)
async def _pending_manual_files() -> list[str]:
"""Manual SQL files this database has not recorded yet. Read-only."""
files = sorted(p.name for p in (MIGRATIONS / "manual").glob("*.sql") if p.is_file())
schema = get_settings().db_default_schema
table = f"{schema}.{MANUAL_TABLE}" if schema else MANUAL_TABLE
async with get_engine().connect() as conn:
driver = (await conn.get_raw_connection()).driver_connection
if await driver.fetchval("SELECT to_regclass($1)", table) is None:
return files
applied = {r["filename"] for r in await driver.fetch(f"SELECT filename FROM {table}")}
return [f for f in files if f not in applied]
async def run_rbac_sync(*, dry_run: bool = False) -> list[str]:
"""Bring permission tags, system roles and bundles up to role/plugins.py.
Returns the SQL it ran (or would run). Empty once the database matches the code,
so running it on every boot is cheap.
"""
from role.plugins import RbacState, build_rbac_sql # local import: pulls in the app models
schema = get_settings().db_default_schema or None
prefix = f"{schema}." if schema else ""
async with get_engine().connect() as conn:
driver = (await conn.get_raw_connection()).driver_connection
if await driver.fetchval("SELECT to_regclass($1)", f"{prefix}roles") is None:
logger.info("rbac sync skipped: roles table does not exist yet")
return []
has_ledger = (
await driver.fetchval("SELECT to_regclass($1)", f"{prefix}{RBAC_LEDGER_TABLE}")
) is not None
async def column(sql: str) -> frozenset[Any]:
return frozenset(tuple(r) if len(r) > 1 else r[0] for r in await driver.fetch(sql))
state = RbacState(
tags=await column(f"SELECT tag_name FROM {prefix}permission_tags"),
roles=await column(f"SELECT role_name FROM {prefix}roles"),
bundles=await column(f"SELECT name FROM {prefix}permissions"),
ledger=(
await column(f"SELECT kind, key FROM {prefix}{RBAC_LEDGER_TABLE}")
if has_ledger
else frozenset()
),
)
statements = build_rbac_sql(state, schema=schema)
if statements and not dry_run:
async with driver.transaction():
for statement in statements:
await driver.execute(statement)
logger.info("rbac sync applied %s statement(s)", len(statements))
return statements
async def sync(*, dry_run: bool = False, allow_drops: bool = False) -> str:
"""Every pending change in one pass: schema drift from all models, manual SQL, RBAC.
Returns the SQL as one reviewable script. `dry_run` executes nothing. Destructive
schema operations are listed as comments and skipped unless `allow_drops`.
"""
header = ["-- DRY RUN: nothing was executed"] if dry_run else []
# Rendered DDL names reflected tables without a schema, as `_run` resolves them.
header.append(f'SET search_path TO "{get_settings().db_default_schema or "public"}", public;')
out = list(header)
async with _lock():
pending: list[Any] = []
if await _schema_is_empty():
if dry_run:
out.append("-- empty database: sync would create every model table from metadata")
else:
await bootstrap_empty()
out.append("-- empty database: created every model table from metadata")
else:
pending = await _run(_pending_ops)
kept = [op for op in pending if allow_drops or not isinstance(op, _DESTRUCTIVE_OPS)]
skipped = [op for op in pending if not allow_drops and isinstance(op, _DESTRUCTIVE_OPS)]
if kept:
out += [f"-- schema: {len(kept)} operation(s) from the models", _render_sql(kept)]
if not dry_run:
await _run(lambda c: _invoke_ops(c, kept))
if skipped:
rendered = "\n".join(f"-- {line}" for line in _render_sql(skipped).splitlines())
header = f"-- skipped {len(skipped)} destructive operation(s); rerun with --allow-drops"
out += [header, rendered]
manual = await _pending_manual_files()
if manual:
verb = "pending (RBAC plan below assumes they did not run)" if dry_run else "applied"
out.append(f"-- manual SQL {verb}:\n" + "\n".join(f"-- {name}" for name in manual))
if not dry_run:
await run_manual_sql()
rbac = await run_rbac_sync(dry_run=dry_run)
if rbac:
out += [f"-- rbac: {len(rbac)} statement(s) from role/plugins.py", *rbac]
if len(out) == len(header):
out.append("-- database is in sync with the code")
return "\n\n".join(out) + "\n"
@asynccontextmanager
async def _lock() -> AsyncIterator[None]:
"""Advisory lock, so only one worker migrates when several boot at once."""
@ -492,6 +642,10 @@ async def migrate(*, autogen: bool | None = None, message: str = "auto") -> None
# ran above. Log and continue so the API can finish booting.
logger.exception("ORM drift apply failed; continuing boot: %s", exc)
await run_manual_sql()
try:
await run_rbac_sync()
except Exception as exc:
logger.exception("RBAC sync failed; continuing boot: %s", exc)
logger.info("database at revision %s", await current())
@ -510,10 +664,15 @@ def main(argv: Sequence[str] | None = None) -> None:
"current",
"head",
"stamp",
"sync",
],
)
parser.add_argument("-m", "--message", default="auto", help="revision message")
parser.add_argument("-r", "--revision", help="target revision")
parser.add_argument("--dry-run", action="store_true", help="sync: print the SQL, run nothing")
parser.add_argument(
"--allow-drops", action="store_true", help="sync: also drop tables, columns, indexes"
)
args = parser.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s")
@ -539,6 +698,10 @@ def main(argv: Sequence[str] | None = None) -> None:
print(head())
elif args.command == "stamp":
await stamp(args.revision or "head")
elif args.command == "sync":
if not args.dry_run:
await create_schemas()
print(await sync(dry_run=args.dry_run, allow_drops=args.allow_drops), end="")
finally:
await close_db()

View File

@ -2159,13 +2159,17 @@ class AtsResults(SQLModel, table=True):
return grouped
@classmethod
async def latest_per_candidate_for_job(cls, session: AsyncSession, job_post_id):
async def latest_per_candidate_for_job(
cls, session: AsyncSession, job_post_id, search=None, top=None, limit=None, skip=None,
):
"""Suggested candidates for one job: the newest score per person, best first.
A person is whichever identity the row carries form_data_id,
candidate_id or user_id (one is set at a time) so DISTINCT ON their
COALESCE, newest created_at winning. Rows carrying none are skipped.
Returns (row, user_name, user_email, form_name, form_email, candidate).
`search` matches the candidate's name, email, title or company; `top` and
`limit` cap how many rows come back; `skip` is the page offset. Returns
(row, user_name, user_email, form_name, form_email, candidate).
"""
from g_sheet.models import FormData
from job.candidate.models import Candidates
@ -2181,16 +2185,82 @@ class AtsResults(SQLModel, table=True):
.order_by(identity.desc(), cls.created_at.desc())
.subquery()
)
result = await session.execute(
statement = (
select(cls, Users.name, Users.email, FormData.name, FormData.candidate_email, Candidates)
.join(latest, cls.id == latest.c.id)
.outerjoin(Users, cls.user_id == Users.id)
.outerjoin(FormData, cls.form_data_id == FormData.id)
.outerjoin(Candidates, cls.candidate_id == Candidates.id)
.order_by(cls.overall_score.desc(), cls.created_at.desc())
.order_by(cls.overall_score.desc(), cls.created_at.desc(),cls.id.desc())
)
if search:
like = f"%{search.strip()}%"
statement = statement.where(
or_(
Users.name.ilike(like),
Users.email.ilike(like),
FormData.name.ilike(like),
FormData.candidate_email.ilike(like),
Candidates.candidate_name.ilike(like),
Candidates.candidate_email.ilike(like),
Candidates.job_title.ilike(like),
Candidates.current_company.ilike(like),
)
)
if skip:
statement = statement.offset(skip)
if top:
statement = statement.limit(top)
if limit:
statement = statement.limit(limit)
result = await session.execute(statement)
return list(result.all())
@classmethod
async def count_suggested_for_job(cls, session: AsyncSession, job_post_id, search=None) -> int:
"""How many suggested candidates match `search` — the paged list's total.
Same rows as latest_per_candidate_for_job before its offset and limit.
"""
from g_sheet.models import FormData
from job.candidate.models import Candidates
jid = cls._as_uuid(job_post_id)
if jid is None:
return 0
identity = func.coalesce(cls.form_data_id, cls.candidate_id, cls.user_id)
latest = (
select(cls.id)
.where(cls.job_post_id == jid, identity.is_not(None))
.distinct(identity)
.order_by(identity.desc(), cls.created_at.desc(),cls.id.desc())
.subquery()
)
statement = (
select(func.count())
.select_from(cls)
.join(latest, cls.id == latest.c.id)
.outerjoin(Users, cls.user_id == Users.id)
.outerjoin(FormData, cls.form_data_id == FormData.id)
.outerjoin(Candidates, cls.candidate_id == Candidates.id)
)
if search:
like = f"%{search.strip()}%"
statement = statement.where(
or_(
Users.name.ilike(like),
Users.email.ilike(like),
FormData.name.ilike(like),
FormData.candidate_email.ilike(like),
Candidates.candidate_name.ilike(like),
Candidates.candidate_email.ilike(like),
Candidates.job_title.ilike(like),
Candidates.current_company.ilike(like),
)
)
result = await session.execute(statement)
return int(result.scalar_one())
@classmethod
async def job_ids_by_emails(cls, session: AsyncSession, emails) -> dict:
"""{email: {job_post_id, ...}} for any prior ATS score of these people."""

View File

@ -937,13 +937,16 @@ async def fetch_requisition_statuses(
@router.get("/jobs/status-history/fetch")
async def fetch_job_status_history(
job_post_id:str=Query(...),
search: str | None = Query(None),
top: int | None = Query(10, ge=1, le=500),
limit: int | None = Query(None, ge=1),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Who changed requisition_status on one job, from what, to what, and when."""
try:
service=JobPost(session=session)
data=await service.fetch_status_history(job_post_id)
data=await service.fetch_status_history(job_post_id,search,top,limit)
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
except HTTPException:
raise
@ -989,15 +992,17 @@ async def fetch_job_profile(
search: str | None = Query(None),
top: int | None = Query(None, ge=1),
limit: int | None = Query(None, ge=1),
skip: int | None = Query(None, ge=0),
current_user: dict = Depends(require_permission(PermissionTag.JOBS_VIEW)),
session: AsyncSession = Depends(get_session),
):
"""Job profile page: the job row, suggested candidates (newest ats_results row
per person, best score first) and the Suggested / Top Match header stats."""
per person, best score first) and the Suggested / Top Match header stats.
`total` is how many suggested candidates match `search`, across all pages."""
try:
service=JobPost(session=session)
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})
data=await service.fetch_job_profile(job_post_id,current_user=current_user,search=search,top=top,limit=limit,skip=skip)
return JSONResponse(content={"data":data,"total":data["total"],"status_code":200})
except HTTPException:
raise
except Exception as e:

View File

@ -88,53 +88,31 @@ 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]:
"""The job's recruiters as UUID strings — current_recruiter_ids, nothing else.
# 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
Strings because every consumer keys names off str(uuid). Works on a JobPosts
row and on a RowMapping from fetch_job_stats; both allow attribute access.
"""
return [str(i) for i in (getattr(row, "current_recruiter_ids", None) or [])]
@classmethod
def has_recruiter(cls, recruiter_id):
"""SQL: this recruiter is the primary pointer or in current_recruiter_ids."""
"""SQL: this recruiter is in current_recruiter_ids."""
uid = recruiter_id if isinstance(recruiter_id, uuid.UUID) else cls._as_uuid(recruiter_id)
if uid is None:
return false()
return or_(
cls.current_recruiter_ids.contains([str(uid)]),
)
return cls.current_recruiter_ids.contains([str(uid)])
@classmethod
def no_recruiters(cls):
"""SQL: neither the pointer nor the JSON list names anyone."""
return and_(
cls.current_recruiter_ids.is_(None),
func.coalesce(func.jsonb_array_length(cls.current_recruiter_ids), 0) == 0,
)
"""SQL: current_recruiter_ids names nobody — NULL or [].
coalesce covers both, so this must not be ANDed with an IS NULL test:
an empty list is not NULL and would fall out of the result.
"""
return func.coalesce(func.jsonb_array_length(cls.current_recruiter_ids), 0) == 0
@classmethod
async def get_job_post_by_id(cls, session: AsyncSession, record_id: str):
@ -502,9 +480,9 @@ class JobPosts(SQLModel, table=True):
.subquery("job_reapplied")
)
# Alias so this join does not collide with the Users join inside
# the manual-upload subquery above.
Recruiter=aliased(Users)
# No recruiter join here: current_recruiter_ids is a JSONB array, so a join
# on it would emit one stats row per recruiter and multiply the counts.
# The service resolves recruiter names from that column instead.
statement = (
select(
cls.id.label("job_post_id"),
@ -514,7 +492,6 @@ class JobPosts(SQLModel, table=True):
cls.requisition_status,
cls.current_recruiter_ids,
cls.created_at,
Recruiter.name.label("recruiter_name"),
func.coalesce(stats.c.total_applicants, 0).label("total_applicants"),
func.coalesce(stats.c.shortlisting, 0).label("shortlisting"),
func.coalesce(stats.c.screened, 0).label("screened"),
@ -530,7 +507,6 @@ 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.in_(cls.current_recruiter_ids))
.where(cls.is_deleted == False) # noqa: E712
)
if active_only:
@ -885,15 +861,22 @@ class JobPostStatusHistory(SQLModel, table=True):
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@classmethod
async def fetch_by_job(cls, session: AsyncSession, job_post_id):
async def fetch_by_job(cls, session: AsyncSession, job_post_id,search=None,top=None,limit=None):
uid = JobPosts._as_uuid(job_post_id)
if uid is None:
return []
result = await session.execute(
statement = (
select(cls)
.where(cls.job_post_id == uid)
.order_by(cls.created_at.desc(), cls.id.desc())
)
if search:
statement = statement.where(cls.from_status.ilike(f"%{search}%") or cls.to_status.ilike(f"%{search}%"))
if top:
statement = statement.limit(top)
if limit:
statement = statement.limit(limit)
result = await session.execute(statement)
return list(result.scalars().all())

View File

@ -335,3 +335,21 @@ def suggested_summary(candidates) -> dict:
"top_score": max(scores) if scores else None,
"bands": bands,
}
def job_people_of(row, people) -> dict:
"""One job's slice of a page-wide {"recruiters": …, "hiring_manager": …} lookup.
Users.job_people resolves every id on the page in two queries; this picks out
the names belonging to one row, keeping the two roles in their own maps.
"""
from job.job_post.models import JobPosts
recruiters = (people or {}).get("recruiters") or {}
managers = (people or {}).get("hiring_manager") or {}
manager_id = getattr(row, "hiring_manager_id", None)
key = str(manager_id) if manager_id else None
return {
"recruiters": {rid: recruiters.get(rid) for rid in JobPosts.recruiter_ids_of(row)},
"hiring_manager": {key: managers[key]} if key and key in managers else {},
}

View File

@ -20,17 +20,24 @@ 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]
# 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 _recruiter_payload(row, names=None):
"""Recruiter ids plus their mapped names, for the payloads that still send the
flat shape (serialize_job_post, serialize_job_stats)."""
names = names or {}
ids = JobPosts.recruiter_ids_of(row)
mapped = [names.get(i) for i in ids]
# recruiter_name is the singular legacy field: the first id that resolved.
recruiter_name = None
for n in mapped:
if n:
recruiter_name = n
break
return {
"current_recruiter_ids": ids,
"recruiter_name": recruiter_name,
"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:
@ -67,20 +74,26 @@ def serialize_job_post(row, *, names=None) -> dict:
}
def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_name=None, applicant_count=0) -> dict:
"""Requisition view of a job post, for the Jobs screen.
def serialize_job_row(row, *, people=None, applicant_count=0) -> dict:
"""Requisition view of a job post, for the Jobs screen and the job profile.
Deliberately separate from serialize_job_post: that payload is shared by the
inbox, candidate and matching paths. department is the one shared field
talent-pool filters key off it on attached job_posts.
`people` is Users.job_people's shape — {"recruiters": {id: name},
"hiring_manager": {id: name}} so the two roles stay apart in the response.
"""
req = getattr(row, "requisition", None)
recruiter_names=names.get("recruiters")
hiring_manager_name=names.get("hiring_manager",None)
people = people or {}
recruiters = people.get("recruiters") or {}
hiring_manager = people.get("hiring_manager") or None
return {
"id": str(row.id),
"title": row.title,
"department": row.department_ref.name if row.department_ref else None,
# The linked department wins; the free-text column is the fallback, and is
# still what fetch_jobs filters on, so reads and filters agree.
"department": (row.department_ref.name if row.department_ref else None) or row.department or None,
"department_id": str(row.department_id) if row.department_id else None,
"location": row.location,
"employment_type": row.employment_type,
@ -104,8 +117,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,
"recruiters": recruiters,
"hiring_manager": hiring_manager,
}

View File

@ -23,6 +23,7 @@ from role.models import EnumRoles
from users.models import Users
from job.job_post.plugins import (
BufferError,
job_people_of,
create_buffer_post,
list_buffer_channels,
local_status,
@ -368,12 +369,12 @@ class JobPost:
async def fetch_requisition_statuses(self):
return RequisitionStatus.as_list()
async def fetch_status_history(self,job_post_id):
async def fetch_status_history(self,job_post_id,search=None,top=None,limit=None):
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")
rows=await JobPostStatusHistory.fetch_by_job(self.session,job_post_id)
names=await Users.names_by_ids(self.session,[r.changed_by for r in rows])
rows=await JobPostStatusHistory.fetch_by_job(self.session,job_post_id,search,top,limit)
names=await Users.names_by_ids(self.session,[r.changed_by for r in rows],search,top,limit)
return [
serialize_status_history(r,changed_by_name=names.get(str(r.changed_by)))
for r in rows
@ -396,22 +397,22 @@ class JobPost:
employment_type=employment_type,hiring_manager_id=hm_uid,
restrict_ids=restrict,
)
names=await Users.names_by_ids(
people=await Users.job_people(
self.session,
[uid for r in rows for uid in JobPosts.recruiter_ids_of(r)]+[r.hiring_manager_id for r in rows],
[uid for r in rows for uid in JobPosts.recruiter_ids_of(r)],
[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,
names=names,
hiring_manager_name=names.get(str(r.hiring_manager_id)),
people=job_people_of(r,people),
applicant_count=counts.get(str(r.id),0),
)
for r in rows
],total
async def fetch_job_profile(self,job_post_id,current_user=None,search=None,top=None,limit=None):
async def fetch_job_profile(self,job_post_id,current_user=None,search=None,top=None,limit=None,skip=None):
"""Job profile page: the requisition row, its suggested candidates and the
Suggested / Top Match header stats one round trip.
@ -433,19 +434,20 @@ class JobPost:
if not job or job.is_deleted:
raise HTTPException(status_code=404,detail="Job post not found")
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)
people=await Users.job_people(
self.session,
JobPosts.recruiter_ids_of(job),
[job.hiring_manager_id],
)
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[job.id])
job_payload=serialize_job_row(
job,
names=recruiter_x_manager_names["recruiters"],
hiring_manager_name=recruiter_x_manager_names["hiring_manager"],
people=people,
applicant_count=counts.get(str(job.id),0),
)
rows=await AtsResults.latest_per_candidate_for_job(self.session,job.id)
rows=await AtsResults.latest_per_candidate_for_job(self.session,job.id,search,top,limit,skip)
total=await AtsResults.count_suggested_for_job(self.session,job.id,search)
emails=[user_email or form_email for row,_,user_email,_,form_email,candidate in rows if candidate is None]
by_email=await Candidates.latest_completed_for_job_by_emails(self.session,job.id,emails)
candidates=[]
@ -460,18 +462,15 @@ class JobPost:
source=suggested_source(row.inbox_id,row.form_data_id,scored.source if scored else None),
optional_matched=optional_skill_hits(job.optional_skills,scored.matched_keywords if scored else []),
))
return {"job":job_payload,**suggested_summary(candidates),"candidates":candidates}
return {"job":job_payload,**suggested_summary(candidates),"total":total,"candidates":candidates}
async def _job_row(self,row):
names=await Users.names_by_ids(
people=await Users.job_people(
self.session,
JobPosts.recruiter_ids_of(row)+[row.hiring_manager_id],
)
return serialize_job_row(
row,
names=names,
hiring_manager_name=names.get(str(row.hiring_manager_id)),
JobPosts.recruiter_ids_of(row),
[row.hiring_manager_id],
)
return serialize_job_row(row,people=job_people_of(row,people))
async def _require_department(self,department_id):
from department.models import Department

View File

@ -0,0 +1,220 @@
-- rbac_ingest.sql
-- One-shot ingestion of the RBAC tables: roles, permissions (bundles) and
-- permission_tags. Produces the same end state as manual migrations
-- 001, 004, 005, 007, 008, 019, 024, 026, 028 and 038 combined, so a fresh
-- database can be seeded in one pass.
--
-- Deliberately NOT under migrations/manual/ — run_manual_sql() only globs that
-- folder, so this file never auto-applies at startup. Run it by hand:
-- psql "$DATABASE_URL" -f migrations/seed/rbac_ingest.sql
--
-- Idempotent: every insert is ON CONFLICT DO NOTHING and bundle ids are only
-- appended to a role when missing, so re-running it is a no-op.
-- Users must log in again afterwards — the frontend caches /users/me permissions.
BEGIN;
-- =============================================================================
-- 1. permission_tags — every module x action (17 x 8 = 136), matches
-- users/permissions.py PermissionModule / PermissionAction
-- =============================================================================
INSERT INTO app.permission_tags
(tag_name, module, action, description, created_at, updated_at, is_active, is_deleted)
SELECT
m.module || '.' || a.action,
m.module,
a.action,
NULL,
NOW(),
NOW(),
true,
false
FROM unnest(ARRAY[
'dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews',
'assessments', 'offers', 'reports', 'analytics', 'job_board', 'settings',
'rbac_users', 'tasks', 'talent', 'requisitions', 'department'
]) WITH ORDINALITY AS m(module, m_ord)
CROSS JOIN unnest(ARRAY[
'view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure'
]) WITH ORDINALITY AS a(action, a_ord)
ORDER BY m.m_ord, a.a_ord
ON CONFLICT (tag_name) DO NOTHING;
-- =============================================================================
-- 2. roles — explicit ids: the app hardcodes candidate = 8 and
-- hiring_manager = 4 (inbox/models.py, users/models.py)
-- =============================================================================
INSERT INTO app.roles
(id, role_name, description, permissions, is_system, created_at, updated_at, is_active, is_deleted)
VALUES
(1, 'system_administrator', 'Full system access', '[]'::jsonb, true, NOW(), NOW(), true, false),
(2, 'hr_administrator', 'HR administration', '[]'::jsonb, true, NOW(), NOW(), false, true),
(3, 'recruiter', 'Recruiting staff', '[]'::jsonb, true, NOW(), NOW(), true, false),
(4, 'hiring_manager', 'Hiring manager for own requisitions', '[]'::jsonb, true, NOW(), NOW(), true, false),
(5, 'department_head', 'Head of a department', '[]'::jsonb, true, NOW(), NOW(), true, false),
(6, 'interviewer', 'Interview panel member', '[]'::jsonb, true, NOW(), NOW(), false, true),
(7, 'ceo', 'Chief executive', '[]'::jsonb, true, NOW(), NOW(), false, true),
(8, 'candidate', 'Applicant account', '[]'::jsonb, true, NOW(), NOW(), true, false)
ON CONFLICT DO NOTHING;
-- Explicit ids bypass the sequence; move it past them so UI-created roles don't collide.
SELECT setval(
pg_get_serial_sequence('app.roles', 'id'),
GREATEST((SELECT MAX(id) FROM app.roles), 1)
);
-- =============================================================================
-- 3. permissions — named bundles; each resolves to permission_tags ids by
-- module list and/or explicit tag names
-- =============================================================================
INSERT INTO app.permissions
(name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted)
SELECT
b.name,
b.description,
(
SELECT COALESCE(jsonb_agg(t.id ORDER BY t.id), '[]'::jsonb)
FROM app.permission_tags t
WHERE t.is_deleted = false
AND (t.module = ANY(b.modules) OR t.tag_name = ANY(b.tags))
),
true,
NOW(),
NOW(),
true,
false
FROM (VALUES
('all_access',
'Every permission in the original 13 modules',
ARRAY['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users']::text[],
ARRAY[]::text[]),
('analytics_dashboard',
'Dashboard KPI tiles, analytics charts, offers, and interview list',
ARRAY['dashboard', 'analytics', 'offers']::text[],
ARRAY['interviews.view']::text[]),
('tasks_management',
'Recruiting task list: view, create, complete and manage tasks',
ARRAY['tasks']::text[],
ARRAY[]::text[]),
('tasks_viewer',
'Recruiting task list: read-only access',
ARRAY[]::text[],
ARRAY['tasks.view', 'tasks.export']::text[]),
('talent_sourcing',
'LinkedIn talent sourcing: run Apify searches and view sourced profiles',
ARRAY['talent']::text[],
ARRAY[]::text[]),
('hiring_forms',
'Fill and amend candidate hiring forms (requisition, interview analysis, cultural fit)',
ARRAY[]::text[],
ARRAY['interviews.create', 'interviews.edit', 'interviews.delete']::text[]),
('requisitions_management',
'Employee requisition forms: view, create, edit and manage requisitions',
ARRAY['requisitions']::text[],
ARRAY[]::text[]),
('manager_candidates',
'Hiring manager: list candidates on own requisition jobs, view profiles, write notes',
ARRAY[]::text[],
ARRAY['candidates.view', 'candidates.create', 'candidates.edit']::text[]),
('requisitions_self',
'Own employee requisition forms: view, create, edit (not org-wide manage)',
ARRAY[]::text[],
ARRAY['requisitions.view', 'requisitions.create', 'requisitions.edit']::text[]),
('interviews_tab',
'Interviews and Calendar tabs: list, schedule, reschedule',
ARRAY[]::text[],
ARRAY['interviews.view', 'interviews.create', 'interviews.edit']::text[]),
('department_management',
'Departments: view, create, edit and manage departments',
ARRAY['department']::text[],
ARRAY[]::text[])
) AS b(name, description, modules, tags)
ON CONFLICT (name) DO NOTHING;
-- =============================================================================
-- 4. roles.permissions — attach bundle ids (append only what is missing).
-- requisitions_self and interviews_tab are for custom roles, so unattached.
-- =============================================================================
WITH role_bundles(role_name, bundle_name) AS (
VALUES
('system_administrator', 'all_access'),
('system_administrator', 'analytics_dashboard'),
('system_administrator', 'tasks_management'),
('system_administrator', 'talent_sourcing'),
('system_administrator', 'hiring_forms'),
('system_administrator', 'requisitions_management'),
('system_administrator', 'department_management'),
('hr_administrator', 'analytics_dashboard'),
('hr_administrator', 'tasks_management'),
('hr_administrator', 'talent_sourcing'),
('hr_administrator', 'hiring_forms'),
('hr_administrator', 'requisitions_management'),
('hr_administrator', 'department_management'),
('recruiter', 'analytics_dashboard'),
('recruiter', 'tasks_management'),
('recruiter', 'talent_sourcing'),
('recruiter', 'hiring_forms'),
('recruiter', 'requisitions_management'),
('hiring_manager', 'analytics_dashboard'),
('hiring_manager', 'tasks_viewer'),
('hiring_manager', 'talent_sourcing'),
('hiring_manager', 'hiring_forms'),
('hiring_manager', 'requisitions_management'),
('hiring_manager', 'manager_candidates'),
('department_head', 'analytics_dashboard'),
('department_head', 'tasks_viewer'),
('department_head', 'talent_sourcing'),
('department_head', 'hiring_forms'),
('department_head', 'requisitions_management'),
('ceo', 'analytics_dashboard'),
('ceo', 'tasks_viewer'),
('ceo', 'talent_sourcing'),
('ceo', 'hiring_forms'),
('ceo', 'requisitions_management')
),
wanted AS (
SELECT rb.role_name, p.id AS permission_id
FROM role_bundles rb
JOIN app.permissions p ON p.name = rb.bundle_name AND p.is_deleted = false
)
UPDATE app.roles r
SET permissions = (
SELECT COALESCE(jsonb_agg(ids.id ORDER BY ids.id), '[]'::jsonb)
FROM (
SELECT value::int AS id
FROM jsonb_array_elements_text(COALESCE(r.permissions, '[]'::jsonb))
UNION
SELECT w.permission_id FROM wanted w WHERE w.role_name = r.role_name
) ids
),
updated_at = NOW()
WHERE EXISTS (
SELECT 1 FROM wanted w
WHERE w.role_name = r.role_name
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(w.permission_id))
);
COMMIT;
-- =============================================================================
-- 5. Verify: role -> bundles -> resolved tag count
-- =============================================================================
SELECT
r.id,
r.role_name,
r.is_deleted,
string_agg(DISTINCT p.name, ', ' ORDER BY p.name) AS bundles,
COUNT(DISTINCT t.id) AS tag_count
FROM app.roles r
LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(r.permissions, '[]'::jsonb)) rp(pid) ON true
LEFT JOIN app.permissions p ON p.id = rp.pid::int AND p.is_deleted = false
LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(p.permission_tags, '[]'::jsonb)) pt(tid) ON true
LEFT JOIN app.permission_tags t ON t.id = pt.tid::int AND t.is_deleted = false
GROUP BY r.id, r.role_name, r.is_deleted
ORDER BY r.id;

265
backend/role/plugins.py Normal file
View File

@ -0,0 +1,265 @@
"""RBAC declared in code: system roles, permission bundles, and the SQL that syncs them.
`PermissionTag` (users/permissions.py) is the tag vocabulary. `SYSTEM_ROLES` and
`RBAC_BUNDLES` below declare the rest. Adding a module means adding its enum values
and a bundle entry here no hand-written migrations/manual/*.sql.
`build_rbac_sql` turns the difference between this code and a database into
idempotent SQL. Admins curate roles in Access Control (a role's matrix replaces its
bundle list), so every code-declared grant a tag inside a bundle, a bundle on a
role is applied once per database and recorded in a ledger. A grant an admin later
removes stays removed; only grants the ledger has never seen are applied.
A database that already has roles but no ledger predates the sync. Its grants were
applied by the old manual migrations and may since have been curated, so the first
sync only records them. Bundles that do not exist yet are still created and filled.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import NamedTuple
from role.models import EnumRoles
from users.permissions import PermissionModule, PermissionTag
RBAC_LEDGER_TABLE = "rbac_sync_ledger"
class SystemRole(NamedTuple):
id: int # pinned: the app hardcodes candidate = 8 and hiring_manager = 4
name: EnumRoles
description: str
retired: bool = False # soft-deleted by 026; created retired on a fresh database
class Bundle(NamedTuple):
description: str
modules: tuple[PermissionModule, ...] = ()
tags: tuple[PermissionTag, ...] = ()
roles: tuple[EnumRoles, ...] = ()
SYSTEM_ROLES: tuple[SystemRole, ...] = (
SystemRole(1, EnumRoles.SYSTEM_ADMINISTRATOR, "Full system access"),
SystemRole(2, EnumRoles.HR_ADMINISTRATOR, "HR administration", retired=True),
SystemRole(3, EnumRoles.RECRUITER, "Recruiting staff"),
SystemRole(4, EnumRoles.HIRING_MANAGER, "Hiring manager for own requisitions"),
SystemRole(5, EnumRoles.DEPARTMENT_HEAD, "Head of a department"),
SystemRole(6, EnumRoles.INTERVIEWER, "Interview panel member", retired=True),
SystemRole(7, EnumRoles.CEO, "Chief executive", retired=True),
SystemRole(8, EnumRoles.CANDIDATE, "Applicant account"),
)
_R = EnumRoles
_T = PermissionTag
_M = PermissionModule
_STAFF = (
_R.SYSTEM_ADMINISTRATOR,
_R.HR_ADMINISTRATOR,
_R.RECRUITER,
_R.HIRING_MANAGER,
_R.DEPARTMENT_HEAD,
_R.CEO,
)
RBAC_BUNDLES: dict[str, Bundle] = {
"all_access": Bundle(
"Every permission in every module",
modules=tuple(PermissionModule),
roles=(_R.SYSTEM_ADMINISTRATOR,),
),
"analytics_dashboard": Bundle(
"Dashboard KPI tiles, analytics charts, offers, and interview list",
modules=(_M.DASHBOARD, _M.ANALYTICS, _M.OFFERS),
tags=(_T.INTERVIEWS_VIEW,),
roles=_STAFF,
),
"tasks_management": Bundle(
"Recruiting task list: view, create, complete and manage tasks",
modules=(_M.TASKS,),
roles=(_R.SYSTEM_ADMINISTRATOR, _R.HR_ADMINISTRATOR, _R.RECRUITER),
),
"tasks_viewer": Bundle(
"Recruiting task list: read-only access",
tags=(_T.TASKS_VIEW, _T.TASKS_EXPORT),
roles=(_R.HIRING_MANAGER, _R.DEPARTMENT_HEAD, _R.CEO),
),
"talent_sourcing": Bundle(
"LinkedIn talent sourcing: run Apify searches and view sourced profiles",
modules=(_M.TALENT,),
roles=_STAFF,
),
"hiring_forms": Bundle(
"Fill and amend candidate hiring forms (requisition, interview analysis, cultural fit)",
tags=(_T.INTERVIEWS_CREATE, _T.INTERVIEWS_EDIT, _T.INTERVIEWS_DELETE),
roles=_STAFF,
),
"requisitions_management": Bundle(
"Employee requisition forms: view, create, edit and manage requisitions",
modules=(_M.REQUISITIONS,),
roles=_STAFF,
),
"manager_candidates": Bundle(
"Hiring manager: list candidates on own requisition jobs, view profiles, write notes",
tags=(_T.CANDIDATES_VIEW, _T.CANDIDATES_CREATE, _T.CANDIDATES_EDIT),
roles=(_R.HIRING_MANAGER,),
),
# Unattached: admins tick these on custom roles in Access Control.
"requisitions_self": Bundle(
"Own employee requisition forms: view, create, edit (not org-wide manage)",
tags=(_T.REQUISITIONS_VIEW, _T.REQUISITIONS_CREATE, _T.REQUISITIONS_EDIT),
),
"interviews_tab": Bundle(
"Interviews and Calendar tabs: list, schedule, reschedule",
tags=(_T.INTERVIEWS_VIEW, _T.INTERVIEWS_CREATE, _T.INTERVIEWS_EDIT),
),
"department_management": Bundle(
"Departments: view, create, edit and manage departments",
modules=(_M.DEPARTMENT,),
roles=(_R.SYSTEM_ADMINISTRATOR, _R.HR_ADMINISTRATOR),
),
}
@dataclass(frozen=True)
class RbacState:
"""What the database already holds, read before planning."""
tags: frozenset[str]
roles: frozenset[str]
bundles: frozenset[str]
ledger: frozenset[tuple[str, str]] # (kind, key)
def bundle_tag_names(bundle: Bundle) -> list[str]:
"""The bundle's tags in vocabulary order: whole modules plus explicit tags."""
modules = {m.value for m in bundle.modules}
explicit = set(bundle.tags)
return [t.value for t in PermissionTag if t.value.split(".")[0] in modules or t in explicit]
def _lit(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
def _in(values: list[str]) -> str:
return "(" + ", ".join(_lit(v) for v in values) + ")"
def _rows(rows: list[str]) -> str:
return ",\n ".join(rows)
def build_rbac_sql(state: RbacState, *, schema: str | None) -> list[str]:
"""Idempotent statements that bring the database up to the code. Empty when in sync."""
def table(name: str) -> str:
return f'"{schema}".{name}' if schema else name
tags_t = table("permission_tags")
roles_t = table("roles")
perms_t = table("permissions")
ledger_t = table(RBAC_LEDGER_TABLE)
adopt = not state.ledger and bool(state.roles)
sql: list[str] = []
recorded: list[tuple[str, str]] = []
missing_tags = [t.value for t in PermissionTag if t.value not in state.tags]
if missing_tags:
values = _rows([
f"({_lit(tag)}, {_lit(tag.split('.')[0])}, {_lit(tag.split('.')[1])}, "
"NOW(), NOW(), true, false)"
for tag in missing_tags
])
sql.append(f"""\
INSERT INTO {tags_t}
(tag_name, module, action, created_at, updated_at, is_active, is_deleted)
VALUES
{values}
ON CONFLICT (tag_name) DO NOTHING;""")
missing_roles = [r for r in SYSTEM_ROLES if r.name.value not in state.roles]
if missing_roles:
values = _rows([
f"({r.id}, {_lit(r.name.value)}, {_lit(r.description)}, '[]'::jsonb, true, "
f"NOW(), NOW(), {str(not r.retired).lower()}, {str(r.retired).lower()})"
for r in missing_roles
])
sql.append(f"""\
INSERT INTO {roles_t}
(id, role_name, description, permissions, is_system,
created_at, updated_at, is_active, is_deleted)
VALUES
{values}
ON CONFLICT DO NOTHING;""")
# Pinned ids bypass the sequence; move it past them so new roles don't collide.
sql.append(
f"SELECT setval(pg_get_serial_sequence('{roles_t}', 'id'), "
f"GREATEST((SELECT MAX(id) FROM {roles_t}), 1));"
)
for name, bundle in RBAC_BUNDLES.items():
created = name not in state.bundles
if created:
sql.append(f"""\
INSERT INTO {perms_t}
(name, description, permission_tags, is_system,
created_at, updated_at, is_active, is_deleted)
VALUES
({_lit(name)}, {_lit(bundle.description)}, '[]'::jsonb, true, NOW(), NOW(), true, false)
ON CONFLICT (name) DO NOTHING;""")
apply = created or not adopt
tags = [
t for t in bundle_tag_names(bundle)
if ("bundle_tag", f"{name}:{t}") not in state.ledger
]
if tags:
recorded += [("bundle_tag", f"{name}:{t}") for t in tags]
if apply:
sql.append(f"""\
UPDATE {perms_t} p
SET permission_tags = COALESCE(p.permission_tags, '[]'::jsonb) || (
SELECT COALESCE(jsonb_agg(t.id ORDER BY t.id), '[]'::jsonb)
FROM {tags_t} t
WHERE t.tag_name IN {_in(tags)}
AND NOT (COALESCE(p.permission_tags, '[]'::jsonb) @> jsonb_build_array(t.id))
),
updated_at = NOW()
WHERE p.name = {_lit(name)};""")
roles = [
r.value for r in bundle.roles
if ("role_bundle", f"{r.value}:{name}") not in state.ledger
]
if roles:
recorded += [("role_bundle", f"{r}:{name}") for r in roles]
if apply:
sql.append(f"""\
UPDATE {roles_t} r
SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id),
updated_at = NOW()
FROM {perms_t} p
WHERE p.name = {_lit(name)}
AND r.role_name IN {_in(roles)}
AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id));""")
if not sql and not recorded:
return []
ledger_sql = [f"""\
CREATE TABLE IF NOT EXISTS {ledger_t} (
kind text NOT NULL,
key text NOT NULL,
applied_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (kind, key)
);"""]
if recorded:
values = _rows([f"({_lit(kind)}, {_lit(key)})" for kind, key in recorded])
ledger_sql.append(f"""\
INSERT INTO {ledger_t} (kind, key)
VALUES
{values}
ON CONFLICT DO NOTHING;""")
return ledger_sql + sql

View File

@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlmodel import Field, Relationship, SQLModel, select
from role.models import Roles
from role.models import EnumRoles, Roles
from job.job_post.models import JobPosts
if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this module
@ -154,36 +154,61 @@ class Users(SQLModel, table=True):
return [row[0] for row in result.all()]
@classmethod
async def names_by_ids(cls, session: AsyncSession, user_ids,manager_id=None,search=None,top=None,limit=None) -> dict[str, str]:
async def names_by_ids(cls, session: AsyncSession, user_ids,search=None,top=None,limit=None) -> dict[str, str]:
"""Resolve {user_id: name} in a single query, whatever role those ids hold.
data = {"recruiters": {}, "hiring_manager": {}}
Shared by departments, offers, history, notifications and the job payloads,
so it stays role-agnostic: a read-time role filter cannot fix bad data, it
only makes names disappear. Role is enforced on write by require_role.
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.
"""
uids = {u for u in (user_ids or []) if u}
if not uids:
return {}
result = await session.execute(
statement =(
select(cls.id, cls.name).where(cls.id.in_(uids))
)
if search:
result = result.where(cls.name.ilike(f"%{search}%"))
statement = statement.where(cls.name.ilike(f"%{search}%"))
if top:
result = result.limit(top)
statement = statement.limit(top)
if limit:
result = result.limit(limit)
statement = statement.limit(limit)
result = await session.execute(statement)
return {str(uid): name for uid, name in result.all()}
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)
@classmethod
async def job_people(cls, session: AsyncSession, recruiter_ids, manager_ids=None) -> dict[str, dict[str, str]]:
"""{"recruiters": {id: name}, "hiring_manager": {id: name}} — the two job
ownership roles resolved apart, one query each.
They are different roles on a job post, so they never share a container.
The manager side is role-checked (hiring_manager, not deleted) because it
is a single stable owner; recruiters are validated on write. Both sides
take a list, so one call serves a whole page of jobs.
"""
data: dict[str, dict[str, str]] = {"recruiters": {}, "hiring_manager": {}}
rids = {u for u in (recruiter_ids or []) if u}
if rids:
result = await session.execute(select(cls.id, cls.name).where(cls.id.in_(rids)))
data["recruiters"] = {str(uid): name for uid, name in result.all()}
mids = {u for u in (manager_ids or []) if u}
if mids:
result = await session.execute(
select(cls.id, cls.name)
.join(Roles, Roles.id == cls.role_id)
.where(
cls.id.in_(mids),
Roles.role_name == EnumRoles.HIRING_MANAGER.value,
cls.is_deleted == False, # noqa: E712
)
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}
)
data["hiring_manager"] = {str(uid): name for uid, name in result.all()}
return data
@classmethod

View File

@ -121,11 +121,23 @@ const CANDIDATES = [
let profilePayload = null
const REQUESTS = []
/** Stand-in for the API's search offset limit, so paging is exercised
against a server that really returns one page and a total. */
function serveProfile(url) {
const params = new URL(url, 'http://localhost').searchParams
const term = (params.get('search') || '').toLowerCase()
const top = Number(params.get('top') || 0)
const skip = Number(params.get('skip') || 0)
const matching = profilePayload.candidates.filter((c) => !term || String(c.name).toLowerCase().includes(term))
const page = top ? matching.slice(skip, skip + top) : matching.slice(skip)
return { data: { ...profilePayload, total: matching.length, candidates: page }, total: matching.length, status_code: 200 }
}
globalThis.fetch = async (input) => {
const url = String(input?.url ?? input)
REQUESTS.push(url)
const body = url.includes('/jobs/profile/fetch')
? { data: profilePayload, total: 1, status_code: 200 }
? serveProfile(url)
: { data: [], status_code: 200 }
return { ok: true, status: 200, statusText: 'OK', text: async () => JSON.stringify(body) }
}
@ -235,6 +247,77 @@ try {
await m.selectOption(container.querySelector('select[aria-label="Match band"]'), 'Weak Match')
check('band filter narrows to that band', names().join('|') === 'Bilal Ahmed', names().join('|'))
// ------------------------------------------------ search / top go to the API
const profileCallsNow = () => REQUESTS.filter((u) => u.includes('/jobs/profile/fetch'))
const lastParams = () => new URL(profileCallsNow().at(-1), 'http://localhost').searchParams
check('first request sends the default size as top=50', new URL(profileCallsNow()[0], 'http://localhost').searchParams.get('top') === '50')
check('first request sends no search', !new URL(profileCallsNow()[0], 'http://localhost').searchParams.has('search'))
check('first request sends no limit', !new URL(profileCallsNow()[0], 'http://localhost').searchParams.has('limit'))
const before = profileCallsNow().length
await m.type(container.querySelector('.toolbar-search input'), 'sana')
check('typing a search re-requests the profile', profileCallsNow().length > before)
check('search is sent as search=', lastParams().get('search') === 'sana', lastParams().get('search'))
check('search keeps the size', lastParams().get('top') === '50')
await m.selectOption(container.querySelector('.page-size-select'), '10')
check('Show changes top', lastParams().get('top') === '10', lastParams().get('top'))
check('Show keeps the search', lastParams().get('search') === 'sana')
await m.type(container.querySelector('.toolbar-search input'), ' ')
check('blank search is not sent', !lastParams().has('search'))
await m.unmount()
container.remove()
// ------------------------------------------------ page arrows
// 120 candidates: two full pages of 50 and a short third page of 20.
const PAGED_ID = '77777777-2222-3333-4444-555555555555'
profilePayload = {
job: jobRow({ id: PAGED_ID }),
suggested: 50, top_match: 0, top_score: 100,
bands: { 'Strong Match': 0, 'Potential Match': 0, 'Weak Match': 0 },
candidates: Array.from({ length: 120 }, (_, i) => candidate({
id: `p${i}`, name: `Candidate ${String(i).padStart(3, '0')}`, match_score: 100 - i, band: 'Weak Match',
})),
}
container = dom.window.document.createElement('div')
dom.window.document.body.appendChild(container)
m = await mod.mountRoute(`/job/${PAGED_ID}?tab=suggested`, container)
await m.settle(60)
const pageCards = () => container.querySelectorAll('.cand-card').length
const pageNames = () => [...container.querySelectorAll('.cand-name')].map((el) => el.textContent.trim())
const pageRanks = () => [...container.querySelectorAll('.cand-rank')].map((el) => el.textContent)
const lastCall = () => new URL(REQUESTS.filter((u) => u.includes('/jobs/profile/fetch')).at(-1), 'http://localhost').searchParams
check('pagination control renders under the grid', container.querySelectorAll('.pagination').length === 1)
check('first page holds 50 of 120', pageCards() === 50, `cards=${pageCards()}`)
check('page info reads 150 of 120', m.text().includes('150') && m.text().includes('of 120'))
check('first request asks for skip-less page 1', !lastCall().get('skip') || lastCall().get('skip') === '0')
check('Suggested stat shows the whole total, not the page', container.querySelector('.hero-stat .v')?.textContent === '120')
check('previous arrow is disabled on page 1', container.querySelector('.page-btn[aria-label="Previous page"]')?.disabled === true)
await m.click(container.querySelector('.page-btn[aria-label="Next page"]'))
check('next arrow requests skip=50', lastCall().get('skip') === '50', lastCall().get('skip'))
check('next arrow keeps top=50', lastCall().get('top') === '50')
check('page 2 starts where page 1 stopped', pageNames()[0] === 'Candidate 050', pageNames()[0])
check('ranks continue across pages', pageRanks()[0] === '51' && pageRanks().at(-1) === '100', pageRanks()[0])
await m.click(container.querySelector('.page-btn[aria-label="Last page"]'))
check('last page requests skip=100', lastCall().get('skip') === '100')
check('last page holds the remaining 20', pageCards() === 20, `cards=${pageCards()}`)
check('page info reads 101120 of 120', m.text().includes('101120'))
check('next arrow is disabled on the last page', container.querySelector('.page-btn[aria-label="Next page"]')?.disabled === true)
await m.click(container.querySelector('.page-btn[aria-label="Previous page"]'))
// Page 2 was fetched already, so React Query may serve it from cache without a
// new request — assert what is on screen, not the last URL.
check('previous arrow steps back to page 2', pageNames()[0] === 'Candidate 050' && m.text().includes('51100'), pageNames()[0])
await m.type(container.querySelector('.toolbar-search input'), 'Candidate 11')
check('a new search goes back to page 1', !lastCall().get('skip') || lastCall().get('skip') === '0', lastCall().get('skip'))
check('search narrows the total', m.text().includes('of 10'), m.text().match(/of \d+/)?.[0])
await m.unmount()
container.remove()

View File

@ -118,9 +118,21 @@ export async function mountRoute(path, container) {
})
await settle()
}
// React tracks an input's value through the native setter; assigning
// el.value directly is invisible to onChange, so go through the prototype.
const type = async (el, value) => {
if (!el) throw new Error('type: element not found')
const win = el.ownerDocument.defaultView
await act(async () => {
Object.getOwnPropertyDescriptor(win.HTMLInputElement.prototype, 'value').set.call(el, value)
el.dispatchEvent(new win.Event('input', { bubbles: true }))
})
await settle()
}
return {
settle,
type,
click,
selectOption,
html: () => container.innerHTML,

View File

@ -217,8 +217,10 @@ export function setStatus(jobPostId, status) {
* candidates (newest ats_results row per person, best score first) and the
* Suggested / Top Match header stats.
*/
export function fetchProfile(jobPostId) {
return request('/jobs/profile/fetch', { params: { job_post_id: jobPostId } })
export function fetchProfile(jobPostId, { search, top, limit, skip } = {}) {
return request('/jobs/profile/fetch', {
params: { job_post_id: jobPostId, search, top, limit, skip },
})
}
/** ats_results.band labels, best first — the backend's MATCH_BANDS. */

View File

@ -87,7 +87,7 @@ export const qk = {
list: (p = {}) => ['jobs', 'list', p],
requisitionStatuses: () => ['jobs', 'requisition-statuses'],
statusHistory: (id) => ['jobs', 'status-history', id],
profile: (id) => ['jobs', 'profile', id],
profile: (id, p = {}) => ['jobs', 'profile', id, p],
stats: (p = {}) => ['jobs', 'stats', p],
},
talent: {

View File

@ -5,17 +5,19 @@
One request, GET /jobs/profile/fetch, feeds the whole page: the requisition
row, the suggested candidates and the Suggested / Top Match header stats.
Suggested = the newest ats_results score per person for this job; Top Match
= how many of them sit in the Strong Match band. Sorting, band filter and
search run client-side over that one list.
= how many of them sit in the Strong Match band. Search and the "Show" size
go to the API as `search` / `top`; band filter and sort then run client-side
over the rows that come back.
Tabs: Details · Suggested Candidates · History (?tab= deep-links a tab).
============================================================ */
import { useMemo, useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Tabs } from '../ui/Tabs'
import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
@ -27,9 +29,20 @@ import { avatarColor, initials as initialsOf } from '../data/seed'
import * as jobsApi from '../api/jobs'
import * as assignmentsApi from '../api/assignments'
import * as offersApi from '../api/offers'
import { EditJobForm, JobCover, JobHistory, JobOwnership, SECTION_LABEL, deptLabel, deptValue } from './Jobs'
import { EditJobForm, JobCover, JobHistory, JobOwnership, SECTION_LABEL } from './Jobs'
import { MiniRing, ScoredCandidateDetail, displayName } from './JobCandidates'
/* The department name as the hero line and the Details tab show it. Kept local
rather than imported from Jobs.jsx so this page does not break when that
screen's helpers are reshuffled. */
function deptValue(j) {
return String(j.requisitionDepartment || j.department || '').trim()
}
function deptLabel(j) {
return deptValue(j) || '—'
}
const TAB_KEYS = ['details', 'suggested', 'history']
const SORTS = [
@ -59,11 +72,20 @@ export default function JobProfile() {
setSearchParams(params, { replace: true })
}
// Suggested-list request params. They live on the page, not in the tab,
// because the one profile call carries them.
const [search, setSearch] = useState('')
const [top, setTop] = useState(DEFAULT_PAGE_SIZE)
const [skip, setSkip] = useState(0)
const params = { search: search.trim() || undefined, top, skip }
const profileQuery = useQuery({
queryKey: qk.jobs.profile(jobId),
queryFn: async () => (await jobsApi.fetchProfile(jobId))?.data ?? null,
queryKey: qk.jobs.profile(jobId, params),
queryFn: async () => (await jobsApi.fetchProfile(jobId, params))?.data ?? null,
enabled: Boolean(jobId),
retry: false,
// A new search or size must not blank the page already on screen.
placeholderData: keepPreviousData,
})
const profile = profileQuery.data
const job = useMemo(() => (profile?.job ? jobsApi.toJobView(profile.job) : null), [profile])
@ -201,7 +223,7 @@ export default function JobProfile() {
</div>
<div className="job-hero-actions">
<div className="hero-stat">
<div className="v">{profile.suggested ?? 0}</div>
<div className="v">{profile.total ?? profile.suggested ?? 0}</div>
<div className="l">Suggested</div>
</div>
<div
@ -246,14 +268,30 @@ export default function JobProfile() {
onChange={setTab}
tabs={[
{ key: 'details', label: 'Details' },
{ key: 'suggested', label: 'Suggested Candidates', count: profile.suggested || undefined },
{ key: 'suggested', label: 'Suggested Candidates', count: (profile.total ?? profile.suggested) || undefined },
{ key: 'history', label: 'History', count: historyCount || undefined },
]}
/>
<div className="tab-pane active">
{tab === 'details' && <JobDetailsTab job={job} canEdit={canEdit} />}
{tab === 'suggested' && <SuggestedCandidatesTab job={job} profile={profile} candidates={suggested} />}
{tab === 'suggested' && (
<SuggestedCandidatesTab
job={job}
profile={profile}
candidates={suggested}
search={search}
setSearch={(v) => { setSearch(v); setSkip(0) }}
top={top}
skip={skip}
setSkip={setSkip}
setTop={(n) => {
const page = pageAfterSizeChange(Math.floor(skip / top) + 1, profile.total ?? 0, n)
setTop(n)
setSkip((page - 1) * n)
}}
/>
)}
{tab === 'history' && <JobHistory historyQuery={historyQuery} statusQuery={statusQuery} offersQuery={offersQuery} />}
</div>
</div>
@ -327,31 +365,27 @@ function JobDetailsTab({ job: j, canEdit }) {
)
}
function SuggestedCandidatesTab({ job, profile, candidates }) {
function SuggestedCandidatesTab({ job, profile, candidates, search, setSearch, top, skip, setSkip, setTop }) {
const navigate = useNavigate()
const [q, setQ] = useState('')
const [band, setBand] = useState('')
const [sort, setSort] = useState('score')
const [viewing, setViewing] = useState(null)
// Search already ran on the server; band and sort apply to what came back.
const list = useMemo(() => {
const term = q.trim().toLowerCase()
let rows = candidates.filter((c) => {
if (band && c.band !== band) return false
if (!term) return true
const hay = [
c.name, c.email, c.currentTitle, c.currentCompany,
c.matchedSkills.join(' '), c.optionalMatched.join(' '),
].filter(Boolean).join(' ').toLowerCase()
return hay.includes(term)
})
let rows = candidates.filter((c) => !band || c.band === band)
if (sort === 'name') rows = [...rows].sort((a, b) => a.name.localeCompare(b.name))
else if (sort === 'recent') rows = [...rows].sort((a, b) => (b.scoredAt?.getTime() ?? 0) - (a.scoredAt?.getTime() ?? 0))
else rows = [...rows].sort((a, b) => (b.score ?? -1) - (a.score ?? -1))
return rows
}, [candidates, q, band, sort])
}, [candidates, band, sort])
const scored = candidates.filter((c) => c.score != null).length
// Paging comes from the API: `total` counts every match, the page holds `top`.
const total = profile.total ?? candidates.length
const pages = Math.max(1, Math.ceil(total / top))
const page = Math.floor(skip / top) + 1
const from = total === 0 ? 0 : skip + 1
const to = Math.min(skip + candidates.length, total)
const bands = profile.bands || {}
function open(c) {
@ -365,14 +399,14 @@ function SuggestedCandidatesTab({ job, profile, candidates }) {
return (
<>
<div className="cand-sub">
{profile.suggested} candidate{profile.suggested === 1 ? '' : 's'} suggested · {scored} scored · vs {job.title}
{total} candidate{total === 1 ? '' : 's'} suggested · vs {job.title}
{sort === 'score' && <span className="auto-tag">Sorted: Best Worst</span>}
</div>
<div className="toolbar">
<div className="toolbar-search">
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search name, skill, company…" />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search name, email, title, company…" />
</div>
<select className="select" aria-label="Match band" value={band} onChange={(e) => setBand(e.target.value)}>
<option value="">All results</option>
@ -395,7 +429,7 @@ function SuggestedCandidatesTab({ job, profile, candidates }) {
</div>
{list.length === 0 ? (
candidates.length === 0 ? (
candidates.length === 0 && !search.trim() ? (
<EmptyState icon="users" title="No suggested candidates yet">
Candidates appear here once their CVs are ATS-scored against this job.
</EmptyState>
@ -405,11 +439,26 @@ function SuggestedCandidatesTab({ job, profile, candidates }) {
) : (
<div className="grid g-3">
{list.map((c, i) => (
<SuggestedCard key={c.id} c={c} rank={sort === 'score' ? i + 1 : null} onOpen={() => open(c)} />
<SuggestedCard key={c.id} c={c} rank={sort === 'score' ? skip + i + 1 : null} onOpen={() => open(c)} />
))}
</div>
)}
{total > 0 && (
<Pagination
from={from}
to={to}
total={total}
page={page}
pages={pages}
setPage={(p) => setSkip((p - 1) * top)}
pageButtons={pageWindow(page, pages)}
pageSize={top}
pageSizeMax={100}
onPageSizeChange={setTop}
/>
)}
{viewing && (
<ScoredCandidateDetail
candidate={{