--- 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// 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.