HR-ATS-Portal/.cursor/rules/backend-house-style.mdc

123 lines
5.9 KiB
Plaintext

---
description: Strict HR-ATS backend house style — layering, routes, serializers, auth
globs: backend/**/*.py
alwaysApply: false
---
# HR-ATS Backend — singular pattern (mandatory)
Match existing modules (`users/`, `inbox/`) exactly. Do not introduce alternate frameworks, layers, or response shapes.
## Package layout (every domain)
```
backend/<domain>/
app.py # routes only — HTTP in/out
views.py # service class — business logic
models.py # SQLModel table + classmethod DB accessors
serializers.py # hand-rolled dict builders (no Pydantic response models)
plugins.py # pure helpers (hash, JWT, clean payload) — NO FastAPI imports
permissions.py # OAuth2 scheme + Depends aliases (auth domains only)
```
- Mount with bare `router = APIRouter()`; register in `main.py` via `app.include_router(...)`.
- No package `__init__.py`; run uvicorn from `backend/` so imports are top-level (`users.app`, `db_setup`).
- Config: `load_dotenv()` + `os.getenv(...)` in the module that needs it. Do not extend `db_setup.Settings` for non-DB keys.
## Layer duties (strict)
| Layer | Owns | Must NOT |
|---|---|---|
| `app.py` | Routes, request Pydantic models (inline), `JSONResponse`, call serializers for HTTP payloads, inject `CurrentUser` / `session` | Business rules, DB queries, JWT encode logic beyond calling plugins |
| `views.py` | Validate rules, call models, raise `HTTPException`, return ORM rows or serialized dicts for CRUD | Build login/token HTTP envelopes; call `serialize_token` |
| `models.py` | Table fields, `select`/`insert`/`update`/`soft_delete`, `selectinload` when relations are needed | HTTPExceptions, serializers, FastAPI |
| `serializers.py` | `serialize_*` → plain `dict` (`str(uuid)`, `.isoformat()` dates, never password) | DB access, Depends |
| `plugins.py` | Pure functions; raise library errors (e.g. `jwt.*`), not HTTP | Import FastAPI |
| `permissions.py` | `OAuth2PasswordBearer`, `get_current_user`, `CurrentUser = Annotated[...]`, `require_permission` | Route handlers |
## Route pattern (`app.py`)
- Verb-in-path: `/users/create`, `/users/fetch`, `/users/login` — not `/auth/token`, not REST nouns-only.
- Every handler uses this wrapper:
```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 fetch: `{"data":items,"total":total,"status_code":200}`; single-by-id: `total: 1`.
- Login/refresh: build tokens in the route, then serialize:
```python
user=await service.authenticate_user(...)
tokens=serialize_token(create_access_token(user),create_refresh_token(user),user)
return JSONResponse(content={**tokens,"status_code":200})
```
- Request schemas live **inline** in `app.py` (`UserCreate`, `TokenRefresh`, …). Not in `serializers.py`.
- Dependencies: default form `session: AsyncSession = Depends(get_session)` unless `Annotated` is required (auth form / `CurrentUser` before other defaults).
- Tight spacing house style: `service=User(session=session)`, `detail=str(e)`, `key=value` in calls — match neighbors, do not “pretty-reformat” whole files.
## 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) # CRUD returns serialized dict
```
- Untyped method args (match existing). Raise `HTTPException(status_code=...,detail="...")`.
- Auth methods (`authenticate_user`, `refresh_access_token`) return the **ORM user** only — never `serialize_token`.
- After email lookup that lacks `selectinload(role)`, re-fetch via `get_user_by_id` before anything that touches `user.role`.
## Model accessors (`models.py`)
- `@classmethod async def get_* / insert_* / update_* / soft_delete_* / count_*`.
- Soft delete: set `is_deleted=True`, `is_active=False`.
- Use `selectinload(cls.role)` on fetches that will be serialized with role fields.
- Commit inside model write methods (existing pattern).
## Auth pattern
- Access + refresh JWTs via `plugins` (`type` claim must be checked in `decode_token`).
- `permissions.CurrentUser` on every protected `/users/*` route; login + refresh stay open.
- `get_current_user`: decode access → DB load by `sub` → reject missing/deleted/inactive → `serialize_user`.
- Login uses `OAuth2PasswordRequestForm`; username field carries email.
- OAuth2 fields (`access_token`, `refresh_token`, `token_type`, `expires_in`) at **response root**; user under `data`.
- `tokenUrl="users/login"` (no leading slash).
- No FastAPI in `plugins.py`. Translate `jwt.PyJWTError` → 401 in `permissions` / `views`.
## Dependencies / env
- Pin in `requirements.txt` under comment banners with trailing rationale comments.
- Secrets in `backend/.env`; document keys in `backend/.env.example`.
- JWT times: `datetime.now(timezone.utc)` only (never naive `datetime.now()` for token `iat`/`exp`).
## Hard bans
- No new abstraction layers (repositories, use-cases, DTOs beyond inline Pydantic requests).
- No Pydantic response models; no `jsonable_encoder` for these routes.
- No changing response envelope (`data` + `status_code`) or inventing `/api/v1` prefixes.
- No drive-by refactors or reformatting unrelated code.
- No RBAC second scheme — vocabulary and `require_permission` live in `users/permissions.py` only.
- Do not touch `inbox/` when the task is `users/` (and vice versa) unless asked.
- No `__init__.py` packages; no moving request models into `serializers.py`.
## When adding a new domain endpoint
1. Accessor on `models.py` if DB changes.
2. Method on service in `views.py`.
3. `serialize_*` in `serializers.py` if new shape.
4. Route in `app.py` with the standard try/except + `JSONResponse`.
5. Protect with `current_user: CurrentUser` if under an authenticated router.