From 3838634e8289329cb4f0ab426023de34cdc70576 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 16 Sep 2026 13:39:48 +0500 Subject: [PATCH] commit with reqxdepoart realtion --- backend/LLM_CONTEXT_PROMPT.md | 400 +++++++++++++----- backend/candidate_forms/app.py | 4 +- backend/candidate_forms/enums.py | 3 +- backend/candidate_forms/models.py | 16 +- backend/candidate_forms/plugins.py | 66 ++- backend/candidate_forms/serializers.py | 2 + backend/candidate_forms/views.py | 69 +-- backend/department/app.py | 2 + backend/department/models.py | 5 + .../manual/041_requisition_department_id.sql | 53 +++ frontend/src/screens/Jobs.jsx | 86 +--- frontend/src/screens/Requisitions.jsx | 15 +- frontend/src/ui/DepartmentSelect.jsx | 68 +++ frontend/src/ui/SearchSelect.jsx | 94 ++++ 14 files changed, 614 insertions(+), 269 deletions(-) create mode 100644 backend/migrations/manual/041_requisition_department_id.sql create mode 100644 frontend/src/ui/DepartmentSelect.jsx create mode 100644 frontend/src/ui/SearchSelect.jsx diff --git a/backend/LLM_CONTEXT_PROMPT.md b/backend/LLM_CONTEXT_PROMPT.md index 5cf5cb5..9db103e 100644 --- a/backend/LLM_CONTEXT_PROMPT.md +++ b/backend/LLM_CONTEXT_PROMPT.md @@ -4,127 +4,311 @@ Copy everything below the line into any LLM session before asking it to write or --- -You are coding inside **HR-ATS-Portal** (`backend/`). You must follow this house style **exactly**. Mirror neighboring files. Do not invent alternate patterns, layers, or response shapes. Prefer matching existing code over “cleaner” industry defaults. +You are coding inside **HR-ATS-Portal** (`backend/`). Follow this house style **exactly**. Mirror the reference flow below line-for-line in shape. Do not invent layers, response shapes, flags, guards, or "defensive" checks the reference does not have. Write **no more and no less** than the reference does for the same job. -## Goal +## Reference flow (the canonical example) -Every change must look like it was written by the same author as `backend/users/` and `backend/inbox/`. +`POST /forms/requisition/create` in `backend/candidate_forms/`. Every new endpoint copies this shape. + +### 1. `enums.py` — enums + nested payload blocks + +```python +class EmploymentType(str,Enum): + PERMANENT = "permanent" + CONTRACT = "contract" + +class Position(BaseModel): + department_id:uuid.UUID + department:Optional[str] + title:Optional[str] + type:Optional[EmploymentType] + period_from:Optional[date]=None +``` + +- `str, Enum` classes and the nested `BaseModel` blocks a request body is built from live here. + +### 2. `app.py` — request models inline + thin route + +```python +class RequisitionFormCreate(BaseModel): + form_type: str = "requisition" + position:Position + replacement_for:Optional[ReplacementFor] + initiated_by:Optional[str] + approved_by_hr:Optional[bool] + + +class RequisitionFormUpdate(BaseModel): + position:Optional[Position]=None + initiated_by:Optional[str]=None + approved_by_hr:Optional[bool]=None + + +@router.post("/forms/requisition/create") +async def create_requisition_form( + payload: RequisitionFormCreate, + current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_CREATE)), + session:AsyncSession=Depends(get_session), +): + try: + service = RequisitionForm(session=session) + data = await service.create_form(payload.model_dump(exclude_unset=True), current_user) + return JSONResponse(content={"data": data, "status_code": 200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) +``` + +- `router = APIRouter()`; mounted in `main.py` via `app.include_router(...)`. +- Paths are verb-in-path: `//create`, `//fetch`, `//update`, `//delete`, `//search`. No `/api/v1`, no REST-resource-only paths. +- Methods: `post` create, `get` fetch/search/count, `patch` update, `delete` delete. +- Param order: `payload` → `current_user` → query params → `session`. +- Record id comes as a query param: `form_id:str=Query(...)` (required) or `Query(None)` (fetch one-or-all). +- Body goes to the service as `payload.model_dump(exclude_unset=True)`. +- Protection: `current_user:dict=Depends(require_permission(PermissionTag.X))`. Several tags: `require_permission(A, B, require_all=False)`. +- The route body is **only** the try block above: build service, one `await`, `JSONResponse`. Nothing else. +- Envelope: `{"data": data, "status_code": 200}`. List with count: add `"total"`. A scalar goes inside `data` as a dict (`{"data":{"open":data},...}`). +- Request models stay inline in `app.py` — Create has required fields without defaults, Update has every field `Optional[...]=None`. + +### 3. `views.py` — service class + +```python +class RequisitionForm: + def __init__(self, session: AsyncSession): + self.session = session + + async def create_form(self, payload, current_user): + payload["position"]["department_id"] = _as_uuid(payload["position"]["department_id"]) + payload["created_by"] = _user_id(current_user) + row = await Requisition.insert_form(self.session, payload) + return serialize_requisition(row) + + async def update_form(self, form_id, payload, current_user): + _user_id(current_user) + row = await Requisition.get_form_by_id(self.session, form_id) + if not row: + raise HTTPException(status_code=404, detail="Form not found") + if not payload: + raise HTTPException(status_code=400, detail="No fields to update") + updated = await Requisition.update_form(self.session, form_id, payload) + if not updated: + raise HTTPException(status_code=404, detail="Form not found") + return serialize_requisition(updated) + + async def get_form_by_id(self, form_id, current_user): + created_by = None if is_admin(current_user) else _user_id(current_user) + if form_id: + row = await Requisition.get_form_by_id(self.session, record_id=form_id, created_by=created_by) + if not row: + raise HTTPException(status_code=404, detail="Form not found") + return serialize_requisition(row) + rows = await Requisition.get_form_by_id(self.session, created_by=created_by) + return [serialize_requisition(r) for r in rows] +``` + +- One class per resource, `__init__(self, session: AsyncSession)` only. +- Method parameters are **untyped**. +- A create method is: adjust the payload dict in place (coerce ids with plugin helpers, stamp `created_by`) → call **one** model classmethod → return the serializer. That is the whole method. +- Business errors: `raise HTTPException(status_code=..., detail="...")` — 404 not found, 400 empty update, 401 auth, 422 invalid value. +- Scope reads with `created_by = None if is_admin(current_user) else _user_id(current_user)`. +- Always return serialized dicts / lists of dicts, never ORM rows. + +### 4. `plugins.py` — shared helpers + +```python +def _as_uuid(value): + if value in (None, ""): + return None + try: + return uuid.UUID(str(value)) + except (TypeError, ValueError): + return None + + +def _user_id(current_user): + if not current_user or not current_user.get("id"): + raise HTTPException(status_code=401, detail="Not authenticated") + uid = _as_uuid(current_user["id"]) + if uid is None: + raise HTTPException(status_code=401, detail="Invalid user id") + return uid +``` + +- Every helper function and constant (`_as_uuid`, `_user_id`, `_aware`, validators, `FORM_TYPES`, definitions payloads) lives in `/plugins.py` and is imported by name into `views.py`. Never define helpers at the top of `views.py`, `models.py`, `app.py`, or `serializers.py`. +- Helpers may raise `HTTPException` when they validate request data. +- Before writing a helper, check the domain's `plugins.py` and reuse what exists. + +### 5. `models.py` — SQLModel table + classmethod accessors + +```python +class Requisition(SQLModel, table=True): + __tablename__ = "requisitions" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id") + department_ref: Optional["Department"] = Relationship( + back_populates="requisitions", + sa_relationship_kwargs={"uselist": False, "lazy": "selectin"}, + ) + position_title: Optional[str] = None + created_by: Optional[uuid.UUID] = Field(foreign_key="users.id") + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + is_deleted: bool = Field(default=False) + + @classmethod + async def get_form_by_id(cls, session: AsyncSession, record_id=None, created_by=None): + qry = select(cls).where(cls.is_deleted == False) # noqa: E712 + if created_by is not None: + qry = qry.where(cls.created_by == created_by) + if record_id not in (None, ""): + try: + uid = uuid.UUID(str(record_id)) + except (TypeError, ValueError): + return None + qry = qry.where(cls.id == uid) + result = await session.execute(qry) + return result.scalars().first() + result = await session.execute(qry.order_by(cls.created_at.desc(),cls.id.desc())) + return list(result.scalars().all()) + + @classmethod + async def insert_form(cls, session: AsyncSession, fields: dict): + position = fields.get("position") if fields.get("position") else {} + row = cls( + department_id=position.get("department_id") if position.get("department_id") else None, + position_title=position.get("title") if position.get("title") else None, + employment_type=EmploymentType(position.get("type")) if position.get("type") else None, + jd_available=position.get("jd_available") if position.get("jd_available") is not None else None, + initiated_by=fields.get("initiated_by") if fields.get("initiated_by") else None, + created_by=fields.get("created_by") if fields.get("created_by") else None, + ) + session.add(row) + await session.commit() + return await cls.get_form_by_id(session, row.id) + + @classmethod + async def update_form(cls, session: AsyncSession, record_id, fields: dict): + row = await cls.get_form_by_id(session, record_id) + if not row: + return None + if "position" in fields: + position = fields.get("position") if fields.get("position") else {} + if "title" in position: + row.position_title = position.get("title") if position.get("title") else None + if "initiated_by" in fields: + row.initiated_by = fields.get("initiated_by") if fields.get("initiated_by") else None + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def soft_delete_form(cls, session: AsyncSession, record_id): + row = await cls.get_form_by_id(session, record_id) + if not row: + return None + row.is_deleted = True + row.updated_at = _now() + session.add(row) + await session.commit() + return row +``` + +- Standard columns on every table: `id` uuid4 PK, `created_by` FK `users.id`, `created_at` / `updated_at` tz-aware via `_now`, `is_deleted`. +- All DB access is `@classmethod async def` taking `session` first. No free functions, no repository class. +- **One accessor does one whole job.** Insert maps the full (nested) payload dict to columns, adds, commits, and re-fetches in the same method. Never split a create into several helper calls or a second "create child" function when the mapping fits inline. +- **One reader for one-or-many:** `get_form_by_id` returns a row when `record_id` is given, a list otherwise. Do not add a separate `fetch_all`. +- Column mapping idiom: `x=src.get("k") if src.get("k") else None`; booleans use `is not None`; enums wrap `Enum(value)`. +- Update idiom: `if "k" in fields:` per field (nested blocks: `if "block" in fields:` then per-key), then `row.updated_at = _now()`, add, commit, refresh, return row. Return `None` when missing — the view raises. +- Soft delete only: `is_deleted = True` (+ `updated_at`). Reads always filter `cls.is_deleted == False # noqa: E712`. +- Cross-domain relations: model name in quotes, import under `if TYPE_CHECKING:`, `back_populates` on both sides, and a bottom-of-file `import .models as __models # noqa: E402, F401`. +- Commits happen inside write accessors; views never commit. + +### 6. `serializers.py` — hand-built dicts + +```python +def _date(value): + return value.isoformat() if value else None + + +def serialize_requisition(row) -> dict: + return { + "id": str(row.id) if row.id else None, + "position": { + "department_id": str(row.department_id) if row.department_id else None, + "title": row.position_title, + "date": _date(row.date), + "type": _enum(row.employment_type), + }, + "initiated_by": row.initiated_by, + "created_by": str(row.created_by) if row.created_by 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, + } +``` + +- `serialize_(row) -> dict`. UUIDs `str(...) if ... else None`, dates `.isoformat()`, enums via `_enum`. +- The response shape mirrors the request shape (nested blocks back out as nested dicts), so the frontend sends and receives the same structure. +- No DB access, no Pydantic response models. Never include passwords. + +### 7. Schema change → manual SQL migration + +- New column / FK / index: add `backend/migrations/manual/_.sql` with a header comment explaining why, idempotent DDL (`ADD COLUMN IF NOT EXISTS`, guarded `ADD CONSTRAINT`, `CREATE INDEX IF NOT EXISTS`), schema `app.`. Never edit an already-applied migration file. ## Package layout (every domain) ``` backend// - app.py # routes only — HTTP in/out - views.py # service class — business logic - models.py # SQLModel table + classmethod DB accessors - serializers.py # hand-rolled dict builders (no Pydantic response models) - plugins.py # pure helpers (hash, JWT, clean payload) — NO FastAPI imports - permissions.py # OAuth2 scheme + Depends aliases (auth domains only) + app.py # routes + inline request models + views.py # service class + models.py # SQLModel table + classmethod accessors + serializers.py # serialize_* dict builders + enums.py # str Enums + nested request BaseModel blocks + plugins.py # helpers + constants + permissions.py # auth domains only ``` -- Bare `router = APIRouter()`; mount in `main.py` with `app.include_router(...)`. -- No package `__init__.py`. Run from `backend/` so imports are top-level (`users.app`, `db_setup`). -- Non-DB config: module-level `load_dotenv()` + `os.getenv(...)`. Do **not** extend `db_setup.Settings` for app secrets. +- No `__init__.py`. Run from `backend/`; imports are top-level (`candidate_forms.views`, `db_setup`, `users.permissions`). +- Config: module-level `load_dotenv()` + `os.getenv(...)`. Do not extend `db_setup.Settings` for app secrets. -## Layer duties (non-negotiable) +## Layer duties -| Layer | Owns | Must NOT do | +| Layer | Owns | Must NOT | |---|---|---| -| `app.py` | Routes, inline request Pydantic models, `JSONResponse`, HTTP token envelope via serializers, inject `session` / `CurrentUser` | Business rules, SQL, JWT crypto beyond calling plugin functions | -| `views.py` | Business checks, call models, raise `HTTPException`, return ORM user (auth) or serialized dict (CRUD) | Call `serialize_token` or build login HTTP payloads | -| `models.py` | Fields, queries, inserts/updates/soft-delete, `selectinload` when needed | HTTPException, FastAPI, serializers | -| `serializers.py` | `serialize_*` → plain `dict` | DB, Depends | -| `plugins.py` | Pure helpers; raise library errors (`jwt.*`) | Import FastAPI / raise HTTPException | -| `permissions.py` | `OAuth2PasswordBearer`, `get_current_user`, `CurrentUser` alias, `require_permission` | Route handlers | - -## Exact route pattern (`app.py`) - -- Paths are verb-in-path: `/users/create`, `/users/fetch`, `/users/login` — **not** `/auth/token`, not REST-resource-only. -- Standard wrapper on every handler: - -```python -try: - service=User(session=session) - data=await service.some_method(...) - return JSONResponse(content={"data":data,"status_code":200}) -except HTTPException: - raise -except Exception as e: - raise HTTPException(status_code=500,detail=str(e)) -``` - -- List: `{"data":items,"total":total,"status_code":200}`. By id: include `"total":1`. -- Login / refresh — **service returns ORM user**; route mints tokens and serializes: - -```python -user=await service.authenticate_user(form_data.username,form_data.password) -tokens=serialize_token(create_access_token(user),create_refresh_token(user),user) -return JSONResponse(content={**tokens,"status_code":200}) -``` - -- Request body models stay **inline in `app.py`** (`UserCreate`, `UserUpdate`, `TokenRefresh`). Never move them into `serializers.py`. -- Use `session: AsyncSession = Depends(get_session)` by default. Use `Annotated` only when required (`OAuth2PasswordRequestForm`, `CurrentUser` before other defaulted params). -- Preserve tight local spacing: `service=User(session=session)`, `detail=str(e)`. Do not pretty-reformat unrelated code. - -## Exact service pattern (`views.py`) - -```python -class User: - def __init__(self,session:AsyncSession): - self.session=session - - async def create_user(self,payload): - ... - return serialize_user(user) -``` - -- Leave service method parameters untyped (match existing). -- Raise `HTTPException(status_code=...,detail="...")` for domain errors. -- `authenticate_user` / `refresh_access_token` return the **Users ORM instance only**. -- If you loaded via an accessor that does not `selectinload(role)`, re-fetch with `get_user_by_id` before serialization that touches `user.role`. (`get_user_by_email` and `get_user_by_id` both eager-load `role` today.) - -## Exact model pattern (`models.py`) - -- SQLModel `table=True`; accessors as `@classmethod async def`. -- Soft delete sets `is_deleted=True` and `is_active=False`. -- `selectinload` relations that serializers read. -- Commits happen inside write accessors (existing convention). - -## Serializers - -- Hand-built dicts only. `str(uuid)`, `.isoformat()` for datetimes. Never include `password`. -- Token response: OAuth2 fields at **root** (`access_token`, `refresh_token`, `token_type`, `expires_in`); user record under `data`. - -## Auth (when touching users auth) - -- PyJWT access + refresh with a `type` claim; `decode_token(..., expected_type=...)` rejects mismatches. -- Protect `/users/*` with `current_user: CurrentUser` except `/users/login` and `/users/refresh`. Prefer `Depends(require_permission(...))` on mutating/list routes that need a specific tag; keep `/users/me` on plain `CurrentUser` so users can discover a missing-role state. -- `get_current_user`: decode access → DB by `sub` → reject missing/deleted/inactive → return `serialize_user(user, with_permissions=True)`. -- Login: `OAuth2PasswordRequestForm` (username = email). `tokenUrl="users/login"` (no leading slash). -- JWT `iat`/`exp` use `datetime.now(timezone.utc)` only — never naive `datetime.now()`. - -## Dependencies / env - -- Add pins to `backend/requirements.txt` under banner comments with a trailing `# why` comment. -- Put secrets in `backend/.env`; keep key names in `backend/.env.example`. - -## Hard bans - -1. No repository / use-case / DTO layers beyond inline request models. -2. No Pydantic response models; no alternate envelopes; no `/api/v1` prefix. -3. No `serialize_token` inside `views.py`. -4. No FastAPI imports in `plugins.py`. -5. No drive-by refactors, renames, or whole-file reformats. -6. RBAC exists in `users/permissions.py`; do not invent a second scheme. -7. Do not edit unrelated domains (`inbox/` vs `users/`) unless asked. -8. Do not add `__init__.py` to make packages. +| `app.py` | Routes, inline request models, `Depends`, `JSONResponse` envelope | Business rules, SQL, helper functions | +| `views.py` | Payload prep, business checks, `HTTPException`, call model, return serializer | SQL, commits, helper definitions | +| `models.py` | Columns, relationships, queries, insert/update/soft-delete + commit | `HTTPException`, serializers | +| `serializers.py` | `serialize_*` → `dict` | DB, `Depends` | +| `enums.py` | Enums, nested request blocks | Logic | +| `plugins.py` | Every helper and constant | Routes, DB writes | ## Workflow when adding an endpoint -1. Model accessor (if DB). -2. Service method in `views.py`. -3. `serialize_*` if new shape. -4. Route in `app.py` with the standard try/except + `JSONResponse`. -5. Add `current_user: CurrentUser` or `Depends(require_permission(...))` if the route is protected. +1. Migration SQL if the schema changes. +2. Columns + one classmethod accessor in `models.py`. +3. Enum / nested block in `enums.py` if the body has one. +4. Helper in `plugins.py` only if a view needs one that does not already exist. +5. `serialize_*` in `serializers.py` if the shape is new. +6. Service method in `views.py`. +7. Inline request model + route in `app.py` with the exact try/except + `JSONResponse` wrapper and `require_permission`. -Before finishing, re-read the touched files and confirm they still match a sibling file’s structure, naming, spacing, and response shape. +## Auth (only when touching `users/`) + +- Login / refresh: service returns the **Users ORM row**; the route mints tokens and calls `serialize_token(...)` — never inside `views.py`. Tokens at the root, user under `data`. +- PyJWT access + refresh with a `type` claim; `iat`/`exp` use `datetime.now(timezone.utc)`. +- RBAC lives in `users/permissions.py` (`require_permission`, `is_admin`, `CurrentUser`). Do not invent a second scheme. + +## Hard bans + +1. No extra flags, params, guards, try/excepts, logging, or validations the reference flow does not have. +2. No splitting one create/update into multiple helper functions or extra model calls when one classmethod does it. +3. No helpers defined outside `plugins.py`; no duplicated helpers — import the existing one. +4. No repository / use-case / DTO layers; no Pydantic response models; no alternate envelopes; no `/api/v1`. +5. No hard deletes. +6. No drive-by refactors, renames, reformatting, or edits to unrelated domains. +7. No `__init__.py`. +8. Dependencies: pin in `backend/requirements.txt` with a `# why` comment; secret names in `backend/.env.example`. + +Before finishing, put the new code next to the reference flow above and remove anything the reference would not have. diff --git a/backend/candidate_forms/app.py b/backend/candidate_forms/app.py index a7d61f0..b314a90 100644 --- a/backend/candidate_forms/app.py +++ b/backend/candidate_forms/app.py @@ -78,7 +78,7 @@ async def search_requisitions( require_all=False, ) ), - q: str | None = Query(None), + search: str | None = Query(None), top: int = Query(50, ge=1, le=100), job_post_id: uuid.UUID | None = Query(None), session: AsyncSession = Depends(get_session), @@ -91,7 +91,7 @@ async def search_requisitions( """ try: service = RequisitionForm(session=session) - data = await service.search(q, top=top, job_post_id=job_post_id) + data = await service.search(search, top=top, job_post_id=job_post_id) return JSONResponse(content={"data": data, "status_code": 200}) except HTTPException: raise diff --git a/backend/candidate_forms/enums.py b/backend/candidate_forms/enums.py index 51b0afa..443351f 100644 --- a/backend/candidate_forms/enums.py +++ b/backend/candidate_forms/enums.py @@ -2,7 +2,7 @@ from enum import Enum from pydantic import BaseModel from typing import Optional from datetime import date - +import uuid class EmploymentType(str,Enum): PERMANENT = "permanent" CONTRACT = "contract" @@ -10,6 +10,7 @@ class EmploymentType(str,Enum): INTERNEE="internee" class Position(BaseModel): + department_id:uuid.UUID department:Optional[str] title:Optional[str] date:Optional[date] diff --git a/backend/candidate_forms/models.py b/backend/candidate_forms/models.py index 15aa3f6..4b784a2 100644 --- a/backend/candidate_forms/models.py +++ b/backend/candidate_forms/models.py @@ -8,8 +8,9 @@ from sqlmodel import Field, Relationship, SQLModel, select from candidate_forms.enums import EmploymentType if TYPE_CHECKING: + from department.models import Department from job.job_post.models import JobPosts - + def _now() -> datetime: return datetime.now(timezone.utc) @@ -19,6 +20,11 @@ class Requisition(SQLModel, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) department: Optional[str] = None + department_id: Optional[uuid.UUID] = Field(default=None, foreign_key="departments.id") + department_ref: Optional["Department"] = Relationship( + back_populates="requisitions", + sa_relationship_kwargs={"uselist": False, "lazy": "selectin"}, + ) position_title: Optional[str] = None date: Optional[Date] = None date_needed: Optional[Date] = None @@ -93,7 +99,7 @@ class Requisition(SQLModel, table=True): async def search( cls, session: AsyncSession, - q: str | None = None, + search: str | None = None, *, top: int = 50, job_post_id=None, @@ -119,7 +125,7 @@ class Requisition(SQLModel, table=True): if except_uid is not None: held = held.where(JobPosts.id != except_uid) statement = statement.where(cls.id.notin_(held)) - term = (q or "").strip() + term = (search or "").strip() if term: like = f"%{term}%" statement = statement.where( @@ -163,6 +169,7 @@ class Requisition(SQLModel, table=True): referral = fields.get("refferal_by") if fields.get("refferal_by") else {} row = cls( department=position.get("department") if position.get("department") else None, + department_id=position.get("department_id") if position.get("department_id") else None, position_title=position.get("title") if position.get("title") else None, date=position.get("date") if position.get("date") else None, date_needed=position.get("date_needed") if position.get("date_needed") else None, @@ -206,6 +213,8 @@ class Requisition(SQLModel, table=True): position = fields.get("position") if fields.get("position") else {} if "department" in position: row.department = position.get("department") if position.get("department") else None + if "department_id" in position: + row.department_id = position.get("department_id") if position.get("department_id") else None if "title" in position: row.position_title = position.get("title") if position.get("title") else None if "date" in position: @@ -416,3 +425,4 @@ class CandidateForms(SQLModel, table=True): import users.models as _users_models # noqa: E402, F401 +import department.models as _department_models # noqa: E402, F401 \ No newline at end of file diff --git a/backend/candidate_forms/plugins.py b/backend/candidate_forms/plugins.py index a59fd29..3154703 100644 --- a/backend/candidate_forms/plugins.py +++ b/backend/candidate_forms/plugins.py @@ -1,11 +1,63 @@ -"""Pure helpers for the hiring forms domain — no FastAPI, no DB. +import uuid +from fastapi import HTTPException +from datetime import timezone + +def _as_uuid(value): + if value in (None, ""): + return None + try: + return uuid.UUID(str(value)) + except (TypeError, ValueError): + return None + + +def _user_id(current_user): + if not current_user or not current_user.get("id"): + raise HTTPException(status_code=401, detail="Not authenticated") + uid = _as_uuid(current_user["id"]) + if uid is None: + raise HTTPException(status_code=401, detail="Invalid user id") + return uid + + +def _aware(value): + if value is not None and getattr(value, "tzinfo", None) is None: + return value.replace(tzinfo=timezone.utc) + return value + + +def _stage_value(status) -> str: + return str(getattr(status, "value", status) or "").upper() + + +def _recommendation(form_type, value): + definition = FORM_DEFINITIONS.get(form_type) or {} + if not definition.get("has_recommendation"): + return None + if value in (None, ""): + return None + value = str(value).strip() + if value not in RECOMMENDATIONS: + raise HTTPException( + status_code=422, + detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}", + ) + return value + + +def _score_sections(form_type, sections): + try: + return normalize_sections(form_type, sections) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + +def _score_fields(form_type, fields): + try: + return normalize_fields(form_type, fields) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) -FORM_DEFINITIONS is the single authority for section/criterion/field keys AND -their on-screen labels, which reproduce the paper annexures verbatim (Annexure A -Employee Requisition Form, Annexure E Interview Evaluation Form). The frontend -renders labels from /forms/definitions, and criterion labels are denormalized -into every saved row so historical records survive future renames. -""" FORM_TYPES = ("requisition", "interview_analysis", "cultural_fit") diff --git a/backend/candidate_forms/serializers.py b/backend/candidate_forms/serializers.py index b2968de..c31b85d 100644 --- a/backend/candidate_forms/serializers.py +++ b/backend/candidate_forms/serializers.py @@ -71,6 +71,7 @@ def serialize_requisition_option(row) -> dict: "id": str(row.id) if row.id else None, "title": row.position_title, "department": row.department, + "department_id": str(row.department_id) if row.department_id else None, "label": f"{title or 'Untitled'} - {department or '—'}", } @@ -80,6 +81,7 @@ def serialize_requisition(row) -> dict: "id": str(row.id) if row.id else None, "position": { "department": row.department, + "department_id": str(row.department_id) if row.department_id else None, "title": row.position_title, "date": _date(row.date), "date_needed": _date(row.date_needed), diff --git a/backend/candidate_forms/views.py b/backend/candidate_forms/views.py index 379a508..f4f302c 100644 --- a/backend/candidate_forms/views.py +++ b/backend/candidate_forms/views.py @@ -14,6 +14,13 @@ from candidate_forms.plugins import ( combined_summary, normalize_fields, normalize_sections, + _as_uuid, + _user_id, + _aware, + _stage_value, + _recommendation, + _score_sections, + _score_fields, ) from candidate_forms.serializers import ( serialize_form, @@ -32,63 +39,6 @@ from users.permissions import is_admin, is_hiring_manager logger = logging.getLogger("candidate_forms") -def _as_uuid(value): - if value in (None, ""): - return None - try: - return uuid.UUID(str(value)) - except (TypeError, ValueError): - return None - - -def _user_id(current_user): - if not current_user or not current_user.get("id"): - raise HTTPException(status_code=401, detail="Not authenticated") - uid = _as_uuid(current_user["id"]) - if uid is None: - raise HTTPException(status_code=401, detail="Invalid user id") - return uid - - -def _aware(value): - if value is not None and getattr(value, "tzinfo", None) is None: - return value.replace(tzinfo=timezone.utc) - return value - - -def _stage_value(status) -> str: - return str(getattr(status, "value", status) or "").upper() - - -def _recommendation(form_type, value): - definition = FORM_DEFINITIONS.get(form_type) or {} - if not definition.get("has_recommendation"): - return None - if value in (None, ""): - return None - value = str(value).strip() - if value not in RECOMMENDATIONS: - raise HTTPException( - status_code=422, - detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}", - ) - return value - - -def _score_sections(form_type, sections): - try: - return normalize_sections(form_type, sections) - except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) - - -def _score_fields(form_type, fields): - try: - return normalize_fields(form_type, fields) - except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) - - class CandidateForm: def __init__(self, session: AsyncSession): self.session = session @@ -406,6 +356,7 @@ class RequisitionForm: self.session = session async def create_form(self, payload, current_user): + payload["position"]["department_id"] = _as_uuid(payload["position"]["department_id"]) payload["created_by"] = _user_id(current_user) row = await Requisition.insert_form(self.session, payload) return serialize_requisition(row) @@ -440,8 +391,8 @@ class RequisitionForm: created_by = None if is_admin(current_user) else _user_id(current_user) return await Requisition.count_open(self.session, created_by=created_by) - async def search(self, q, top=50, job_post_id=None): + async def search(self, search, top=50, job_post_id=None): rows = await Requisition.search( - self.session, q, top=top, job_post_id=job_post_id, + self.session, search=search, top=top, job_post_id=job_post_id, ) return [serialize_requisition_option(r) for r in rows] \ No newline at end of file diff --git a/backend/department/app.py b/backend/department/app.py index c987b10..2b4338f 100644 --- a/backend/department/app.py +++ b/backend/department/app.py @@ -149,6 +149,8 @@ async def fetch_department_names( PermissionTag.DEPARTMENT_VIEW, PermissionTag.JOB_BOARD_CREATE, PermissionTag.JOBS_EDIT, + PermissionTag.REQUISITIONS_CREATE, + PermissionTag.REQUISITIONS_EDIT, require_all=False, ) ), diff --git a/backend/department/models.py b/backend/department/models.py index 2a6b634..3a223e3 100644 --- a/backend/department/models.py +++ b/backend/department/models.py @@ -14,6 +14,7 @@ from sqlmodel import Field, Relationship, SQLModel, select from department.plugins import as_uuid, now_utc if TYPE_CHECKING: + from candidate_forms.models import Requisition from job.job_post.models import JobPosts class Department(SQLModel, table=True): @@ -27,6 +28,8 @@ class Department(SQLModel, table=True): # post loads its department_ref. Query JobPosts by department_id instead. job_posts: List["JobPosts"] = Relationship(back_populates="department_ref", sa_relationship_kwargs={"lazy": "noload"}) + requisitions: List["Requisition"] = Relationship(back_populates="department_ref", sa_relationship_kwargs={"lazy": "noload"}) + subtitle: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None) is_active: bool = Field(default=True) @@ -171,3 +174,5 @@ class Department(SQLModel, table=True): return {} result = await session.execute(select(cls.id, cls.name).where(cls.id.in_(uids))) return {row[0]: row[1] for row in result.all()} + +import candidate_forms.models as _candidate_forms_models # noqa: E402, F401 \ No newline at end of file diff --git a/backend/migrations/manual/041_requisition_department_id.sql b/backend/migrations/manual/041_requisition_department_id.sql new file mode 100644 index 0000000..918e062 --- /dev/null +++ b/backend/migrations/manual/041_requisition_department_id.sql @@ -0,0 +1,53 @@ +-- 041_requisition_department_id.sql +-- Restores requisitions.department_id: many requisitions -> one department. This +-- is the FK behind Requisition.department_id / Requisition.department_ref +-- (backend/candidate_forms/models.py) and Department.requisitions +-- (backend/department/models.py). department_ref/requisitions are ORM +-- relationships only — they add no column, the FK below is the whole schema +-- change, and a requisition is linked by writing department_id. +-- +-- 039 added this column and 040 dropped it again when the department link moved +-- to job_posts; job_posts.department_id from 040 stays. A new file rather than +-- an edit to either: manual migrations run once and are recorded in +-- manual_migrations, so edits to an applied file never reach an existing +-- database. Applied at startup by alembic_setup.run_manual_sql(). +-- +-- The legacy free-text requisitions.department column is kept and only read here +-- to backfill; 040 wrote the department name back into it before dropping the +-- FK, so rows linked under 039 recover their link. Unmatched rows stay NULL. + +ALTER TABLE app.requisitions + ADD COLUMN IF NOT EXISTS department_id uuid; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'fk_requisitions_department_id_departments' + ) THEN + ALTER TABLE app.requisitions + ADD CONSTRAINT fk_requisitions_department_id_departments + FOREIGN KEY (department_id) REFERENCES app.departments (id); + END IF; +END $$; + +CREATE INDEX IF NOT EXISTS ix_requisitions_department_id + ON app.requisitions (department_id); + +-- Backfill from the legacy text column, if it is still there: match a +-- department's name or short code, case-insensitive and trimmed. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'app' + AND table_name = 'requisitions' + AND column_name = 'department' + ) THEN + UPDATE app.requisitions r + SET department_id = d.id + FROM app.departments d + WHERE r.department_id IS NULL + AND lower(btrim(r.department)) IN (lower(d.name), lower(d.short_code)); + END IF; +END $$; diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index 5023c3a..aad0416 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -14,6 +14,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta import AiFieldAssist from '../ui/AiFieldAssist' import DataTable from '../ui/DataTable' import Modal from '../ui/Modal' +import SearchSelect from '../ui/SearchSelect' import PageHeader from '../ui/PageHeader' import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives' import { useToast } from '../ui/Toast' @@ -397,91 +398,6 @@ function fmtWhen(value) { return fmtDateTime(value) || null } -/** - * Searchable picker: type to filter, click a row to store the id. - * Not free-text — the value is always an option id (or '' when allowEmpty). - */ -function SearchSelect({ - options = [], - value, - onChange, - placeholder = 'Search…', - disabled = false, - loading = false, - allowEmpty = false, - emptyLabel = 'Unassigned', - error = false, - onQueryChange, -}) { - const [q, setQ] = useState('') - const [open, setOpen] = useState(false) - const root = useRef(null) - const selected = options.find((o) => String(o.id) === String(value || '')) - - useEffect(() => { - function onDoc(e) { - if (root.current && !root.current.contains(e.target)) setOpen(false) - } - document.addEventListener('mousedown', onDoc) - return () => document.removeEventListener('mousedown', onDoc) - }, []) - - useEffect(() => { - if (!onQueryChange || !open) return - onQueryChange(q) - }, [q, open, onQueryChange]) - - const term = q.trim().toLowerCase() - const filtered = onQueryChange - ? options - : options.filter((o) => { - if (!term) return true - const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase() - return hay.includes(term) - }) - - return ( -
- { setOpen(true); setQ('') }} - onChange={(e) => { setQ(e.target.value); setOpen(true) }} - /> - {open && !disabled && !loading && ( -
- {allowEmpty && ( - - )} - {filtered.length === 0 && ( -
No matches
- )} - {filtered.map((o) => ( - - ))} -
- )} -
- ) -} - function sameIdList(a, b) { const x = [...(a || [])].map(String) const y = [...(b || [])].map(String) diff --git a/frontend/src/screens/Requisitions.jsx b/frontend/src/screens/Requisitions.jsx index b3c7af1..beb585f 100644 --- a/frontend/src/screens/Requisitions.jsx +++ b/frontend/src/screens/Requisitions.jsx @@ -13,6 +13,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import DataTable from '../ui/DataTable' import Modal from '../ui/Modal' +import DepartmentSelect from '../ui/DepartmentSelect' import PageHeader from '../ui/PageHeader' import { Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } from '../ui/primitives' import { useToast } from '../ui/Toast' @@ -269,7 +270,8 @@ export default function Requisitions() { function blankForm() { return { - department: '', + department_id: '', + department_name: '', title: '', date: toDateInput(new Date().toISOString()), date_needed: '', @@ -308,7 +310,8 @@ function fromRow(row) { const rep = row.replacement_for || {} const ref = row.refferal_by || {} return { - department: pos.department || '', + department_id: pos.department_id ? String(pos.department_id) : '', + department_name: pos.department || '', title: pos.title || '', date: toDateInput(pos.date), date_needed: toDateInput(pos.date_needed), @@ -345,7 +348,7 @@ function fromRow(row) { function toPayload(f) { const body = { position: { - department: emptyToNull(f.department), + department_id: emptyToNull(f.department_id), title: emptyToNull(f.title), date: emptyToNull(f.date), date_needed: emptyToNull(f.date_needed), @@ -453,7 +456,11 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
- set('department', e.target.value)} /> + set('department_id', id)} + fallbackName={fields.department_name} + />
diff --git a/frontend/src/ui/DepartmentSelect.jsx b/frontend/src/ui/DepartmentSelect.jsx new file mode 100644 index 0000000..d4dbb0a --- /dev/null +++ b/frontend/src/ui/DepartmentSelect.jsx @@ -0,0 +1,68 @@ +/* ============================================================ + DepartmentSelect — the "From (Dept.)" / "Department" picker. + + Wraps SearchSelect over GET /department/names (`{data:[{id,name}]}`) and + stores the department's id, never its name. The server filters on `search`, + so typing is debounced and SearchSelect is told not to filter again locally. + ============================================================ */ + +import { useEffect, useMemo, useState } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' + +import SearchSelect from './SearchSelect' +import { qk } from '../lib/queryKeys' +import * as departmentsApi from '../api/departments' + +/** + * @param value selected department id, '' when none + * @param onChange (id) => void — receives '' when cleared + * @param fallbackName name of the saved department, used only to label it when + * the list does not contain it (see below) + */ +export default function DepartmentSelect({ value, onChange, fallbackName = '', disabled = false, error = false }) { + const [q, setQ] = useState('') + const [term, setTerm] = useState('') + + useEffect(() => { + const t = setTimeout(() => setTerm(q.trim()), 250) + return () => clearTimeout(t) + }, [q]) + + const query = useQuery({ + queryKey: qk.departments.names(term), + queryFn: async () => departmentsApi.toRows(await departmentsApi.listNames({ search: term })), + placeholderData: keepPreviousData, + retry: false, + }) + + // Two ways the saved department is missing from `query.data`: it was since + // deactivated (the endpoint returns active only), or the user has typed a + // search that excludes it. Keep it in the list either way so opening the form + // — or typing and then clearing — never silently drops the selection. + const options = useMemo(() => { + const rows = query.data ?? [] + const picked = String(value || '') + if (picked && !rows.some((d) => String(d.id) === picked)) { + return [{ id: picked, name: fallbackName || 'Current department' }, ...rows] + } + return rows + }, [query.data, value, fallbackName]) + + return ( + <> + + {query.isError &&

Could not load departments.

} + + ) +} diff --git a/frontend/src/ui/SearchSelect.jsx b/frontend/src/ui/SearchSelect.jsx new file mode 100644 index 0000000..96d7bdc --- /dev/null +++ b/frontend/src/ui/SearchSelect.jsx @@ -0,0 +1,94 @@ +/* ============================================================ + SearchSelect — type-to-filter picker whose value is always an option id. + + Moved out of screens/Jobs.jsx so Requisitions can use the same control. + Options are `{ id, name, email?, role_name? }`. Pass `onQueryChange` when the + caller filters server-side; without it the list is filtered in place. + ============================================================ */ + +import { useEffect, useRef, useState } from 'react' + +/** + * Searchable picker: type to filter, click a row to store the id. + * Not free-text — the value is always an option id (or '' when allowEmpty). + */ +export default function SearchSelect({ + options = [], + value, + onChange, + placeholder = 'Search…', + disabled = false, + loading = false, + allowEmpty = false, + emptyLabel = 'Unassigned', + error = false, + onQueryChange, +}) { + const [q, setQ] = useState('') + const [open, setOpen] = useState(false) + const root = useRef(null) + const selected = options.find((o) => String(o.id) === String(value || '')) + + useEffect(() => { + function onDoc(e) { + if (root.current && !root.current.contains(e.target)) setOpen(false) + } + document.addEventListener('mousedown', onDoc) + return () => document.removeEventListener('mousedown', onDoc) + }, []) + + useEffect(() => { + if (!onQueryChange || !open) return + onQueryChange(q) + }, [q, open, onQueryChange]) + + const term = q.trim().toLowerCase() + const filtered = onQueryChange + ? options + : options.filter((o) => { + if (!term) return true + const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase() + return hay.includes(term) + }) + + return ( +
+ { setOpen(true); setQ('') }} + onChange={(e) => { setQ(e.target.value); setOpen(true) }} + /> + {open && !disabled && !loading && ( +
+ {allowEmpty && ( + + )} + {filtered.length === 0 && ( +
No matches
+ )} + {filtered.map((o) => ( + + ))} +
+ )} +
+ ) +}