# backend Django 5.2 + DRF API for the SAR Link portal. | | | |---|---| | Auth | knox tokens, two-step login (mobile -> password or SMS OTP) | | Database | PostgreSQL 16 | | Background tasks | procrastinate (postgres-backed, no broker) | | Serving | gunicorn + WhiteNoise for `/static/` | ## Layout ``` apibase/ settings, urls, procrastinate app core/ healthcheck, pagination, unified error shape users/ custom User (mobile is the identifier), OtpCode, auth endpoints ``` ## Run it From the repo root (`docker compose up` starts backend + database + frontend): ```sh cp backend/.env.example backend/.env docker compose up --build docker compose exec backend python manage.py createsuperuser # asks for a mobile number ``` The API is on `http://localhost:8000`, the admin on `http://localhost:8000/admin/`, and Swagger (DEBUG only) on `http://localhost:8000/api/docs/`. Without Docker: ```sh python -m venv .venv && . .venv/bin/activate pip install -r requirements.txt POSTGRES_HOST=localhost python manage.py migrate POSTGRES_HOST=localhost python manage.py runserver ``` ## Tests `apibase/settings_test.py` turns off throttling and uses a fast password hasher. It still runs on postgres, because procrastinate's migrations are postgres-only. ```sh python manage.py test --settings=apibase.settings_test ``` ## Authentication Everything starts with the mobile number. One request decides which second box the SPA renders: ``` POST /api/auth/start/ {"mobile": "7712345"} -> {"next": "password", "mobile": "+9607712345"} the account signs in with a password -> {"next": "otp", "mobile": ..., "expires_at": ..., "resend_available_at": ..., "code_length": 6} a code was sent by SMS ``` **`start` never says whether a number has an account.** Every number that gets a code gets the same response, so the endpoint can't be used to enumerate members. That answer comes only after the code is confirmed: ``` POST /api/auth/login/password/ {"mobile", "password"} -> {"next": "dashboard", "token", "expiry", "user"} POST /api/auth/verify/ {"mobile", "code"} -> {"next": "dashboard", "token", "expiry", "user"} the account signs in -> {"next": "register", "registration_token", "mobile", "expires_at"} no account: go register POST /api/auth/otp/resend/ {"mobile"} GET /api/auth/me/ Authorization: Token POST /api/auth/logout/ Authorization: Token ``` A disabled account is also only reported at `verify/`, for the same reason. Which method an account uses is `User.auth_method` (`otp` by default, or `password`). An account set to `password` with no usable password falls back to OTP, so nobody gets locked out — see `User.effective_auth_method`. Numbers are normalised to E.164 (`+960XXXXXXX`) at the serializer, so `7712345`, `960 771 2345` and `+9607712345` are all the same account. Codes are 6 digits, stored only as a hash, single-use, valid for `OTP_TTL_SECONDS` (5 min), at most `OTP_MAX_ATTEMPTS` (5) guesses, with a `OTP_RESEND_COOLDOWN_SECONDS` (60s) resend cooldown on top of per-IP throttles. Issuing a new code invalidates the outstanding one. ### SMS `users/sms.py` posts to the SAR Link gateway: ``` POST {SMS_API_URL} # https://smsapi.sarlink.net/api/sms/send X-API-Key: {SMS_API_KEY} {"to": "+9607712345", "text": "..."} ``` With `SMS_API_KEY` empty the message is written to the log instead of being sent, so every flow works in dev — the code is in the backend log. The real key belongs in `backend/.env` (gitignored), never in `.env.example`. ### Errors Every error has the same shape, and the SPA branches on `code`: ```json {"detail": "That code is not correct.", "code": "invalid_code", "attempts_left": 4} ``` ## Registration `verify/` returns a `registration_token` when the number has no account: proof that the number was confirmed by SMS, good for `REGISTRATION_TICKET_TTL_SECONDS` (30 min) and redeemable once. The form then posts it back: ``` POST /api/auth/register/ { "registration_token": "...", "full_name": "Mariyam Ibrahim", "idnumber": "A123456", "date_of_birth": "1998-02-11", "atoll": 1, "island": 1, "terms_accepted": true, "policy_accepted": true } -> 201 {"status": "pending", "mobile", "full_name", "detail"} ``` The mobile number is **not** read from the form - it comes from the ticket, so the account always gets a number the applicant proved they control and the prefilled field can't be tampered with. Both agreements must be `true`, and the island must belong to the chosen atoll. Registering does **not** sign anyone in and does **not** produce a usable account: the row is created with `status = "pending"`, no password, and `auth_method = "otp"`. The applicant can sign in with an SMS code to watch the status, but nothing is provisioned until an admin approves. ### Approving In the Django admin, filter `status = pending`, review, then use the **Approve selected registrations** / **Reject selected registrations** actions. Both record `reviewed_at`/`reviewed_by` and text the applicant. In code: `user.approve(reviewer=admin)` / `user.reject(reviewer=admin, reason="...")`. ## Locations The registration form's atoll/island dropdowns come from the database: ``` GET /api/locations/atolls/ # public, islands nested ``` Seed data lives in `locations/seed.py` (currently just Faafu -> Dharanboodhoo) and is applied by migration `locations/0002_seed_locations`, so a fresh database has it. To re-apply after editing: ```sh python manage.py seed_locations # idempotent ``` Anything else is managed in the admin. ## Not built yet Devices, billing, password self-service, and the RADIUS access-control integration.