Files
octopus/myproject/customers/models.py
Andrey Smakotin 685c06d94d Добавлена функциональность системного клиента для анонимных покупок
- Добавлено поле is_system_customer в модель Customer с индексом
- Системный клиент создается автоматически при создании нового тенанта
- Реализована защита системного клиента от редактирования и удаления:
  - Защита на уровне модели (save/delete методы)
  - Защита на уровне формы (валидация)
  - Защита на уровне представлений (проверки с дружественными сообщениями)
  - Защита в админке (readonly поля, запрет удаления)
- Системный клиент скрыт из списков и поиска на фронтенде
- Создан информационный шаблон для отображения системного клиента
- Исправлена обработка NULL значений для полей email/phone (Django best practice)
- Добавлено отображение "Не указано" вместо None в карточке клиента

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 00:07:38 +03:00

247 lines
12 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="Общая сумма покупок"
)
# System customer flag
is_system_customer = models.BooleanField(
default=False,
db_index=True,
verbose_name="Системный клиент",
help_text="Автоматически созданный клиент для анонимных покупок и наличных продаж"
)
# 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):
# Защита системного клиента от изменений
if self.pk and self.is_system_customer:
# Получаем оригинальный объект из БД
try:
original = Customer.objects.get(pk=self.pk)
# Проверяем, не пытаются ли изменить критичные поля
if original.email != self.email:
raise ValidationError("Нельзя изменить email системного клиента")
if original.is_system_customer != self.is_system_customer:
raise ValidationError("Нельзя изменить флаг системного клиента")
except Customer.DoesNotExist:
pass
# Обеспечиваем нормализацию телефона, даже если 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 delete(self, *args, **kwargs):
"""Защита системного клиента от удаления"""
if self.is_system_customer:
raise ValidationError("Нельзя удалить системного клиента. Он необходим для работы системы.")
super().delete(*args, **kwargs)
@classmethod
def get_or_create_system_customer(cls):
"""
Получить или создать системного клиента для анонимных покупок.
Системный клиент используется для:
- Анонимных покупок в POS системе
- Покупок от неизвестных клиентов (проходимость)
- Наличных продаж без указания покупателя
Возвращает:
tuple: (customer, created) - объект клиента и флаг создания
"""
customer, created = cls.objects.get_or_create(
email="system@pos.customer",
defaults={
"name": "АНОНИМНЫЙ ПОКУПАТЕЛЬ (POS)",
"is_system_customer": True,
"loyalty_tier": "no_discount",
"notes": "SYSTEM_CUSTOMER - автоматически созданный клиент для анонимных покупок и наличных продаж",
}
)
return customer, created
def increment_total_spent(self, amount):
"""Увеличивает общую сумму покупок"""
self.total_spent = self.total_spent + amount
self.save(update_fields=['total_spent'])