Edit_Job_Profile
parent
1fa25f2854
commit
43852e22e0
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 1–50 of 120', m.text().includes('1–50') && 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 101–120 of 120', m.text().includes('101–120'))
|
||||
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('51–100'), 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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
Loading…
Reference in New Issue