feat: DB constraints for phone auth

This commit is contained in:
2026-03-14 00:31:20 +03:00
parent 4026b94c3a
commit 5ece1036cd
4 changed files with 60 additions and 4 deletions
@@ -0,0 +1,25 @@
# Generated by Django 6.0.3 on 2026-03-13 20:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0003_preferred_language"),
]
operations = [
migrations.AlterField(
model_name="user",
name="phone_number",
field=models.CharField(max_length=20, unique=True),
),
migrations.AddConstraint(
model_name="user",
constraint=models.CheckConstraint(
condition=models.Q(("phone_number__regex", r"^\+[1-9][0-9]{7,14}$")),
name="accounts_user_phone_e164_format",
),
),
]
+10 -1
View File
@@ -4,6 +4,7 @@ from datetime import timedelta
from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
from django.db import models
from django.db.models import Q
from django.utils import timezone
@@ -42,7 +43,7 @@ class UserManager(BaseUserManager):
class User(AbstractBaseUser, PermissionsMixin):
email = models.EmailField(unique=True, null=True, blank=True)
phone_number = models.CharField(max_length=20, unique=True, null=True, blank=True)
phone_number = models.CharField(max_length=20, unique=True)
role = models.CharField(max_length=20, choices=UserRole.choices, default=UserRole.CUSTOMER)
first_name = models.CharField(max_length=150, blank=True)
last_name = models.CharField(max_length=150, blank=True)
@@ -62,6 +63,14 @@ class User(AbstractBaseUser, PermissionsMixin):
USERNAME_FIELD = "phone_number"
REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier
class Meta:
constraints = [
models.CheckConstraint(
name="accounts_user_phone_e164_format",
condition=Q(phone_number__regex=r"^\+[1-9][0-9]{7,14}$"),
),
]
def __str__(self):
return self.email or self.phone_number or str(self.id)
@@ -1,6 +1,7 @@
from unittest.mock import patch
import pytest
from django.db import IntegrityError
from django.urls import reverse
from apps.accounts.models import OtpPurpose, PhoneOTP, User
@@ -69,3 +70,24 @@ def test_otp_verify_rejects_auth_purpose_otp(client):
)
assert response.status_code == 400
@pytest.mark.django_db
def test_db_rejects_null_phone_number():
# DB invariant: phone_number is mandatory.
with pytest.raises(IntegrityError):
User.objects.create(email="null-phone@example.com")
@pytest.mark.django_db
def test_db_rejects_non_e164_phone_number():
# DB invariant: store normalized E.164 only.
with pytest.raises(IntegrityError):
User.objects.create_user(phone_number="0512345678")
@pytest.mark.django_db
def test_db_rejects_duplicate_phone_number():
User.objects.create_user(phone_number="+966512345678")
with pytest.raises(IntegrityError):
User.objects.create_user(phone_number="+966512345678")
@@ -17,18 +17,18 @@ def booking_payload():
email="owner@example.com",
password="pass",
role=UserRole.MANAGER,
phone_number="0500000001",
phone_number="+966500000001",
)
customer = User.objects.create_user(
email="customer@example.com",
password="pass",
phone_number="0500000002",
phone_number="+966500000002",
)
staff_user = User.objects.create_user(
email="staff@example.com",
password="pass",
role=UserRole.STAFF,
phone_number="0500000003",
phone_number="+966500000003",
)
salon = Salon.objects.create(