commit with reqxdepoart realtion
Deploy to S3 / deploy (push) Successful in 33s Details

Interview_Delay
ahmed.mujtaba 2026-09-16 13:39:48 +05:00
parent b841677bc3
commit 3838634e82
14 changed files with 614 additions and 269 deletions

View File

@ -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: `/<domain>/create`, `/<domain>/fetch`, `/<domain>/update`, `/<domain>/delete`, `/<domain>/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 `<domain>/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 <other>.models as _<other>_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_<resource>(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/<next_number>_<snake_name>.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/<domain>/
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 files 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.

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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")

View File

@ -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),

View File

@ -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]

View File

@ -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,
)
),

View File

@ -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

View File

@ -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 $$;

View File

@ -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 (
<div className={`dropdown${open ? ' open' : ''}`} ref={root} style={{ width: '100%' }}>
<input
className={error ? 'err' : ''}
value={open ? q : (selected?.name || '')}
disabled={disabled || loading}
placeholder={loading ? 'Loading…' : placeholder}
autoComplete="off"
onFocus={() => { setOpen(true); setQ('') }}
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
/>
{open && !disabled && !loading && (
<div className="dropdown-menu" style={{ left: 0, right: 'auto', width: '100%', minWidth: 0, maxHeight: 240, overflowY: 'auto' }}>
{allowEmpty && (
<button
type="button"
className="dropdown-link"
onClick={() => { onChange(''); setQ(''); setOpen(false) }}
>
{emptyLabel}
</button>
)}
{filtered.length === 0 && (
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>No matches</div>
)}
{filtered.map((o) => (
<button
type="button"
key={o.id}
className="dropdown-link"
onClick={() => { onChange(String(o.id)); setQ(''); setOpen(false) }}
>
{o.name}
{o.email ? <span className="cell-sub" style={{ marginLeft: 8 }}>{o.email}</span> : null}
</button>
))}
</div>
)}
</div>
)
}
function sameIdList(a, b) {
const x = [...(a || [])].map(String)
const y = [...(b || [])].map(String)

View File

@ -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 }) {
<div className="form-grid">
<div className="form-field">
<label>From (Dept.)</label>
<input value={fields.department} onChange={(e) => set('department', e.target.value)} />
<DepartmentSelect
value={fields.department_id}
onChange={(id) => set('department_id', id)}
fallbackName={fields.department_name}
/>
</div>
<div className="form-field">
<label>Job title <span className="req">*</span></label>

View File

@ -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 (
<>
<SearchSelect
options={options}
value={value}
onChange={onChange}
onQueryChange={setQ}
placeholder="Search departments…"
disabled={disabled}
loading={query.isPending && !query.data}
error={error}
allowEmpty
emptyLabel="No department"
/>
{query.isError && <p className="text-muted text-sm">Could not load departments.</p>}
</>
)
}

View File

@ -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 (
<div className={`dropdown${open ? ' open' : ''}`} ref={root} style={{ width: '100%' }}>
<input
className={error ? 'err' : ''}
value={open ? q : (selected?.name || '')}
disabled={disabled || loading}
placeholder={loading ? 'Loading…' : placeholder}
autoComplete="off"
onFocus={() => { setOpen(true); setQ('') }}
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
/>
{open && !disabled && !loading && (
<div className="dropdown-menu" style={{ left: 0, right: 'auto', width: '100%', minWidth: 0, maxHeight: 240, overflowY: 'auto' }}>
{allowEmpty && (
<button
type="button"
className="dropdown-link"
onClick={() => { onChange(''); setQ(''); setOpen(false) }}
>
{emptyLabel}
</button>
)}
{filtered.length === 0 && (
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>No matches</div>
)}
{filtered.map((o) => (
<button
type="button"
key={o.id}
className="dropdown-link"
onClick={() => { onChange(String(o.id)); setQ(''); setOpen(false) }}
>
{o.name}
{o.email ? <span className="cell-sub" style={{ marginLeft: 8 }}>{o.email}</span> : null}
</button>
))}
</div>
)}
</div>
)
}