Files
2026-09-22 00:50:30 +05:00
..
2026-09-22 00:50:30 +05:00
2026-09-22 00:50:30 +05:00
2026-09-22 00:50:30 +05:00
2026-09-22 00:50:30 +05:00
2026-09-22 00:50:30 +05:00
2026-09-22 00:50:30 +05:00
2026-09-22 00:50:30 +05:00
2026-09-22 00:50:30 +05:00
2026-09-22 00:50:30 +05:00
2026-09-22 00:50:30 +05:00
2026-09-22 00:50:30 +05:00
2026-09-22 00:50:30 +05:00
2026-09-22 00:50:30 +05:00

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.