feat: deprecate email, pre-verify users + documentation
This commit is contained in:
@@ -5,7 +5,9 @@
|
||||
- External calls (OTP, notifications, payment gateway) run synchronously in request/response paths, increasing latency risk.
|
||||
- Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries.
|
||||
- Phone-first auth is in place with `USERNAME_FIELD = "phone_number"`, but endpoint/admin/domain alignment is still incomplete and needs hardening.
|
||||
- Phone auth now pre-creates customers when `/api/auth/phone/request/` runs (keeping `is_phone_verified=False`) and `/api/auth/phone/verify/` hands out JWTs; `/api/auth/register/` stays available for optional profile data while `/api/auth/token/` returns `410 Gone` and `/api/auth/social/<provider>/` remains a `501 Not Implemented` placeholder to keep the phone OTP contract explicit.
|
||||
|
||||
## Near-Term Focus
|
||||
- finalize otp testing
|
||||
- work on authentication and complete it
|
||||
- align admin + serializers to favor phone-over-email display names so phone-only accounts stay readable everywhere
|
||||
|
||||
@@ -7,12 +7,12 @@ from apps.accounts.models import PhoneOTP, User
|
||||
@admin.register(User)
|
||||
class UserAdmin(DjangoUserAdmin):
|
||||
model = User
|
||||
list_display = ("email", "phone_number", "role", "is_staff", "is_phone_verified")
|
||||
list_display = ("phone_number", "email", "role", "is_staff", "is_phone_verified")
|
||||
list_filter = ("role", "is_staff", "is_phone_verified")
|
||||
ordering = ("email",)
|
||||
ordering = ("phone_number",)
|
||||
search_fields = ("email", "phone_number")
|
||||
fieldsets = (
|
||||
(None, {"fields": ("email", "password")}),
|
||||
(None, {"fields": ("phone_number", "password")}),
|
||||
("Personal", {"fields": ("first_name", "last_name", "phone_number")}),
|
||||
("Roles", {"fields": ("role", "is_phone_verified")}),
|
||||
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
|
||||
@@ -21,7 +21,7 @@ class UserAdmin(DjangoUserAdmin):
|
||||
add_fieldsets = (
|
||||
(None, {
|
||||
"classes": ("wide",),
|
||||
"fields": ("email", "password1", "password2", "role"),
|
||||
"fields": ("phone_number", "password1", "password2", "role"),
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -71,8 +71,18 @@ class User(AbstractBaseUser, PermissionsMixin):
|
||||
),
|
||||
]
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
first = (self.first_name or "").strip()
|
||||
last = (self.last_name or "").strip()
|
||||
if first or last:
|
||||
return f"{first} {last}".strip()
|
||||
if self.email:
|
||||
return self.email
|
||||
return self.phone_number
|
||||
|
||||
def __str__(self):
|
||||
return self.email or self.phone_number or str(self.id)
|
||||
return self.display_name
|
||||
|
||||
|
||||
class OtpChannel(models.TextChoices):
|
||||
|
||||
@@ -76,3 +76,25 @@ def test_phone_auth_refresh_endpoint_still_works(client):
|
||||
)
|
||||
assert refresh_response.status_code == 200
|
||||
assert "access" in refresh_response.json()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@override_settings(OTP_PROVIDER="console")
|
||||
def test_phone_auth_verify_returns_404_when_user_removed(client):
|
||||
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
|
||||
request_response = client.post(
|
||||
reverse("phone_auth_request"),
|
||||
{"phone_number": "0512345678", "channel": "sms"},
|
||||
content_type="application/json",
|
||||
)
|
||||
request_id = request_response.json()["request_id"]
|
||||
|
||||
User.objects.filter(phone_number="+966512345678").delete()
|
||||
|
||||
verify_response = client.post(
|
||||
reverse("phone_auth_verify"),
|
||||
{"request_id": request_id, "code": "123456"},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert verify_response.status_code == 404
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import pytest
|
||||
|
||||
from apps.accounts.models import User
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_display_name_prefers_full_name():
|
||||
user = User.objects.create_user(
|
||||
phone_number="+966500000001",
|
||||
first_name="Sara",
|
||||
last_name="Ali",
|
||||
email="sara@example.com",
|
||||
)
|
||||
|
||||
assert user.display_name == "Sara Ali"
|
||||
assert str(user) == "Sara Ali"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_display_name_falls_back_to_email():
|
||||
user = User.objects.create_user(
|
||||
phone_number="+966500000002",
|
||||
email="fallback@example.com",
|
||||
)
|
||||
|
||||
assert user.display_name == "fallback@example.com"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_display_name_falls_back_to_phone_when_no_email():
|
||||
user = User.objects.create_user(
|
||||
phone_number="+966500000003",
|
||||
)
|
||||
|
||||
assert user.display_name == "+966500000003"
|
||||
@@ -29,4 +29,4 @@ class Booking(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.customer.email} - {self.service.name}"
|
||||
return f"{self.customer.display_name} - {self.service.name}"
|
||||
|
||||
@@ -34,9 +34,7 @@ class BookingSerializer(serializers.ModelSerializer):
|
||||
def get_staff_name(self, obj):
|
||||
if not obj.staff:
|
||||
return None
|
||||
first = obj.staff.user.first_name or ""
|
||||
last = obj.staff.user.last_name or ""
|
||||
return (first + " " + last).strip() or obj.staff.user.email
|
||||
return obj.staff.user.display_name
|
||||
|
||||
def validate(self, attrs):
|
||||
if not self.instance or "status" not in attrs:
|
||||
|
||||
@@ -56,7 +56,7 @@ class StaffProfile(models.Model):
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} - {self.salon.name}"
|
||||
return f"{self.user.display_name} - {self.salon.name}"
|
||||
|
||||
|
||||
class StaffAvailability(models.Model):
|
||||
@@ -84,7 +84,10 @@ class StaffAvailability(models.Model):
|
||||
ordering = ["staff_id", "day_of_week", "start_time"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.staff.user.email} {self.get_day_of_week_display()} {self.start_time}-{self.end_time}"
|
||||
return (
|
||||
f"{self.staff.user.display_name} {self.get_day_of_week_display()} "
|
||||
f"{self.start_time}-{self.end_time}"
|
||||
)
|
||||
|
||||
|
||||
class Review(models.Model):
|
||||
@@ -95,4 +98,4 @@ class Review(models.Model):
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return f"Review {self.rating} for {self.salon.name}"
|
||||
return f"Review {self.rating} by {self.customer.display_name} for {self.salon.name}"
|
||||
|
||||
@@ -26,9 +26,7 @@ class StaffSerializer(serializers.ModelSerializer):
|
||||
fields = ["id", "name", "title", "bio", "is_active"]
|
||||
|
||||
def get_name(self, obj):
|
||||
first = obj.user.first_name or ""
|
||||
last = obj.user.last_name or ""
|
||||
return (first + " " + last).strip() or obj.user.email
|
||||
return obj.user.display_name
|
||||
|
||||
|
||||
class ReviewSerializer(serializers.ModelSerializer):
|
||||
@@ -39,9 +37,7 @@ class ReviewSerializer(serializers.ModelSerializer):
|
||||
fields = ["id", "rating", "comment", "created_at", "customer_name"]
|
||||
|
||||
def get_customer_name(self, obj):
|
||||
first = obj.customer.first_name or ""
|
||||
last = obj.customer.last_name or ""
|
||||
return (first + " " + last).strip() or obj.customer.email
|
||||
return obj.customer.display_name
|
||||
|
||||
|
||||
class SalonSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
from datetime import timedelta, time
|
||||
|
||||
import pytest
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import User, UserRole
|
||||
from apps.bookings.models import Booking
|
||||
from apps.bookings.serializers import BookingSerializer
|
||||
from apps.salons.models import (
|
||||
Salon,
|
||||
Service,
|
||||
StaffAvailability,
|
||||
StaffProfile,
|
||||
Review,
|
||||
)
|
||||
from apps.salons.serializers import ReviewSerializer, StaffSerializer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestDisplayNameFallbacks:
|
||||
def _create_customer(self, phone_number):
|
||||
return User.objects.create_user(phone_number=phone_number)
|
||||
|
||||
def _create_staff_user(self, phone_number):
|
||||
return User.objects.create_user(phone_number=phone_number, role=UserRole.STAFF)
|
||||
|
||||
def _create_salon(self, owner):
|
||||
return Salon.objects.create(
|
||||
owner=owner,
|
||||
name="Test Salon",
|
||||
address="123 Main",
|
||||
city="Riyadh",
|
||||
)
|
||||
|
||||
def _create_service(self, salon):
|
||||
return Service.objects.create(
|
||||
salon=salon,
|
||||
name="Haircut",
|
||||
description="",
|
||||
duration_minutes=60,
|
||||
price_amount=200,
|
||||
currency="SAR",
|
||||
)
|
||||
|
||||
def test_staff_serializer_falls_back_to_phone(self):
|
||||
owner = User.objects.create_user(phone_number="+966500000001", email="owner@example.com")
|
||||
salon = self._create_salon(owner)
|
||||
staff_user = self._create_staff_user(phone_number="+966500000002")
|
||||
staff_profile = StaffProfile.objects.create(user=staff_user, salon=salon)
|
||||
|
||||
serializer = StaffSerializer(staff_profile)
|
||||
|
||||
assert serializer.data["name"] == "+966500000002"
|
||||
|
||||
def test_review_serializer_customer_name_uses_phone(self):
|
||||
owner = User.objects.create_user(phone_number="+966500000003", email="owner2@example.com")
|
||||
salon = self._create_salon(owner)
|
||||
customer = self._create_customer(phone_number="+966500000004")
|
||||
review = Review.objects.create(salon=salon, customer=customer, rating=5, comment="Great")
|
||||
|
||||
serializer = ReviewSerializer(review)
|
||||
|
||||
assert serializer.data["customer_name"] == "+966500000004"
|
||||
assert "+966500000004" in str(review)
|
||||
|
||||
def test_booking_serializer_staff_name_and_str_use_phone(self):
|
||||
owner = User.objects.create_user(phone_number="+966500000005", email="owner3@example.com")
|
||||
salon = self._create_salon(owner)
|
||||
staff_user = self._create_staff_user(phone_number="+966500000006")
|
||||
staff_profile = StaffProfile.objects.create(user=staff_user, salon=salon)
|
||||
service = self._create_service(salon)
|
||||
customer = self._create_customer(phone_number="+966500000007")
|
||||
start = timezone.now()
|
||||
booking = Booking.objects.create(
|
||||
salon=salon,
|
||||
customer=customer,
|
||||
service=service,
|
||||
staff=staff_profile,
|
||||
start_time=start,
|
||||
end_time=start + timedelta(hours=1),
|
||||
price_amount=service.price_amount,
|
||||
currency=service.currency,
|
||||
)
|
||||
|
||||
serializer = BookingSerializer(booking)
|
||||
|
||||
assert serializer.data["staff_name"] == "+966500000006"
|
||||
assert "+966500000007" in str(booking)
|
||||
|
||||
def test_staff_model_str_uses_phone(self):
|
||||
owner = User.objects.create_user(phone_number="+966500000008", email="owner4@example.com")
|
||||
salon = self._create_salon(owner)
|
||||
staff_user = self._create_staff_user(phone_number="+966500000009")
|
||||
staff_profile = StaffProfile.objects.create(user=staff_user, salon=salon)
|
||||
availability = StaffAvailability.objects.create(
|
||||
staff=staff_profile,
|
||||
day_of_week=0,
|
||||
start_time=time(9, 0),
|
||||
end_time=time(10, 0),
|
||||
)
|
||||
|
||||
assert "+966500000009" in str(staff_profile)
|
||||
assert "+966500000009" in str(availability)
|
||||
Reference in New Issue
Block a user