diff --git a/apps/users/api/serializers.py b/apps/users/api/serializers.py index 8461258..b7cfb0b 100644 --- a/apps/users/api/serializers.py +++ b/apps/users/api/serializers.py @@ -4,6 +4,7 @@ from drf_spectacular.utils import extend_schema_serializer from rest_framework import serializers from core.serializers.base import BaseModelSerializer +from apps.users.email_identity import normalize_email_identity User = get_user_model() @@ -186,6 +187,20 @@ class UserProfileSerializer(BaseModelSerializer): full_name = serializers.ReadOnlyField() age = serializers.ReadOnlyField() + def validate_email(self, value): + normalized = normalize_email_identity(value) + user = self.instance + + if normalized is None: + return None + + existing = User.objects.filter(email=normalized) + if user is not None: + existing = existing.exclude(pk=user.pk) + if existing.exists(): + raise serializers.ValidationError("A user with this email already exists.") + return normalized + class Meta: model = User fields = BaseModelSerializer.Meta.fields + ( diff --git a/apps/users/email_identity.py b/apps/users/email_identity.py new file mode 100644 index 0000000..490c146 --- /dev/null +++ b/apps/users/email_identity.py @@ -0,0 +1,16 @@ +from __future__ import annotations + + +def normalize_email_identity(value: str | None) -> str | None: + if value is None: + return None + normalized = value.strip().lower() + return normalized or None + + +def mask_mobile(value: str | None) -> str | None: + if not value: + return None + if len(value) <= 4: + return value + return f"{value[:2]}{'*' * max(len(value) - 6, 1)}{value[-4:]}" diff --git a/apps/users/migrations/0003_normalize_user_email_identity.py b/apps/users/migrations/0003_normalize_user_email_identity.py new file mode 100644 index 0000000..d7248e6 --- /dev/null +++ b/apps/users/migrations/0003_normalize_user_email_identity.py @@ -0,0 +1,63 @@ +# Generated by Django 5.2.12 on 2026-05-14 + +from django.db import migrations, models +from django.db.models import Q +from django.db.models.functions import Lower + + +def _normalize_email(value): + if value is None: + return None + normalized = value.strip().lower() + return normalized or None + + +def normalize_user_and_social_emails(apps, schema_editor): + User = apps.get_model("users", "User") + UserSocialAccount = apps.get_model("users", "UserSocialAccount") + + seen_emails = set() + for user in User.objects.all().order_by("created_at", "id"): + normalized_email = _normalize_email(user.email) + if normalized_email in seen_emails: + normalized_email = None + elif normalized_email is not None: + seen_emails.add(normalized_email) + + if user.email != normalized_email: + user.email = normalized_email + user.save(update_fields=["email"]) + + for social_account in UserSocialAccount.objects.all().order_by("created_at", "id"): + normalized_email = _normalize_email(social_account.email) + if social_account.email != normalized_email: + social_account.email = normalized_email + social_account.save(update_fields=["email"]) + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0002_usersocialaccount"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="email", + field=models.EmailField(blank=True, default=None, max_length=254, null=True), + ), + migrations.AlterField( + model_name="usersocialaccount", + name="email", + field=models.EmailField(blank=True, default=None, max_length=254, null=True), + ), + migrations.RunPython(normalize_user_and_social_emails, migrations.RunPython.noop), + migrations.AddConstraint( + model_name="user", + constraint=models.UniqueConstraint( + Lower("email"), + condition=Q(email__isnull=False), + name="user_email_ci_uniq", + ), + ), + ] diff --git a/apps/users/models.py b/apps/users/models.py index 7e7bd96..e0da956 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -1,9 +1,12 @@ from django.contrib.auth.models import AbstractUser from django.db import models +from django.db.models import Q +from django.db.models.functions import Lower from core.models.base import BaseModel from core.utils import calculate_age, common_datetime_str +from apps.users.email_identity import normalize_email_identity from apps.users.services.managers import UserManager @@ -11,7 +14,7 @@ class User(AbstractUser, BaseModel): username = None mobile = models.CharField(max_length=11, unique=True) - email = models.EmailField(blank=True, default="") + email = models.EmailField(blank=True, null=True, default=None) description = models.TextField(blank=True, default="") profile_picture = models.ImageField(upload_to="profile/users/", blank=True, null=True) @@ -41,11 +44,22 @@ class User(AbstractUser, BaseModel): def __str__(self): return self.full_name or self.mobile + def save(self, *args, **kwargs): + self.email = normalize_email_identity(self.email) + super().save(*args, **kwargs) + class Meta: verbose_name = "user" verbose_name_plural = "users" db_table = "user" ordering = ("-updated_at", "-created_at") + constraints = ( + models.UniqueConstraint( + Lower("email"), + condition=Q(email__isnull=False), + name="user_email_ci_uniq", + ), + ) indexes = ( models.Index(fields=["id"], name="user_id_idx"), models.Index(fields=["mobile"], name="user_mobile_idx"), @@ -78,7 +92,7 @@ class UserSocialAccount(BaseModel): user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="social_accounts") provider = models.CharField(max_length=32, choices=ProviderType.choices) provider_user_id = models.CharField(max_length=255) - email = models.EmailField(blank=True, default="") + email = models.EmailField(blank=True, null=True, default=None) email_verified = models.BooleanField(default=False) avatar_url = models.URLField(blank=True, default="") @@ -100,3 +114,7 @@ class UserSocialAccount(BaseModel): def __str__(self): return f"{self.provider}:{self.provider_user_id}" + + def save(self, *args, **kwargs): + self.email = normalize_email_identity(self.email) + super().save(*args, **kwargs) diff --git a/apps/users/services/managers.py b/apps/users/services/managers.py index edc3176..efb6983 100644 --- a/apps/users/services/managers.py +++ b/apps/users/services/managers.py @@ -1,6 +1,7 @@ from django.contrib.auth.models import BaseUserManager from core.models.base import SoftDeleteManager +from apps.users.email_identity import normalize_email_identity class UserManager(BaseUserManager, SoftDeleteManager): @@ -9,6 +10,8 @@ class UserManager(BaseUserManager, SoftDeleteManager): def _create_user(self, mobile, password, **extra_fields): if not mobile: raise ValueError("Mobile must be set") + if "email" in extra_fields: + extra_fields["email"] = normalize_email_identity(extra_fields.get("email")) user = self.model(mobile=mobile, **extra_fields) user.set_password(password) user.save(using=self._db)