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) ## Package layout (every domain)
``` ```
backend/<domain>/ backend/<domain>/
app.py # routes only — HTTP in/out app.py # routes + inline request models
views.py # service class — business logic views.py # service class
models.py # SQLModel table + classmethod DB accessors models.py # SQLModel table + classmethod accessors
serializers.py # hand-rolled dict builders (no Pydantic response models) serializers.py # serialize_* dict builders
plugins.py # pure helpers (hash, JWT, clean payload) — NO FastAPI imports enums.py # str Enums + nested request BaseModel blocks
permissions.py # OAuth2 scheme + Depends aliases (auth domains only) plugins.py # helpers + constants
permissions.py # auth domains only
``` ```
- Bare `router = APIRouter()`; mount in `main.py` with `app.include_router(...)`. - No `__init__.py`. Run from `backend/`; imports are top-level (`candidate_forms.views`, `db_setup`, `users.permissions`).
- No package `__init__.py`. Run from `backend/` so imports are top-level (`users.app`, `db_setup`). - Config: module-level `load_dotenv()` + `os.getenv(...)`. Do not extend `db_setup.Settings` for app secrets.
- Non-DB 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 | | `app.py` | Routes, inline request models, `Depends`, `JSONResponse` envelope | Business rules, SQL, helper 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 | | `views.py` | Payload prep, business checks, `HTTPException`, call model, return serializer | SQL, commits, helper definitions |
| `models.py` | Fields, queries, inserts/updates/soft-delete, `selectinload` when needed | HTTPException, FastAPI, serializers | | `models.py` | Columns, relationships, queries, insert/update/soft-delete + commit | `HTTPException`, serializers |
| `serializers.py` | `serialize_*` → plain `dict` | DB, Depends | | `serializers.py` | `serialize_*``dict` | DB, `Depends` |
| `plugins.py` | Pure helpers; raise library errors (`jwt.*`) | Import FastAPI / raise HTTPException | | `enums.py` | Enums, nested request blocks | Logic |
| `permissions.py` | `OAuth2PasswordBearer`, `get_current_user`, `CurrentUser` alias, `require_permission` | Route handlers | | `plugins.py` | Every helper and constant | Routes, DB writes |
## 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.
## Workflow when adding an endpoint ## Workflow when adding an endpoint
1. Model accessor (if DB). 1. Migration SQL if the schema changes.
2. Service method in `views.py`. 2. Columns + one classmethod accessor in `models.py`.
3. `serialize_*` if new shape. 3. Enum / nested block in `enums.py` if the body has one.
4. Route in `app.py` with the standard try/except + `JSONResponse`. 4. Helper in `plugins.py` only if a view needs one that does not already exist.
5. Add `current_user: CurrentUser` or `Depends(require_permission(...))` if the route is protected. 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, require_all=False,
) )
), ),
q: str | None = Query(None), search: str | None = Query(None),
top: int = Query(50, ge=1, le=100), top: int = Query(50, ge=1, le=100),
job_post_id: uuid.UUID | None = Query(None), job_post_id: uuid.UUID | None = Query(None),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@ -91,7 +91,7 @@ async def search_requisitions(
""" """
try: try:
service = RequisitionForm(session=session) 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}) return JSONResponse(content={"data": data, "status_code": 200})
except HTTPException: except HTTPException:
raise raise

View File

@ -2,7 +2,7 @@ from enum import Enum
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from datetime import date from datetime import date
import uuid
class EmploymentType(str,Enum): class EmploymentType(str,Enum):
PERMANENT = "permanent" PERMANENT = "permanent"
CONTRACT = "contract" CONTRACT = "contract"
@ -10,6 +10,7 @@ class EmploymentType(str,Enum):
INTERNEE="internee" INTERNEE="internee"
class Position(BaseModel): class Position(BaseModel):
department_id:uuid.UUID
department:Optional[str] department:Optional[str]
title:Optional[str] title:Optional[str]
date:Optional[date] date:Optional[date]

View File

@ -8,8 +8,9 @@ from sqlmodel import Field, Relationship, SQLModel, select
from candidate_forms.enums import EmploymentType from candidate_forms.enums import EmploymentType
if TYPE_CHECKING: if TYPE_CHECKING:
from department.models import Department
from job.job_post.models import JobPosts from job.job_post.models import JobPosts
def _now() -> datetime: def _now() -> datetime:
return datetime.now(timezone.utc) 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) id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
department: Optional[str] = None 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 position_title: Optional[str] = None
date: Optional[Date] = None date: Optional[Date] = None
date_needed: Optional[Date] = None date_needed: Optional[Date] = None
@ -93,7 +99,7 @@ class Requisition(SQLModel, table=True):
async def search( async def search(
cls, cls,
session: AsyncSession, session: AsyncSession,
q: str | None = None, search: str | None = None,
*, *,
top: int = 50, top: int = 50,
job_post_id=None, job_post_id=None,
@ -119,7 +125,7 @@ class Requisition(SQLModel, table=True):
if except_uid is not None: if except_uid is not None:
held = held.where(JobPosts.id != except_uid) held = held.where(JobPosts.id != except_uid)
statement = statement.where(cls.id.notin_(held)) statement = statement.where(cls.id.notin_(held))
term = (q or "").strip() term = (search or "").strip()
if term: if term:
like = f"%{term}%" like = f"%{term}%"
statement = statement.where( statement = statement.where(
@ -163,6 +169,7 @@ class Requisition(SQLModel, table=True):
referral = fields.get("refferal_by") if fields.get("refferal_by") else {} referral = fields.get("refferal_by") if fields.get("refferal_by") else {}
row = cls( row = cls(
department=position.get("department") if position.get("department") else None, 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, position_title=position.get("title") if position.get("title") else None,
date=position.get("date") if position.get("date") 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, 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 {} position = fields.get("position") if fields.get("position") else {}
if "department" in position: if "department" in position:
row.department = position.get("department") if position.get("department") else None 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: if "title" in position:
row.position_title = position.get("title") if position.get("title") else None row.position_title = position.get("title") if position.get("title") else None
if "date" in position: if "date" in position:
@ -416,3 +425,4 @@ class CandidateForms(SQLModel, table=True):
import users.models as _users_models # noqa: E402, F401 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") 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, "id": str(row.id) if row.id else None,
"title": row.position_title, "title": row.position_title,
"department": row.department, "department": row.department,
"department_id": str(row.department_id) if row.department_id else None,
"label": f"{title or 'Untitled'} - {department or ''}", "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, "id": str(row.id) if row.id else None,
"position": { "position": {
"department": row.department, "department": row.department,
"department_id": str(row.department_id) if row.department_id else None,
"title": row.position_title, "title": row.position_title,
"date": _date(row.date), "date": _date(row.date),
"date_needed": _date(row.date_needed), "date_needed": _date(row.date_needed),

View File

@ -14,6 +14,13 @@ from candidate_forms.plugins import (
combined_summary, combined_summary,
normalize_fields, normalize_fields,
normalize_sections, normalize_sections,
_as_uuid,
_user_id,
_aware,
_stage_value,
_recommendation,
_score_sections,
_score_fields,
) )
from candidate_forms.serializers import ( from candidate_forms.serializers import (
serialize_form, serialize_form,
@ -32,63 +39,6 @@ from users.permissions import is_admin, is_hiring_manager
logger = logging.getLogger("candidate_forms") 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: class CandidateForm:
def __init__(self, session: AsyncSession): def __init__(self, session: AsyncSession):
self.session = session self.session = session
@ -406,6 +356,7 @@ class RequisitionForm:
self.session = session self.session = session
async def create_form(self, payload, current_user): 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) payload["created_by"] = _user_id(current_user)
row = await Requisition.insert_form(self.session, payload) row = await Requisition.insert_form(self.session, payload)
return serialize_requisition(row) return serialize_requisition(row)
@ -440,8 +391,8 @@ class RequisitionForm:
created_by = None if is_admin(current_user) else _user_id(current_user) created_by = None if is_admin(current_user) else _user_id(current_user)
return await Requisition.count_open(self.session, created_by=created_by) 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( 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] return [serialize_requisition_option(r) for r in rows]

View File

@ -149,6 +149,8 @@ async def fetch_department_names(
PermissionTag.DEPARTMENT_VIEW, PermissionTag.DEPARTMENT_VIEW,
PermissionTag.JOB_BOARD_CREATE, PermissionTag.JOB_BOARD_CREATE,
PermissionTag.JOBS_EDIT, PermissionTag.JOBS_EDIT,
PermissionTag.REQUISITIONS_CREATE,
PermissionTag.REQUISITIONS_EDIT,
require_all=False, require_all=False,
) )
), ),

View File

@ -14,6 +14,7 @@ from sqlmodel import Field, Relationship, SQLModel, select
from department.plugins import as_uuid, now_utc from department.plugins import as_uuid, now_utc
if TYPE_CHECKING: if TYPE_CHECKING:
from candidate_forms.models import Requisition
from job.job_post.models import JobPosts from job.job_post.models import JobPosts
class Department(SQLModel, table=True): 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. # 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"}) 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) subtitle: Optional[str] = Field(default=None)
description: Optional[str] = Field(default=None) description: Optional[str] = Field(default=None)
is_active: bool = Field(default=True) is_active: bool = Field(default=True)
@ -171,3 +174,5 @@ class Department(SQLModel, table=True):
return {} return {}
result = await session.execute(select(cls.id, cls.name).where(cls.id.in_(uids))) result = await session.execute(select(cls.id, cls.name).where(cls.id.in_(uids)))
return {row[0]: row[1] for row in result.all()} 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 AiFieldAssist from '../ui/AiFieldAssist'
import DataTable from '../ui/DataTable' import DataTable from '../ui/DataTable'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import SearchSelect from '../ui/SearchSelect'
import PageHeader from '../ui/PageHeader' import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives' import { Badge, EmptyState, FieldError, Icon, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
@ -397,91 +398,6 @@ function fmtWhen(value) {
return fmtDateTime(value) || null 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) { function sameIdList(a, b) {
const x = [...(a || [])].map(String) const x = [...(a || [])].map(String)
const y = [...(b || [])].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 DataTable from '../ui/DataTable'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import DepartmentSelect from '../ui/DepartmentSelect'
import PageHeader from '../ui/PageHeader' import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } from '../ui/primitives' import { Badge, EmptyState, FieldError, Icon, KpiCard, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
@ -269,7 +270,8 @@ export default function Requisitions() {
function blankForm() { function blankForm() {
return { return {
department: '', department_id: '',
department_name: '',
title: '', title: '',
date: toDateInput(new Date().toISOString()), date: toDateInput(new Date().toISOString()),
date_needed: '', date_needed: '',
@ -308,7 +310,8 @@ function fromRow(row) {
const rep = row.replacement_for || {} const rep = row.replacement_for || {}
const ref = row.refferal_by || {} const ref = row.refferal_by || {}
return { return {
department: pos.department || '', department_id: pos.department_id ? String(pos.department_id) : '',
department_name: pos.department || '',
title: pos.title || '', title: pos.title || '',
date: toDateInput(pos.date), date: toDateInput(pos.date),
date_needed: toDateInput(pos.date_needed), date_needed: toDateInput(pos.date_needed),
@ -345,7 +348,7 @@ function fromRow(row) {
function toPayload(f) { function toPayload(f) {
const body = { const body = {
position: { position: {
department: emptyToNull(f.department), department_id: emptyToNull(f.department_id),
title: emptyToNull(f.title), title: emptyToNull(f.title),
date: emptyToNull(f.date), date: emptyToNull(f.date),
date_needed: emptyToNull(f.date_needed), date_needed: emptyToNull(f.date_needed),
@ -453,7 +456,11 @@ function RequisitionEditor({ row, allowed, onClose, onSaved, toast }) {
<div className="form-grid"> <div className="form-grid">
<div className="form-field"> <div className="form-field">
<label>From (Dept.)</label> <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>
<div className="form-field"> <div className="form-field">
<label>Job title <span className="req">*</span></label> <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>
)
}