register and sign in pages

This commit is contained in:
2026-09-22 00:50:30 +05:00
parent f313867653
commit 78d04f75d1
96 changed files with 6383 additions and 109 deletions
View File
+141
View File
@@ -0,0 +1,141 @@
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from .models import OtpCode, RegistrationTicket, User
from .sms import send_registration_approved, send_registration_rejected
@admin.register(User)
class UserAdmin(BaseUserAdmin):
ordering = ["-date_joined"]
list_display = [
"mobile",
"full_name",
"status",
"idnumber",
"island",
"auth_method",
"date_joined",
]
list_filter = ["status", "auth_method", "atoll", "is_staff", "is_active"]
search_fields = ["mobile", "full_name", "email", "idnumber"]
readonly_fields = [
"date_joined",
"updated_at",
"last_login",
"terms_accepted_at",
"policy_accepted_at",
"reviewed_at",
"reviewed_by",
]
autocomplete_fields = ["atoll", "island"]
actions = ["approve_registrations", "reject_registrations"]
fieldsets = [
(None, {"fields": ["mobile", "password"]}),
(
"Applicant",
{
"fields": [
"full_name",
"email",
"idnumber",
"date_of_birth",
"atoll",
"island",
]
},
),
(
"Registration review",
{
"fields": [
"status",
"rejection_reason",
"terms_accepted_at",
"policy_accepted_at",
"reviewed_at",
"reviewed_by",
]
},
),
("Portal", {"fields": ["auth_method", "mobile_verified"]}),
(
"Permissions",
{
"fields": [
"is_active",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
]
},
),
("Dates", {"fields": ["last_login", "date_joined", "updated_at"]}),
]
add_fieldsets = [
(
None,
{
"classes": ["wide"],
"fields": [
"mobile",
"full_name",
"auth_method",
"password1",
"password2",
],
},
),
]
@admin.action(description="Approve selected registrations")
def approve_registrations(self, request, queryset):
pending = queryset.exclude(status=User.Status.APPROVED)
for user in pending:
user.approve(reviewer=request.user)
send_registration_approved(user.mobile)
self.message_user(
request, f"Approved {pending.count()} registration(s).", messages.SUCCESS
)
@admin.action(description="Reject selected registrations")
def reject_registrations(self, request, queryset):
pending = queryset.exclude(status=User.Status.REJECTED)
for user in pending:
user.reject(reviewer=request.user)
send_registration_rejected(user.mobile, user.rejection_reason)
self.message_user(
request, f"Rejected {pending.count()} registration(s).", messages.WARNING
)
@admin.register(OtpCode)
class OtpCodeAdmin(admin.ModelAdmin):
list_display = [
"mobile",
"purpose",
"attempts",
"created_at",
"expires_at",
"consumed_at",
]
list_filter = ["purpose"]
search_fields = ["mobile"]
readonly_fields = [
"user",
"mobile",
"purpose",
"code_hash",
"attempts",
"created_at",
"expires_at",
"consumed_at",
]
@admin.register(RegistrationTicket)
class RegistrationTicketAdmin(admin.ModelAdmin):
list_display = ["mobile", "created_at", "expires_at", "consumed_at"]
search_fields = ["mobile"]
readonly_fields = ["key", "mobile", "created_at", "expires_at", "consumed_at"]
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "users"
+38
View File
@@ -0,0 +1,38 @@
from django.contrib.auth.models import BaseUserManager
from .mobile import normalize_mobile
class UserManager(BaseUserManager):
"""Users are identified by mobile number, not username."""
use_in_migrations = True
def _create_user(self, mobile, password=None, **extra_fields):
if not mobile:
raise ValueError("A mobile number is required.")
user = self.model(mobile=normalize_mobile(mobile), **extra_fields)
if password:
user.set_password(password)
else:
user.set_unusable_password()
user.save(using=self._db)
return user
def create_user(self, mobile, password=None, **extra_fields):
extra_fields.setdefault("is_staff", False)
extra_fields.setdefault("is_superuser", False)
return self._create_user(mobile, password, **extra_fields)
def create_superuser(self, mobile, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
extra_fields.setdefault("is_active", True)
extra_fields.setdefault("status", self.model.Status.APPROVED)
extra_fields.setdefault("auth_method", self.model.AuthMethod.PASSWORD)
if not extra_fields["is_staff"] or not extra_fields["is_superuser"]:
raise ValueError("Superusers must have is_staff and is_superuser set.")
return self._create_user(mobile, password, **extra_fields)
def get_by_natural_key(self, username):
return self.get(mobile=normalize_mobile(username))
+89
View File
@@ -0,0 +1,89 @@
# Generated by Django 5.2.7 on 2026-09-21 19:05
import django.db.models.deletion
import django.utils.timezone
import users.managers
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('locations', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='RegistrationTicket',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(db_index=True, max_length=64, unique=True)),
('mobile', models.CharField(db_index=True, max_length=16)),
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
('expires_at', models.DateTimeField()),
('consumed_at', models.DateTimeField(blank=True, null=True)),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('mobile', models.CharField(db_index=True, max_length=16, unique=True)),
('full_name', models.CharField(blank=True, max_length=255)),
('email', models.EmailField(blank=True, max_length=254, null=True, unique=True)),
('document_type', models.CharField(choices=[('id_card', 'ID card'), ('passport', 'Passport'), ('work_permit', 'Work permit')], default='id_card', max_length=16)),
('document_number', models.CharField(blank=True, db_index=True, max_length=32)),
('date_of_birth', models.DateField(blank=True, null=True)),
('auth_method', models.CharField(choices=[('otp', 'SMS one-time code'), ('password', 'Password')], default='otp', max_length=16)),
('mobile_verified', models.BooleanField(default=False)),
('status', models.CharField(choices=[('pending', 'Pending approval'), ('approved', 'Approved'), ('rejected', 'Rejected')], db_index=True, default='pending', max_length=16)),
('terms_accepted_at', models.DateTimeField(blank=True, null=True)),
('policy_accepted_at', models.DateTimeField(blank=True, null=True)),
('reviewed_at', models.DateTimeField(blank=True, null=True)),
('rejection_reason', models.TextField(blank=True)),
('is_active', models.BooleanField(default=True)),
('is_staff', models.BooleanField(default=False)),
('date_joined', models.DateTimeField(default=django.utils.timezone.now)),
('updated_at', models.DateTimeField(auto_now=True)),
('atoll', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='users', to='locations.atoll')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('island', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='users', to='locations.island')),
('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_users', to=settings.AUTH_USER_MODEL)),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'ordering': ['-date_joined'],
},
managers=[
('objects', users.managers.UserManager()),
],
),
migrations.CreateModel(
name='OtpCode',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mobile', models.CharField(db_index=True, max_length=16)),
('purpose', models.CharField(choices=[('login', 'Login'), ('registration', 'Registration')], default='login', max_length=16)),
('code_hash', models.CharField(max_length=128)),
('attempts', models.PositiveSmallIntegerField(default=0)),
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
('expires_at', models.DateTimeField()),
('consumed_at', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='otp_codes', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at'],
'indexes': [models.Index(fields=['mobile', 'purpose', '-created_at'], name='users_otpco_mobile_0ef94c_idx')],
},
),
]
+14
View File
@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("users", "0001_initial"),
]
operations = [
migrations.RemoveField(model_name="user", name="document_type"),
migrations.RenameField(
model_name="user", old_name="document_number", new_name="idnumber"
),
]
+29
View File
@@ -0,0 +1,29 @@
"""Maldives mobile number normalisation.
Everything past the serializer layer deals in E.164 (`+960XXXXXXX`) so a
number is stored and looked up exactly one way.
"""
import re
from django.core.exceptions import ValidationError
COUNTRY_CODE = "960"
LOCAL_LENGTH = 7
# Maldives mobile prefixes are 7xx and 9xx.
LOCAL_RE = re.compile(r"^[79]\d{6}$")
def normalize_mobile(value: str) -> str:
"""Return `value` as +960XXXXXXX, or raise ValidationError."""
digits = re.sub(r"[\s()-]", "", str(value or "")).lstrip("+")
if digits.startswith("00" + COUNTRY_CODE):
digits = digits[len("00" + COUNTRY_CODE) :]
elif digits.startswith(COUNTRY_CODE) and len(digits) > LOCAL_LENGTH:
digits = digits[len(COUNTRY_CODE) :]
if not LOCAL_RE.match(digits):
raise ValidationError("Enter a valid Maldives mobile number.")
return f"+{COUNTRY_CODE}{digits}"
+279
View File
@@ -0,0 +1,279 @@
import secrets
from datetime import timedelta
from django.conf import settings
from django.contrib.auth.hashers import check_password, make_password
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.db import models
from django.utils import timezone
from .managers import UserManager
from .mobile import normalize_mobile
class User(AbstractBaseUser, PermissionsMixin):
"""Portal account. The mobile number is the login identifier.
A self-registered account starts at `status = PENDING` and only becomes
usable once an admin approves it - see `approve()` / `reject()`.
"""
class AuthMethod(models.TextChoices):
OTP = "otp", "SMS one-time code"
PASSWORD = "password", "Password"
class Status(models.TextChoices):
PENDING = "pending", "Pending approval"
APPROVED = "approved", "Approved"
REJECTED = "rejected", "Rejected"
mobile = models.CharField(max_length=16, unique=True, db_index=True)
full_name = models.CharField(max_length=255, blank=True)
email = models.EmailField(blank=True, null=True, unique=True)
# ID card, passport or work permit number.
idnumber = models.CharField(max_length=32, blank=True, db_index=True)
date_of_birth = models.DateField(null=True, blank=True)
# Address
atoll = models.ForeignKey(
"locations.Atoll",
on_delete=models.PROTECT,
related_name="users",
null=True,
blank=True,
)
island = models.ForeignKey(
"locations.Island",
on_delete=models.PROTECT,
related_name="users",
null=True,
blank=True,
)
# Which second step /auth/start/ asks the SPA to render.
auth_method = models.CharField(
max_length=16, choices=AuthMethod.choices, default=AuthMethod.OTP
)
mobile_verified = models.BooleanField(default=False)
# Registration review
status = models.CharField(
max_length=16, choices=Status.choices, default=Status.PENDING, db_index=True
)
terms_accepted_at = models.DateTimeField(null=True, blank=True)
policy_accepted_at = models.DateTimeField(null=True, blank=True)
reviewed_at = models.DateTimeField(null=True, blank=True)
reviewed_by = models.ForeignKey(
"self",
on_delete=models.SET_NULL,
related_name="reviewed_users",
null=True,
blank=True,
)
rejection_reason = models.TextField(blank=True)
is_active = models.BooleanField(default=True)
is_staff = models.BooleanField(default=False)
date_joined = models.DateTimeField(default=timezone.now)
updated_at = models.DateTimeField(auto_now=True)
objects = UserManager()
USERNAME_FIELD = "mobile"
REQUIRED_FIELDS = []
class Meta:
ordering = ["-date_joined"]
def __str__(self):
return f"{self.full_name or 'Unnamed'} ({self.mobile})"
def save(self, *args, **kwargs):
self.mobile = normalize_mobile(self.mobile)
super().save(*args, **kwargs)
@property
def is_admin_user(self) -> bool:
"""Admin surface gate: staff or superuser."""
return self.is_staff or self.is_superuser
@property
def is_approved(self) -> bool:
return self.status == self.Status.APPROVED
def can_use_password_login(self) -> bool:
return (
self.auth_method == self.AuthMethod.PASSWORD and self.has_usable_password()
)
@property
def effective_auth_method(self) -> str:
"""`auth_method`, falling back to OTP if no password is actually set."""
if self.can_use_password_login():
return self.AuthMethod.PASSWORD
return self.AuthMethod.OTP
@property
def address(self) -> str:
parts = [part for part in [self.island_id and self.island.name, self.atoll_id and self.atoll.name] if part]
return ", ".join(parts)
def approve(self, reviewer=None) -> None:
self.status = self.Status.APPROVED
self.rejection_reason = ""
self.reviewed_at = timezone.now()
self.reviewed_by = reviewer
self.save(
update_fields=[
"status",
"rejection_reason",
"reviewed_at",
"reviewed_by",
"updated_at",
]
)
def reject(self, reviewer=None, reason: str = "") -> None:
self.status = self.Status.REJECTED
self.rejection_reason = reason
self.reviewed_at = timezone.now()
self.reviewed_by = reviewer
self.save(
update_fields=[
"status",
"rejection_reason",
"reviewed_at",
"reviewed_by",
"updated_at",
]
)
def _generate_code() -> str:
return f"{secrets.randbelow(1_000_000):06d}"
class OtpCodeQuerySet(models.QuerySet):
def active(self):
return self.filter(consumed_at__isnull=True, expires_at__gt=timezone.now())
class OtpCode(models.Model):
"""A single-use SMS code. Only the hash of the code is stored."""
class Purpose(models.TextChoices):
LOGIN = "login", "Login"
REGISTRATION = "registration", "Registration"
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="otp_codes",
null=True,
blank=True,
)
mobile = models.CharField(max_length=16, db_index=True)
purpose = models.CharField(
max_length=16, choices=Purpose.choices, default=Purpose.LOGIN
)
code_hash = models.CharField(max_length=128)
attempts = models.PositiveSmallIntegerField(default=0)
created_at = models.DateTimeField(default=timezone.now)
expires_at = models.DateTimeField()
consumed_at = models.DateTimeField(null=True, blank=True)
objects = OtpCodeQuerySet.as_manager()
class Meta:
ordering = ["-created_at"]
indexes = [models.Index(fields=["mobile", "purpose", "-created_at"])]
def __str__(self):
return f"{self.purpose} code for {self.mobile}"
@classmethod
def issue(cls, mobile: str, purpose: str, user=None) -> tuple["OtpCode", str]:
"""Invalidate any outstanding codes and return (row, plaintext code)."""
cls.objects.filter(
mobile=mobile, purpose=purpose, consumed_at__isnull=True
).update(consumed_at=timezone.now())
code = _generate_code()
otp = cls.objects.create(
user=user,
mobile=mobile,
purpose=purpose,
code_hash=make_password(code),
expires_at=timezone.now() + timedelta(seconds=settings.OTP_TTL_SECONDS),
)
return otp, code
@property
def is_expired(self) -> bool:
return self.expires_at <= timezone.now()
@property
def is_exhausted(self) -> bool:
return self.attempts >= settings.OTP_MAX_ATTEMPTS
@property
def resend_available_at(self):
return self.created_at + timedelta(
seconds=settings.OTP_RESEND_COOLDOWN_SECONDS
)
def verify(self, code: str) -> bool:
"""Check `code`, counting the attempt. Consumes the row on success."""
self.attempts += 1
if check_password(str(code), self.code_hash):
self.consumed_at = timezone.now()
self.save(update_fields=["attempts", "consumed_at"])
return True
self.save(update_fields=["attempts"])
return False
class RegistrationTicketQuerySet(models.QuerySet):
def active(self):
return self.filter(consumed_at__isnull=True, expires_at__gt=timezone.now())
class RegistrationTicket(models.Model):
"""Proof that a mobile number was verified by SMS, redeemable once.
Issued when a registration code is confirmed and required by the
registration submit, so the form can't be posted for a number the caller
never proved they control.
"""
key = models.CharField(max_length=64, unique=True, db_index=True)
mobile = models.CharField(max_length=16, db_index=True)
created_at = models.DateTimeField(default=timezone.now)
expires_at = models.DateTimeField()
consumed_at = models.DateTimeField(null=True, blank=True)
objects = RegistrationTicketQuerySet.as_manager()
class Meta:
ordering = ["-created_at"]
def __str__(self):
return f"registration ticket for {self.mobile}"
@classmethod
def issue(cls, mobile: str) -> "RegistrationTicket":
cls.objects.filter(mobile=mobile, consumed_at__isnull=True).update(
consumed_at=timezone.now()
)
return cls.objects.create(
key=secrets.token_urlsafe(32),
mobile=mobile,
expires_at=timezone.now()
+ timedelta(seconds=settings.REGISTRATION_TICKET_TTL_SECONDS),
)
def consume(self) -> None:
self.consumed_at = timezone.now()
self.save(update_fields=["consumed_at"])
+170
View File
@@ -0,0 +1,170 @@
from datetime import date
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.utils import timezone
from rest_framework import serializers
from locations.models import Atoll, Island
from .mobile import normalize_mobile
from .models import RegistrationTicket, User
MAX_AGE_YEARS = 120
class MobileField(serializers.CharField):
"""Accepts 7712345 / 9607712345 / +960 771 2345 and stores +9607712345."""
def to_internal_value(self, data):
value = super().to_internal_value(data)
try:
return normalize_mobile(value)
except DjangoValidationError as exc:
raise serializers.ValidationError(exc.messages) from exc
class AuthStartSerializer(serializers.Serializer):
mobile = MobileField()
class PasswordLoginSerializer(serializers.Serializer):
mobile = MobileField()
password = serializers.CharField(trim_whitespace=False, write_only=True)
class OtpVerifySerializer(serializers.Serializer):
mobile = MobileField()
code = serializers.RegexField(r"^\d{6}$", write_only=True)
class UserSerializer(serializers.ModelSerializer):
is_admin = serializers.BooleanField(source="is_admin_user", read_only=True)
has_password = serializers.SerializerMethodField()
atoll_name = serializers.CharField(source="atoll.name", default=None, read_only=True)
island_name = serializers.CharField(
source="island.name", default=None, read_only=True
)
class Meta:
model = User
fields = [
"id",
"mobile",
"full_name",
"email",
"idnumber",
"date_of_birth",
"atoll",
"atoll_name",
"island",
"island_name",
"auth_method",
"status",
"rejection_reason",
"mobile_verified",
"is_admin",
"has_password",
"date_joined",
]
read_only_fields = fields
def get_has_password(self, obj) -> bool:
return obj.has_usable_password()
class RegistrationSerializer(serializers.Serializer):
"""The registration form.
The mobile number is not accepted from the client: it comes from the
`registration_token`, which is only issued after that number confirmed an
SMS code. So the number on the account is always one the applicant proved
they control, and the form's prefilled field can't be tampered with.
"""
registration_token = serializers.CharField(write_only=True)
full_name = serializers.CharField(max_length=255)
idnumber = serializers.CharField(max_length=32)
date_of_birth = serializers.DateField()
atoll = serializers.PrimaryKeyRelatedField(
queryset=Atoll.objects.filter(is_active=True)
)
island = serializers.PrimaryKeyRelatedField(
queryset=Island.objects.filter(is_active=True)
)
terms_accepted = serializers.BooleanField()
policy_accepted = serializers.BooleanField()
def validate_registration_token(self, value):
ticket = RegistrationTicket.objects.active().filter(key=value).first()
if ticket is None:
raise serializers.ValidationError(
"Your number needs to be verified again."
)
return ticket
def validate_full_name(self, value):
name = " ".join(value.split())
if len(name) < 3:
raise serializers.ValidationError("Enter your full name.")
return name
def validate_idnumber(self, value):
return value.strip().upper()
def validate_date_of_birth(self, value):
today = date.today()
if value > today:
raise serializers.ValidationError("Date of birth can't be in the future.")
if value.year < today.year - MAX_AGE_YEARS:
raise serializers.ValidationError("Enter a valid date of birth.")
return value
def validate_terms_accepted(self, value):
if not value:
raise serializers.ValidationError(
"You must agree to the terms and conditions."
)
return value
def validate_policy_accepted(self, value):
if not value:
raise serializers.ValidationError(
"You must confirm you understand the privacy policy."
)
return value
def validate(self, attrs):
island = attrs["island"]
if island.atoll_id != attrs["atoll"].pk:
raise serializers.ValidationError(
{"island": "That island isn't in the selected atoll."}
)
ticket = attrs["registration_token"]
if User.objects.filter(mobile=ticket.mobile).exists():
raise serializers.ValidationError(
{"mobile": "An account already exists for this number."}
)
return attrs
@transaction.atomic
def create(self, validated_data):
ticket = validated_data["registration_token"]
now = timezone.now()
user = User.objects.create_user(
mobile=ticket.mobile,
full_name=validated_data["full_name"],
idnumber=validated_data["idnumber"],
date_of_birth=validated_data["date_of_birth"],
atoll=validated_data["atoll"],
island=validated_data["island"],
auth_method=User.AuthMethod.OTP,
status=User.Status.PENDING,
mobile_verified=True,
terms_accepted_at=now,
policy_accepted_at=now,
)
ticket.consume()
return user
+73
View File
@@ -0,0 +1,73 @@
"""SMS delivery via the SAR Link SMS gateway.
POST {SMS_API_URL}
X-API-Key: {SMS_API_KEY}
{"to": "+9607712345", "text": "..."}
With `SMS_API_URL`/`SMS_API_KEY` unset (dev) nothing is sent and the message is
logged instead, so the OTP flows stay usable without the gateway.
"""
import logging
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
TIMEOUT_SECONDS = 10
def send_sms(mobile: str, text: str) -> bool:
if not settings.SMS_API_URL or not settings.SMS_API_KEY:
logger.warning("SMS not configured; would send to %s: %s", mobile, text)
return False
try:
response = requests.post(
settings.SMS_API_URL,
json={"to": mobile, "text": text},
headers={
"X-API-Key": settings.SMS_API_KEY,
"Content-Type": "application/json",
},
timeout=TIMEOUT_SECONDS,
)
response.raise_for_status()
except requests.RequestException:
logger.exception("Failed to send SMS to %s", mobile)
return False
logger.info("Sent SMS to %s", mobile)
return True
def send_otp(mobile: str, code: str, purpose: str) -> bool:
minutes = max(1, settings.OTP_TTL_SECONDS // 60)
what = "registration" if purpose == "registration" else "login"
return send_sms(
mobile,
f"{code} is your SAR Link {what} code. It expires in {minutes} minutes.",
)
def send_registration_submitted(mobile: str) -> bool:
return send_sms(
mobile,
"Thanks for registering with SAR Link. Your application is being "
"reviewed and we'll text you once it's approved.",
)
def send_registration_approved(mobile: str) -> bool:
return send_sms(
mobile,
f"Your SAR Link registration is approved. Sign in at {settings.FRONTEND_URL}",
)
def send_registration_rejected(mobile: str, reason: str = "") -> bool:
tail = f" Reason: {reason}" if reason else ""
return send_sms(
mobile, f"Your SAR Link registration could not be approved.{tail}"
)
View File
+185
View File
@@ -0,0 +1,185 @@
from django.test import TestCase
from django.urls import reverse
from rest_framework.test import APIClient
from users.models import OtpCode, User
class AuthFlowTests(TestCase):
def setUp(self):
self.client = APIClient()
self.otp_user = User.objects.create_user(mobile="7712345", full_name="Otp User")
self.password_user = User.objects.create_user(
mobile="7798765",
full_name="Password User",
password="correct-horse-battery",
auth_method=User.AuthMethod.PASSWORD,
)
# --- step 1 ---------------------------------------------------------
def test_start_reports_password_method(self):
response = self.client.post(reverse("auth-start"), {"mobile": "7798765"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "password")
self.assertFalse(OtpCode.objects.exists())
def test_start_sends_a_code_for_otp_accounts(self):
response = self.client.post(reverse("auth-start"), {"mobile": "771 2345"})
self.assertEqual(response.data["next"], "otp")
self.assertEqual(response.data["mobile"], "+9607712345")
self.assertEqual(OtpCode.objects.filter(user=self.otp_user).count(), 1)
def test_start_does_not_say_whether_a_number_has_an_account(self):
known = self.client.post(reverse("auth-start"), {"mobile": "7712345"})
unknown = self.client.post(reverse("auth-start"), {"mobile": "7700000"})
self.assertEqual(unknown.status_code, known.status_code)
self.assertEqual(set(unknown.data), set(known.data))
self.assertEqual(unknown.data["next"], "otp")
def test_start_never_returns_a_masked_number(self):
for mobile in ["7798765", "7712345", "7700000"]:
with self.subTest(mobile=mobile):
response = self.client.post(reverse("auth-start"), {"mobile": mobile})
self.assertNotIn("mobile_masked", response.data)
def test_start_falls_back_to_a_code_when_no_password_is_set(self):
self.otp_user.auth_method = User.AuthMethod.PASSWORD
self.otp_user.save(update_fields=["auth_method"])
response = self.client.post(reverse("auth-start"), {"mobile": "7712345"})
self.assertEqual(response.data["next"], "otp")
def test_start_rejects_a_malformed_number(self):
response = self.client.post(reverse("auth-start"), {"mobile": "123"})
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["code"], "invalid")
self.assertIn("mobile", response.data["errors"])
# --- step 2: password ----------------------------------------------
def test_password_login_returns_a_token(self):
response = self.client.post(
reverse("auth-login-password"),
{"mobile": "7798765", "password": "correct-horse-battery"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "dashboard")
self.assertIn("token", response.data)
self.assertEqual(response.data["user"]["mobile"], "+9607798765")
def test_password_login_rejects_a_wrong_password(self):
response = self.client.post(
reverse("auth-login-password"),
{"mobile": "7798765", "password": "nope"},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["code"], "invalid_credentials")
# --- step 2: code ---------------------------------------------------
def _issue_code(self, user=None):
target = user or self.otp_user
return OtpCode.issue(
mobile=target.mobile, purpose=OtpCode.Purpose.LOGIN, user=target
)
def test_verifying_a_code_signs_the_account_in(self):
_, code = self._issue_code()
response = self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": code}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "dashboard")
self.assertIn("token", response.data)
self.otp_user.refresh_from_db()
self.assertTrue(self.otp_user.mobile_verified)
def test_a_code_works_only_once(self):
_, code = self._issue_code()
self.client.post(reverse("auth-verify"), {"mobile": "7712345", "code": code})
response = self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": code}
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["code"], "code_expired")
def test_wrong_code_counts_attempts(self):
self._issue_code()
response = self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": "000000"}
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["code"], "invalid_code")
self.assertEqual(response.data["attempts_left"], 4)
def test_code_is_exhausted_after_max_attempts(self):
self._issue_code()
for _ in range(5):
self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": "000000"}
)
response = self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": "000000"}
)
self.assertEqual(response.status_code, 429)
self.assertEqual(response.data["code"], "code_exhausted")
def test_issuing_a_new_code_invalidates_the_previous_one(self):
_, first = self._issue_code()
_, second = self._issue_code()
self.assertEqual(OtpCode.objects.active().count(), 1)
response = self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": first}
)
self.assertEqual(response.status_code, 400)
response = self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": second}
)
self.assertEqual(response.status_code, 200)
def test_a_disabled_account_is_told_only_after_verifying(self):
self.otp_user.is_active = False
self.otp_user.save(update_fields=["is_active"])
start = self.client.post(reverse("auth-start"), {"mobile": "7712345"})
self.assertEqual(start.data["next"], "otp")
_, code = self._issue_code()
response = self.client.post(
reverse("auth-verify"), {"mobile": "7712345", "code": code}
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.data["code"], "account_disabled")
# --- resend ----------------------------------------------------------
def test_resend_is_rate_limited_by_the_cooldown(self):
self.client.post(reverse("auth-start"), {"mobile": "7712345"})
response = self.client.post(reverse("auth-otp-resend"), {"mobile": "7712345"})
self.assertEqual(response.status_code, 429)
self.assertEqual(response.data["code"], "resend_cooldown")
def test_resend_works_for_a_number_without_an_account(self):
response = self.client.post(reverse("auth-otp-resend"), {"mobile": "7700000"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "otp")
self.assertTrue(OtpCode.objects.filter(mobile="+9607700000").exists())
def test_resend_does_not_reveal_that_an_account_uses_a_password(self):
response = self.client.post(reverse("auth-otp-resend"), {"mobile": "7798765"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "otp")
self.assertFalse(OtpCode.objects.exists())
# --- session --------------------------------------------------------
def test_me_requires_a_token(self):
self.assertEqual(self.client.get(reverse("auth-me")).status_code, 401)
def test_me_returns_the_signed_in_account(self):
login = self.client.post(
reverse("auth-login-password"),
{"mobile": "7798765", "password": "correct-horse-battery"},
)
self.client.credentials(HTTP_AUTHORIZATION=f"Token {login.data['token']}")
response = self.client.get(reverse("auth-me"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["mobile"], "+9607798765")
self.assertFalse(response.data["is_admin"])
+17
View File
@@ -0,0 +1,17 @@
from django.core.exceptions import ValidationError
from django.test import SimpleTestCase
from users.mobile import normalize_mobile
class NormalizeMobileTests(SimpleTestCase):
def test_accepts_every_way_a_number_is_typed(self):
for value in ["7712345", "+9607712345", "9607712345", "960 771 2345", "009607712345", "771-2345"]:
with self.subTest(value=value):
self.assertEqual(normalize_mobile(value), "+9607712345")
def test_rejects_invalid_numbers(self):
for value in ["", "123", "1712345", "77123456", "abcdefg", None]:
with self.subTest(value=value):
with self.assertRaises(ValidationError):
normalize_mobile(value)
+250
View File
@@ -0,0 +1,250 @@
from datetime import date, timedelta
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from locations.models import Atoll, Island
from users.models import OtpCode, RegistrationTicket, User
class RegistrationFlowTests(TestCase):
def setUp(self):
self.client = APIClient()
self.atoll = Atoll.objects.get(name="Faafu")
self.island = Island.objects.get(name="Dharanboodhoo")
self.other_atoll = Atoll.objects.create(name="Kaafu", code="K")
self.other_island = Island.objects.create(atoll=self.other_atoll, name="Male")
# --- verifying the number -------------------------------------------
def test_unknown_number_gets_a_code_without_being_told_anything(self):
response = self.client.post(reverse("auth-start"), {"mobile": "7700000"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "otp")
self.assertEqual(response.data["mobile"], "+9607700000")
otp = OtpCode.objects.get(mobile="+9607700000")
self.assertEqual(otp.purpose, OtpCode.Purpose.REGISTRATION)
self.assertIsNone(otp.user)
def test_verifying_an_unknown_number_hands_back_a_ticket(self):
code = self._send_registration_code("7700000")
response = self.client.post(
reverse("auth-verify"), {"mobile": "7700000", "code": code}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "register")
self.assertTrue(response.data["registration_token"])
self.assertEqual(response.data["mobile"], "+9607700000")
self.assertNotIn("token", response.data)
def test_verify_rejects_a_wrong_code(self):
self._send_registration_code("7700000")
response = self.client.post(
reverse("auth-verify"), {"mobile": "7700000", "code": "000000"}
)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.data["code"], "invalid_code")
self.assertFalse(RegistrationTicket.objects.exists())
def test_a_code_signs_in_an_account_created_in_the_meantime(self):
code = self._send_registration_code("7700000")
User.objects.create_user(mobile="7700000")
response = self.client.post(
reverse("auth-verify"), {"mobile": "7700000", "code": code}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "dashboard")
self.assertFalse(RegistrationTicket.objects.exists())
# --- the form -------------------------------------------------------
def test_registration_creates_a_pending_account(self):
token = self._verified_token("7700000")
response = self.client.post(reverse("auth-register"), self._form(token))
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["status"], "pending")
# No token: registering does not sign anyone in.
self.assertNotIn("token", response.data)
user = User.objects.get(mobile="+9607700000")
self.assertEqual(user.status, User.Status.PENDING)
self.assertEqual(user.full_name, "Ahmed Ali")
self.assertEqual(user.idnumber, "WP123456")
self.assertEqual(user.date_of_birth, date(1995, 4, 17))
self.assertEqual(user.island, self.island)
self.assertTrue(user.mobile_verified)
self.assertFalse(user.has_usable_password())
self.assertIsNotNone(user.terms_accepted_at)
self.assertIsNotNone(user.policy_accepted_at)
def test_the_number_comes_from_the_token_not_the_form(self):
token = self._verified_token("7700000")
payload = self._form(token) | {"mobile": "7711111"}
self.client.post(reverse("auth-register"), payload)
self.assertTrue(User.objects.filter(mobile="+9607700000").exists())
self.assertFalse(User.objects.filter(mobile="+9607711111").exists())
def test_a_token_works_only_once(self):
token = self._verified_token("7700000")
self.client.post(reverse("auth-register"), self._form(token))
User.objects.all().delete()
response = self.client.post(reverse("auth-register"), self._form(token))
self.assertEqual(response.status_code, 400)
self.assertIn("registration_token", response.data["errors"])
def test_an_expired_token_is_refused(self):
token = self._verified_token("7700000")
RegistrationTicket.objects.filter(key=token).update(
expires_at=timezone.now() - timedelta(minutes=1)
)
response = self.client.post(reverse("auth-register"), self._form(token))
self.assertEqual(response.status_code, 400)
self.assertIn("registration_token", response.data["errors"])
def test_registration_requires_a_token(self):
payload = self._form("nope")
response = self.client.post(reverse("auth-register"), payload)
self.assertEqual(response.status_code, 400)
self.assertIn("registration_token", response.data["errors"])
def test_both_agreements_are_required(self):
for field in ["terms_accepted", "policy_accepted"]:
with self.subTest(field=field):
token = self._verified_token("7700000")
payload = self._form(token) | {field: False}
response = self.client.post(reverse("auth-register"), payload)
self.assertEqual(response.status_code, 400)
self.assertIn(field, response.data["errors"])
self.assertFalse(User.objects.exists())
def test_island_must_belong_to_the_selected_atoll(self):
token = self._verified_token("7700000")
payload = self._form(token) | {"island": self.other_island.pk}
response = self.client.post(reverse("auth-register"), payload)
self.assertEqual(response.status_code, 400)
self.assertIn("island", response.data["errors"])
def test_required_fields(self):
token = self._verified_token("7700000")
response = self.client.post(
reverse("auth-register"), {"registration_token": token}
)
self.assertEqual(response.status_code, 400)
for field in [
"full_name",
"idnumber",
"date_of_birth",
"atoll",
"island",
]:
self.assertIn(field, response.data["errors"])
def test_future_date_of_birth_is_refused(self):
token = self._verified_token("7700000")
payload = self._form(token) | {
"date_of_birth": (date.today() + timedelta(days=1)).isoformat()
}
response = self.client.post(reverse("auth-register"), payload)
self.assertEqual(response.status_code, 400)
self.assertIn("date_of_birth", response.data["errors"])
def test_cannot_register_a_number_that_already_has_an_account(self):
token = self._verified_token("7700000")
User.objects.create_user(mobile="7700000")
response = self.client.post(reverse("auth-register"), self._form(token))
self.assertEqual(response.status_code, 400)
self.assertIn("mobile", response.data["errors"])
# --- after submitting ------------------------------------------------
def test_a_pending_applicant_can_sign_in_and_see_their_status(self):
token = self._verified_token("7700000")
self.client.post(reverse("auth-register"), self._form(token))
start = self.client.post(reverse("auth-start"), {"mobile": "7700000"})
self.assertEqual(start.data["next"], "otp")
otp = OtpCode.objects.active().filter(mobile="+9607700000").first()
self.assertIsNotNone(otp)
self.assertEqual(otp.purpose, OtpCode.Purpose.LOGIN)
_, code = OtpCode.issue(
mobile="+9607700000",
purpose=OtpCode.Purpose.LOGIN,
user=User.objects.get(mobile="+9607700000"),
)
response = self.client.post(
reverse("auth-verify"), {"mobile": "7700000", "code": code}
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["next"], "dashboard")
self.assertEqual(response.data["user"]["status"], "pending")
def test_approval_flips_the_status_and_records_the_reviewer(self):
admin = User.objects.create_superuser(mobile="7711111", password="x")
token = self._verified_token("7700000")
self.client.post(reverse("auth-register"), self._form(token))
user = User.objects.get(mobile="+9607700000")
user.approve(reviewer=admin)
user.refresh_from_db()
self.assertEqual(user.status, User.Status.APPROVED)
self.assertTrue(user.is_approved)
self.assertEqual(user.reviewed_by, admin)
self.assertIsNotNone(user.reviewed_at)
def test_rejection_records_the_reason(self):
token = self._verified_token("7700000")
self.client.post(reverse("auth-register"), self._form(token))
user = User.objects.get(mobile="+9607700000")
user.reject(reason="Document unreadable")
user.refresh_from_db()
self.assertEqual(user.status, User.Status.REJECTED)
self.assertEqual(user.rejection_reason, "Document unreadable")
# --- helpers ---------------------------------------------------------
def _send_registration_code(self, mobile: str) -> str:
otp, code = OtpCode.issue(
mobile=f"+960{mobile}", purpose=OtpCode.Purpose.REGISTRATION
)
self.assertIsNotNone(otp)
return code
def _verified_token(self, mobile: str) -> str:
code = self._send_registration_code(mobile)
response = self.client.post(
reverse("auth-verify"), {"mobile": mobile, "code": code}
)
self.assertEqual(response.status_code, 200, response.data)
return response.data["registration_token"]
def _form(self, token: str) -> dict:
return {
"registration_token": token,
"full_name": "Ahmed Ali",
"idnumber": "wp123456",
"date_of_birth": "1995-04-17",
"atoll": self.atoll.pk,
"island": self.island.pk,
"terms_accepted": True,
"policy_accepted": True,
}
class LocationsApiTests(TestCase):
def test_atolls_are_public_and_include_islands(self):
response = APIClient().get(reverse("atoll-list"))
self.assertEqual(response.status_code, 200)
names = {atoll["name"]: atoll for atoll in response.data}
self.assertIn("Faafu", names)
self.assertEqual(
[island["name"] for island in names["Faafu"]["islands"]],
["Dharanboodhoo"],
)
+22
View File
@@ -0,0 +1,22 @@
from django.urls import path
from knox import views as knox_views
from .views import (
AuthStartView,
MeView,
OtpResendView,
PasswordLoginView,
RegisterView,
VerifyCodeView,
)
urlpatterns = [
path("start/", AuthStartView.as_view(), name="auth-start"),
path("login/password/", PasswordLoginView.as_view(), name="auth-login-password"),
path("verify/", VerifyCodeView.as_view(), name="auth-verify"),
path("otp/resend/", OtpResendView.as_view(), name="auth-otp-resend"),
path("register/", RegisterView.as_view(), name="auth-register"),
path("me/", MeView.as_view(), name="auth-me"),
path("logout/", knox_views.LogoutView.as_view(), name="auth-logout"),
path("logout-all/", knox_views.LogoutAllView.as_view(), name="auth-logout-all"),
]
+262
View File
@@ -0,0 +1,262 @@
"""Authentication and registration.
1. POST /api/auth/start/ {mobile}
-> {"next": "password"} the account signs in with a password
-> {"next": "otp", ...} a code was sent by SMS
2. POST /api/auth/login/password/ {mobile, password}
-> {"next": "dashboard", "token", "expiry", "user"}
POST /api/auth/verify/ {mobile, code}
-> {"next": "dashboard", "token", "expiry", "user"}
-> {"next": "register", "registration_token", "mobile", "expires_at"}
3. POST /api/auth/register/ {registration_token, ...form}
-> 201 {"status": "pending", ...}
Whether a number has an account is only answered once its owner has confirmed
a code, so `start` looks the same for every valid number.
"""
import logging
from django.conf import settings
from django.contrib.auth import authenticate
from django.utils import timezone
from knox.views import LoginView as KnoxLoginView
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from .models import OtpCode, RegistrationTicket, User
from .serializers import (
AuthStartSerializer,
OtpVerifySerializer,
PasswordLoginSerializer,
RegistrationSerializer,
UserSerializer,
)
from .sms import send_otp, send_registration_submitted
logger = logging.getLogger(__name__)
CODE_LENGTH = 6
def _issue_code(mobile: str, user: User | None) -> OtpCode:
purpose = OtpCode.Purpose.LOGIN if user else OtpCode.Purpose.REGISTRATION
otp, code = OtpCode.issue(mobile=mobile, purpose=purpose, user=user)
send_otp(mobile, code, purpose)
return otp
def _code_payload(mobile: str, otp: OtpCode | None = None) -> dict:
payload = {"next": "otp", "mobile": mobile, "code_length": CODE_LENGTH}
if otp is not None:
payload["expires_at"] = otp.expires_at
payload["resend_available_at"] = otp.resend_available_at
return payload
class AuthStartView(APIView):
"""Step 1: password box, or a code sent by SMS."""
authentication_classes = []
permission_classes = [AllowAny]
throttle_scope = "auth_start"
def post(self, request):
serializer = AuthStartSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
mobile = serializer.validated_data["mobile"]
user = User.objects.filter(mobile=mobile).first()
if user is not None and user.effective_auth_method == User.AuthMethod.PASSWORD:
return Response({"next": "password", "mobile": mobile})
return Response(_code_payload(mobile, _issue_code(mobile, user)))
class OtpResendView(APIView):
"""Send a fresh code, honouring the cooldown."""
authentication_classes = []
permission_classes = [AllowAny]
throttle_scope = "otp_request"
def post(self, request):
serializer = AuthStartSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
mobile = serializer.validated_data["mobile"]
user = User.objects.filter(mobile=mobile).first()
if user is not None and user.effective_auth_method == User.AuthMethod.PASSWORD:
return Response(_code_payload(mobile))
latest = OtpCode.objects.active().filter(mobile=mobile).first()
if latest and latest.resend_available_at > timezone.now():
return Response(
{
"detail": "A code was just sent. Try again shortly.",
"code": "resend_cooldown",
"resend_available_at": latest.resend_available_at,
},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
return Response(_code_payload(mobile, _issue_code(mobile, user)))
class BaseLoginView(KnoxLoginView):
"""Issues a knox token, with the account serialised alongside it."""
authentication_classes = []
permission_classes = [AllowAny]
def issue_token(self, request, user):
request.user = user
return super().post(request, format=None)
def get_post_response_data(self, request, token, instance):
data = super().get_post_response_data(request, token, instance)
data["next"] = "dashboard"
data["user"] = UserSerializer(request.user).data
return data
class PasswordLoginView(BaseLoginView):
"""Step 2, password."""
throttle_scope = "auth_login"
def post(self, request, format=None):
serializer = PasswordLoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = authenticate(
request,
username=serializer.validated_data["mobile"],
password=serializer.validated_data["password"],
)
if user is None:
return Response(
{
"detail": "Incorrect mobile number or password.",
"code": "invalid_credentials",
},
status=status.HTTP_400_BAD_REQUEST,
)
return self.issue_token(request, user)
class VerifyCodeView(BaseLoginView):
"""Step 2, SMS code.
A confirmed code either signs the account in or, when the number has no
account, hands back the ticket the registration form needs.
"""
throttle_scope = "auth_login"
def post(self, request, format=None):
serializer = OtpVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
mobile = serializer.validated_data["mobile"]
otp = (
OtpCode.objects.active()
.filter(mobile=mobile)
.select_related("user")
.first()
)
if otp is None:
return Response(
{
"detail": "That code has expired. Request a new one.",
"code": "code_expired",
},
status=status.HTTP_400_BAD_REQUEST,
)
if otp.is_exhausted:
return Response(
{
"detail": "Too many incorrect attempts. Request a new code.",
"code": "code_exhausted",
},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
if not otp.verify(serializer.validated_data["code"]):
return Response(
{
"detail": "That code is not correct.",
"code": "invalid_code",
"attempts_left": max(0, settings.OTP_MAX_ATTEMPTS - otp.attempts),
},
status=status.HTTP_400_BAD_REQUEST,
)
user = otp.user or User.objects.filter(mobile=mobile).first()
if user is None:
ticket = RegistrationTicket.issue(mobile)
return Response(
{
"next": "register",
"registration_token": ticket.key,
"mobile": mobile,
"expires_at": ticket.expires_at,
}
)
if not user.is_active:
return Response(
{"detail": "This account is disabled.", "code": "account_disabled"},
status=status.HTTP_403_FORBIDDEN,
)
if not user.mobile_verified:
user.mobile_verified = True
user.save(update_fields=["mobile_verified", "updated_at"])
return self.issue_token(request, user)
class RegisterView(APIView):
"""Submit the registration form. Creates a pending account - no token."""
authentication_classes = []
permission_classes = [AllowAny]
throttle_scope = "register"
def post(self, request):
serializer = RegistrationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
send_registration_submitted(user.mobile)
logger.info("Registration submitted for %s (user %s)", user.mobile, user.pk)
return Response(
{
"status": user.status,
"mobile": user.mobile,
"full_name": user.full_name,
"detail": (
"Your registration is pending approval. We'll text you "
"once it has been reviewed."
),
},
status=status.HTTP_201_CREATED,
)
class MeView(APIView):
"""The signed-in account, for the SPA to hydrate its session."""
permission_classes = [IsAuthenticated]
def get(self, request):
return Response(UserSerializer(request.user).data)