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:
@@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,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"]
|
||||
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user