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):
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:
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.
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 <token>
POST /api/auth/logout/ Authorization: Token <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:
{"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:
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.