Updated PLANS.md, AGENTS.md, and arabic-localization.md to reflect the “foundations now, full translations later” approach and marked progress accordingly.

Implemented localization foundations across backend and frontend (locale settings/middleware, preferred language, i18n wiring, RTL support, minimal Arabic UI strings, Accept-Language).
Added targeted backend and frontend tests plus a risks note for pending full translation coverage.
This commit is contained in:
2026-02-28 11:48:58 +03:00
parent fd90af33b3
commit d40bb10876
27 changed files with 407 additions and 68 deletions
+28
View File
@@ -0,0 +1,28 @@
from django.utils import translation
class UserLocaleMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
language = None
activated = False
user = getattr(request, "user", None)
if user and getattr(user, "is_authenticated", False):
language = getattr(user, "preferred_language", None)
if language:
translation.activate(language)
request.LANGUAGE_CODE = language
activated = True
response = self.get_response(request)
active_language = translation.get_language()
if active_language:
response["Content-Language"] = active_language
if activated:
translation.deactivate()
return response
@@ -0,0 +1,19 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_phone_auth_fields"),
]
operations = [
migrations.AddField(
model_name="user",
name="preferred_language",
field=models.CharField(
choices=[("ar-sa", "Arabic (Saudi Arabia)"), ("en", "English")],
default="ar-sa",
max_length=10,
),
),
]
+5
View File
@@ -47,6 +47,11 @@ class User(AbstractBaseUser, PermissionsMixin):
first_name = models.CharField(max_length=150, blank=True)
last_name = models.CharField(max_length=150, blank=True)
is_phone_verified = models.BooleanField(default=False)
preferred_language = models.CharField(
max_length=10,
choices=settings.LANGUAGES,
default=settings.LANGUAGE_CODE,
)
is_active = models.BooleanField(default=True)
is_staff = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
+10 -1
View File
@@ -10,7 +10,16 @@ User = get_user_model()
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "email", "phone_number", "first_name", "last_name", "role", "is_phone_verified"]
fields = [
"id",
"email",
"phone_number",
"first_name",
"last_name",
"role",
"is_phone_verified",
"preferred_language",
]
read_only_fields = ["id", "role", "is_phone_verified"]
+17 -13
View File
@@ -8,6 +8,8 @@ from django.conf import settings
from django.contrib.auth.hashers import check_password, make_password
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
logger = logging.getLogger(__name__)
@@ -21,13 +23,13 @@ class OtpSendResult:
class OtpRateLimitError(RuntimeError):
def __init__(self, retry_after_seconds: int):
super().__init__("Too many OTP requests. Try again later.")
super().__init__(_("Too many OTP requests. Try again later."))
self.retry_after_seconds = retry_after_seconds
class OtpCooldownError(RuntimeError):
def __init__(self, retry_after_seconds: int):
super().__init__("Please wait before requesting another code.")
super().__init__(_("Please wait before requesting another code."))
self.retry_after_seconds = retry_after_seconds
@@ -56,17 +58,17 @@ class TwilioOtpProvider(BaseOtpProvider):
def _assert_config(self) -> None:
if not self.account_sid or not self.auth_token or not self.from_number:
raise ValueError("Twilio credentials are not configured")
raise ValueError(_("Twilio credentials are not configured"))
def send_sms(self, to_number: str, message: str) -> None:
self._assert_config()
raise NotImplementedError("Twilio SMS adapter not implemented yet")
raise NotImplementedError(_("Twilio SMS adapter not implemented yet"))
def send_whatsapp(self, to_number: str, message: str) -> None:
self._assert_config()
if not self.whatsapp_from:
raise ValueError("Twilio WhatsApp sender is not configured")
raise NotImplementedError("Twilio WhatsApp adapter not implemented yet")
raise ValueError(_("Twilio WhatsApp sender is not configured"))
raise NotImplementedError(_("Twilio WhatsApp adapter not implemented yet"))
class UnifonicOtpProvider(BaseOtpProvider):
@@ -77,17 +79,17 @@ class UnifonicOtpProvider(BaseOtpProvider):
def _assert_config(self) -> None:
if not self.app_sid or not self.sender_id:
raise ValueError("Unifonic credentials are not configured")
raise ValueError(_("Unifonic credentials are not configured"))
def send_sms(self, to_number: str, message: str) -> None:
self._assert_config()
raise NotImplementedError("Unifonic SMS adapter not implemented yet")
raise NotImplementedError(_("Unifonic SMS adapter not implemented yet"))
def send_whatsapp(self, to_number: str, message: str) -> None:
self._assert_config()
if not self.whatsapp_sender:
raise ValueError("Unifonic WhatsApp sender is not configured")
raise NotImplementedError("Unifonic WhatsApp adapter not implemented yet")
raise ValueError(_("Unifonic WhatsApp sender is not configured"))
raise NotImplementedError(_("Unifonic WhatsApp adapter not implemented yet"))
PROVIDERS = {
@@ -101,7 +103,7 @@ def get_provider() -> BaseOtpProvider:
provider_key = settings.OTP_PROVIDER
provider_cls = PROVIDERS.get(provider_key)
if not provider_cls:
raise ValueError(f"Unknown OTP provider: {provider_key}")
raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key})
return provider_cls()
@@ -147,13 +149,15 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo
expires_at=PhoneOTP.expiry_at(),
)
message = f"Your verification code is {code}. It expires in {settings.OTP_EXPIRY_MINUTES} minutes."
message = _(
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
if channel == OtpChannel.SMS:
provider.send_sms(phone_number, message)
elif channel == OtpChannel.WHATSAPP:
provider.send_whatsapp(phone_number, message)
else:
raise ValueError("Unsupported OTP channel")
raise ValueError(_("Unsupported OTP channel"))
return OtpSendResult(request_id=str(otp.id), expires_at=otp.expires_at.isoformat())
+5 -3
View File
@@ -1,9 +1,11 @@
import re
from django.utils.translation import gettext as _
def normalize_phone_number(raw_phone: str) -> str:
if not raw_phone:
raise ValueError("Phone number is required")
raise ValueError(_("Phone number is required"))
phone = re.sub(r"[\s\-\(\)]", "", raw_phone)
if phone.startswith("00"):
@@ -12,7 +14,7 @@ def normalize_phone_number(raw_phone: str) -> str:
if phone.startswith("+"):
digits = phone[1:]
if not digits.isdigit() or not (8 <= len(digits) <= 15):
raise ValueError("Invalid phone number format")
raise ValueError(_("Invalid phone number format"))
return "+" + digits
digits = re.sub(r"\D", "", phone)
@@ -23,4 +25,4 @@ def normalize_phone_number(raw_phone: str) -> str:
if digits.startswith("5") and len(digits) == 9:
return "+966" + digits
raise ValueError("Phone number must be in E.164 format or a valid Saudi mobile")
raise ValueError(_("Phone number must be in E.164 format or a valid Saudi mobile"))
@@ -0,0 +1,29 @@
import pytest
from django.urls import reverse
from apps.accounts.models import User
@pytest.mark.django_db
def test_accept_language_sets_content_language_header(client):
response = client.post(
reverse("otp_request"),
{"phone_number": "123", "channel": "sms"},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="en",
)
assert response.headers.get("Content-Language") == "en"
@pytest.mark.django_db
def test_user_preference_overrides_accept_language(client):
user = User.objects.create_user(
email="locale@example.com",
phone_number="+966512345678",
)
user.preferred_language = "en"
user.save(update_fields=["preferred_language"])
client.force_login(user)
response = client.get(reverse("me"), HTTP_ACCEPT_LANGUAGE="ar-sa")
assert response.headers.get("Content-Language") == "en"
+7 -6
View File
@@ -2,6 +2,7 @@ from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404
from rest_framework import generics, permissions, status
from rest_framework.response import Response
from django.utils.translation import gettext as _
from rest_framework.views import APIView
from rest_framework_simplejwt.tokens import RefreshToken
@@ -71,14 +72,14 @@ class OTPVerifyView(APIView):
data = serializer.validated_data
otp = get_object_or_404(PhoneOTP, id=data["request_id"])
if not verify_otp(otp, data["code"]):
return Response({"detail": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST)
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
user = User.objects.filter(phone_number=otp.phone_number).first()
if user and not user.is_phone_verified:
user.is_phone_verified = True
user.save(update_fields=["is_phone_verified"])
return Response({"detail": "Phone verified"}, status=status.HTTP_200_OK)
return Response({"detail": _("Phone verified")}, status=status.HTTP_200_OK)
class PhoneAuthRequestView(APIView):
@@ -95,7 +96,7 @@ class PhoneAuthRequestView(APIView):
if not user:
if email and User.objects.filter(email=email).exists():
return Response(
{"detail": "Email already in use."},
{"detail": _("Email already in use.")},
status=status.HTTP_400_BAD_REQUEST,
)
user = User.objects.create_user(
@@ -134,11 +135,11 @@ class PhoneAuthVerifyView(APIView):
data = serializer.validated_data
otp = get_object_or_404(PhoneOTP, id=data["request_id"])
if not verify_otp(otp, data["code"]):
return Response({"detail": "Invalid or expired code"}, status=status.HTTP_400_BAD_REQUEST)
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
user = User.objects.filter(phone_number=otp.phone_number).first()
if not user:
return Response({"detail": "User not found"}, status=status.HTTP_404_NOT_FOUND)
return Response({"detail": _("User not found")}, status=status.HTTP_404_NOT_FOUND)
if not user.is_phone_verified:
user.is_phone_verified = True
@@ -160,6 +161,6 @@ class SocialLoginPlaceholderView(APIView):
def post(self, request, provider):
return Response(
{"detail": "Social login not configured yet. Add OAuth provider config."},
{"detail": _("Social login not configured yet. Add OAuth provider config.")},
status=status.HTTP_501_NOT_IMPLEMENTED,
)
+2 -1
View File
@@ -1,4 +1,5 @@
from rest_framework import serializers
from django.utils.translation import gettext_lazy as _
from apps.bookings.models import Booking
from apps.salons.models import Service, StaffProfile
@@ -46,7 +47,7 @@ class BookingCreateSerializer(serializers.ModelSerializer):
service: Service = attrs["service"]
staff = attrs.get("staff")
if staff and staff.salon_id != service.salon_id:
raise serializers.ValidationError("Selected staff does not belong to this salon")
raise serializers.ValidationError(_("Selected staff does not belong to this salon"))
return attrs
def create(self, validated_data):
+2 -1
View File
@@ -1,4 +1,5 @@
from rest_framework import serializers
from django.utils.translation import gettext_lazy as _
from apps.bookings.models import Booking
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
@@ -33,7 +34,7 @@ class PaymentCreateSerializer(serializers.ModelSerializer):
def validate_booking_id(self, value):
if not Booking.objects.filter(id=value).exists():
raise serializers.ValidationError("Booking not found")
raise serializers.ValidationError(_("Booking not found"))
return value
def create(self, validated_data):
+3 -2
View File
@@ -1,5 +1,6 @@
from rest_framework import permissions, status, viewsets
from rest_framework.response import Response
from django.utils.translation import gettext as _
from apps.bookings.models import Booking
from apps.payments.models import Payment
@@ -39,11 +40,11 @@ class PaymentViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True)
booking = Booking.objects.get(id=serializer.validated_data["booking_id"])
if not user_can_access_booking(request.user, booking):
return Response({"detail": "Not allowed"}, status=status.HTTP_403_FORBIDDEN)
return Response({"detail": _("Not allowed")}, status=status.HTTP_403_FORBIDDEN)
payment = serializer.save()
return Response(
{
"detail": "Payment record created. Provider integration pending.",
"detail": _("Payment record created. Provider integration pending."),
"payment_id": payment.id,
"amount": str(payment.amount),
"currency": payment.currency,
+1
View File
@@ -0,0 +1 @@
+8 -1
View File
@@ -31,10 +31,12 @@ INSTALLED_APPS = [
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"corsheaders.middleware.CorsMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"apps.accounts.middleware.UserLocaleMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
@@ -96,7 +98,12 @@ AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "en-us"
LANGUAGE_CODE = "ar-sa"
LANGUAGES = [
("ar-sa", "Arabic (Saudi Arabia)"),
("en", "English"),
]
LOCALE_PATHS = [BASE_DIR / "locale"]
TIME_ZONE = "Asia/Riyadh"
USE_I18N = True
USE_TZ = True