Files
octopus/myproject/customers/models.py
Andrey Smakotin 9394abfa3f Добавлена валидация уникальности email и phone для клиентов
Изменения:
- Добавлено ограничение unique=True для поля email в модели Customer
- Email теперь поддерживает NULL значения (несколько клиентов могут быть без email)
- Добавлены методы clean_email() и clean_phone() в CustomerForm для валидации
- Переписан API endpoint api_create_customer для использования формы вместо прямого создания
- Создано две миграции: сначала разрешение NULL, затем добавление unique constraint
- В текущей БД преобразованы пустые строки email в NULL (4 записи)

Это исправляет:
- Возможность создания дубликатов клиентов с одинаковыми email/phone
- Работает как в веб-форме, так и в модальном окне создания заказа
- Устранена уязвимость race condition через использование DB constraints

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-11 17:36:11 +03:00

196 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import phonenumbers
from django.core.exceptions import ValidationError
from django.db import models
from phonenumber_field.modelfields import PhoneNumberField
from products.models import Product
class Customer(models.Model):
"""
Модель клиента для цветочного магазина в Минске, Беларусь.
"""
# Name field that is not required to be unique
name = models.CharField(max_length=200, blank=True, verbose_name="Имя")
email = models.EmailField(blank=True, null=True, unique=True, verbose_name="Email")
# Phone with validation using django-phonenumber-field
phone = PhoneNumberField(
blank=True,
null=True,
unique=True,
verbose_name="Телефон",
help_text="Введите телефон в любом формате, например: +375291234567, 80255270546, 8(029)1234567 - будет автоматически преобразован в международный формат"
)
# Temporary field to store raw phone number during initialization
_raw_phone = None
# Loyalty program
loyalty_tier = models.CharField(
max_length=20,
choices=[
('no_discount', 'Без скидки'),
('bronze', 'Бронза'),
('silver', 'Серебро'),
('gold', 'Золото'),
('platinum', 'Платина'),
],
default='no_discount',
verbose_name="Уровень лояльности"
)
total_spent = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Общая сумма покупок"
)
# Additional notes
notes = models.TextField(
blank=True,
null=True,
verbose_name="Заметки",
help_text="Заметки о клиенте, особые предпочтения и т.д."
)
# Timestamps
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
class Meta:
verbose_name = "Клиент"
verbose_name_plural = "Клиенты"
indexes = [
models.Index(fields=['name']),
models.Index(fields=['email']),
models.Index(fields=['phone']),
models.Index(fields=['created_at']),
models.Index(fields=['loyalty_tier']),
]
ordering = ['-created_at']
def __str__(self):
if self.name:
return self.name
if self.phone:
return str(self.phone)
if self.email:
return self.email
return "Безымянный клиент"
@property
def full_name(self):
"""Полное имя клиента"""
return self.name
@property
def is_vip(self):
"""Проверяет, является ли клиент VIP на основе уровня лояльности"""
return self.loyalty_tier in ("gold", "platinum")
def get_loyalty_discount(self):
"""Возвращает скидку в зависимости от уровня лояльности"""
discounts = {
'no_discount': 0,
'bronze': 0,
'silver': 5, # 5%
'gold': 10, # 10%
'platinum': 15 # 15%
}
return discounts.get(self.loyalty_tier, 0)
def validate_unique(self, exclude=None):
"""Переопределение для корректной проверки уникальности телефона при обновлениях"""
# Снова нормализуем номер телефона перед проверкой уникальности
if self.phone:
# Проверяем существующих клиентов с таким же телефоном (исключая текущий экземпляр при обновлении)
existing = Customer.objects.filter(phone=self.phone)
if self.pk:
existing = existing.exclude(pk=self.pk)
if existing.exists():
raise ValidationError({'phone': 'Пользователь с таким номером телефона уже существует.'})
# Вызываем родительский validate_unique для обработки других проверок
super().validate_unique(exclude=exclude)
def clean_phone(self):
"""Пользовательская очистка поля телефона для нормализации перед валидацией."""
if self.phone:
try:
# Парсим номер телефона, чтобы проверить его валидность и нормализовать
raw_phone = str(self.phone)
# Если начинается с '8' и имеет 11 цифр, это может быть российский формат
if raw_phone.startswith('8') and len(raw_phone) == 11:
# Сначала пробуем BY для белорусских номеров
parsed = phonenumbers.parse(raw_phone, "BY")
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
# Если BY не работает, пробуем RU как запасной вариант
parsed = phonenumbers.parse(raw_phone, "RU")
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
# Пробуем парсить без кода страны (может быть уже в международном формате)
parsed = phonenumbers.parse(raw_phone, None)
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except phonenumbers.NumberParseException:
# Если парсинг не удался, возвращаем как есть и позволяем валидации поля обработать это
pass
return self.phone
def clean_fields(self, exclude=None):
# Нормализуем телефон перед запуском проверки полей
if not exclude:
exclude = []
if 'phone' not in exclude and self.phone:
normalized = None
try:
normalized = self.clean_phone()
except Exception:
normalized = None
if normalized:
# присваиваем нормализованное значение (E.164), чтобы PhoneNumberField видел корректный формат
self.phone = normalized
super().clean_fields(exclude=exclude)
def clean(self):
"""Дополнительная валидация при необходимости."""
super().clean()
def save(self, *args, **kwargs):
# Обеспечиваем нормализацию телефона, даже если save вызывается напрямую (не через форму)
# На данный момент, если вызов прошел через валидацию формы, телефон уже должен быть нормализован
# Но если save вызывается непосредственно в модели, нам все равно нужно нормализовать
if self.phone and str(self.phone).startswith('8') and len(str(self.phone)) == 11:
# Это, вероятно, номер в формате страны, который требует нормализации
try:
# Сначала пробуем BY для белорусских номеров
parsed = phonenumbers.parse(str(self.phone), "BY")
if phonenumbers.is_valid_number(parsed):
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
else:
# Если BY не работает, пробуем RU как запасной вариант
parsed = phonenumbers.parse(str(self.phone), "RU")
if phonenumbers.is_valid_number(parsed):
self.phone = phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except phonenumbers.NumberParseException:
# Если парсинг не удался, не меняем и позволяем валидации поля обработать это
pass
super().save(*args, **kwargs)
def increment_total_spent(self, amount):
"""Увеличивает общую сумму покупок"""
self.total_spent = self.total_spent + amount
self.save(update_fields=['total_spent'])