diff --git a/backend/alembic_setup.py b/backend/alembic_setup.py index 61f1581..838e0bd 100644 --- a/backend/alembic_setup.py +++ b/backend/alembic_setup.py @@ -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() diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 107cc93..ff9df68 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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.""" diff --git a/backend/job/app.py b/backend/job/app.py index 064f76e..64bd20d 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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: diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 36ff9f9..0853131 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -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()) diff --git a/backend/job/job_post/plugins.py b/backend/job/job_post/plugins.py index e28119e..9a688b1 100644 --- a/backend/job/job_post/plugins.py +++ b/backend/job/job_post/plugins.py @@ -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 {}, + } diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index e49a18a..aae3e9a 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -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, } diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 6b49504..8ead2e3 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -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 diff --git a/backend/migrations/seed/rbac_ingest.sql b/backend/migrations/seed/rbac_ingest.sql new file mode 100644 index 0000000..a1c849c --- /dev/null +++ b/backend/migrations/seed/rbac_ingest.sql @@ -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; diff --git a/backend/role/plugins.py b/backend/role/plugins.py new file mode 100644 index 0000000..42b2b6d --- /dev/null +++ b/backend/role/plugins.py @@ -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 diff --git a/backend/users/models.py b/backend/users/models.py index 37521c5..b182db1 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -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 diff --git a/frontend/job-profile.test.mjs b/frontend/job-profile.test.mjs index 9602c37..81cae51 100644 --- a/frontend/job-profile.test.mjs +++ b/frontend/job-profile.test.mjs @@ -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() diff --git a/frontend/src/__smoke__/entry.jsx b/frontend/src/__smoke__/entry.jsx index e7e9f2f..c4f43c2 100644 --- a/frontend/src/__smoke__/entry.jsx +++ b/frontend/src/__smoke__/entry.jsx @@ -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, diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js index 310bfb1..acbb653 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -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. */ diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 1d2e498..8b26b1c 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -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: { diff --git a/frontend/src/screens/JobProfile.jsx b/frontend/src/screens/JobProfile.jsx index 4aec16a..7abb797 100644 --- a/frontend/src/screens/JobProfile.jsx +++ b/frontend/src/screens/JobProfile.jsx @@ -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() {