commit with reqxdepoart realtion
Deploy to S3 / deploy (push) Successful in 33s
Details
Deploy to S3 / deploy (push) Successful in 33s
Details
parent
b841677bc3
commit
3838634e82
|
|
@ -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 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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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 $$;
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue