From 685c06d94d46a65e4769285e3b307ca5350f9adc Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Thu, 20 Nov 2025 00:07:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D0=BE=D0=BD?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D1=81=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D0=BC=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BB?= =?UTF-8?q?=D0=B8=D0=B5=D0=BD=D1=82=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D0=B0?= =?UTF-8?q?=D0=BD=D0=BE=D0=BD=D0=B8=D0=BC=D0=BD=D1=8B=D1=85=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BA=D1=83=D0=BF=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Добавлено поле 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 --- myproject/customers/admin.py | 52 +++++++++++- myproject/customers/forms.py | 24 ++++-- .../0002_customer_is_system_customer.py | 18 ++++ myproject/customers/models.py | 59 ++++++++++++- .../templates/customers/customer_detail.html | 4 +- .../templates/customers/customer_system.html | 27 ++++++ myproject/customers/views.py | 82 +++++++------------ myproject/tenants/admin.py | 14 ++++ 8 files changed, 215 insertions(+), 65 deletions(-) create mode 100644 myproject/customers/migrations/0002_customer_is_system_customer.py create mode 100644 myproject/customers/templates/customers/customer_system.html diff --git a/myproject/customers/admin.py b/myproject/customers/admin.py index ec37b18..4f98903 100644 --- a/myproject/customers/admin.py +++ b/myproject/customers/admin.py @@ -21,6 +21,24 @@ class IsVipFilter(admin.SimpleListFilter): return queryset +class IsSystemCustomerFilter(admin.SimpleListFilter): + title = 'Системный клиент' + parameter_name = 'is_system_customer' + + def lookups(self, request, model_admin): + return ( + ('yes', 'Системный'), + ('no', 'Обычный'), + ) + + def queryset(self, request, queryset): + if self.value() == 'yes': + return queryset.filter(is_system_customer=True) + if self.value() == 'no': + return queryset.filter(is_system_customer=False) + return queryset + + @admin.register(Customer) class CustomerAdmin(admin.ModelAdmin): """Административный интерфейс для управления клиентами цветочного магазина""" @@ -31,11 +49,13 @@ class CustomerAdmin(admin.ModelAdmin): 'loyalty_tier', 'total_spent', 'is_vip', + 'is_system_customer', 'created_at' ) list_filter = ( 'loyalty_tier', IsVipFilter, + IsSystemCustomerFilter, 'created_at' ) search_fields = ( @@ -45,19 +65,45 @@ class CustomerAdmin(admin.ModelAdmin): ) date_hierarchy = 'created_at' ordering = ('-created_at',) - readonly_fields = ('created_at', 'updated_at', 'total_spent', 'is_vip') + readonly_fields = ('created_at', 'updated_at', 'total_spent', 'is_vip', 'is_system_customer') - fieldsets = ( ('Основная информация', { - 'fields': ('name', 'email', 'phone') + 'fields': ('name', 'email', 'phone', 'is_system_customer') }), ('Программа лояльности', { 'fields': ('loyalty_tier', 'total_spent', 'is_vip'), 'classes': ('collapse',) }), + ('Заметки', { + 'fields': ('notes',) + }), ('Даты', { 'fields': ('created_at', 'updated_at'), 'classes': ('collapse',) }), ) + + def get_readonly_fields(self, request, obj=None): + """Делаем все поля read-only для системного клиента""" + if obj and obj.is_system_customer: + # Для системного клиента все поля только для чтения + return ['name', 'email', 'phone', 'loyalty_tier', 'total_spent', 'is_vip', 'is_system_customer', 'notes', 'created_at', 'updated_at'] + return self.readonly_fields + + def has_delete_permission(self, request, obj=None): + """Запрет на удаление системного клиента""" + if obj and obj.is_system_customer: + return False + return super().has_delete_permission(request, obj) + + def changeform_view(self, request, object_id=None, form_url='', extra_context=None): + """Добавляем предупреждение для системного клиента""" + extra_context = extra_context or {} + if object_id: + obj = self.get_object(request, object_id) + if obj and obj.is_system_customer: + extra_context['readonly'] = True + from django.contrib import messages + messages.warning(request, 'Это системный клиент. Редактирование запрещено для обеспечения корректной работы системы.') + return super().changeform_view(request, object_id, form_url, extra_context) diff --git a/myproject/customers/forms.py b/myproject/customers/forms.py index f05f4a4..e0b6f98 100644 --- a/myproject/customers/forms.py +++ b/myproject/customers/forms.py @@ -15,6 +15,7 @@ class CustomerForm(forms.ModelForm): class Meta: model = Customer fields = ['name', 'email', 'phone', 'loyalty_tier', 'notes'] + exclude = ['is_system_customer'] widgets = { 'notes': forms.Textarea(attrs={'rows': 3}), } @@ -43,9 +44,9 @@ class CustomerForm(forms.ModelForm): """Проверяет уникальность email при создании/редактировании""" email = self.cleaned_data.get('email') - # Если email пустой, это нормально (blank=True) + # Нормализуем пустые значения в None (Django best practice для nullable полей) if not email: - return email + return None # Проверяем уникальность queryset = Customer.objects.filter(email=email) @@ -63,9 +64,9 @@ class CustomerForm(forms.ModelForm): """Проверяет уникальность телефона при создании/редактировании""" phone = self.cleaned_data.get('phone') - # Если телефон пустой, это нормально (blank=True) + # Нормализуем пустые значения в None (Django best practice для nullable полей) if not phone: - return phone + return None # Проверяем уникальность queryset = Customer.objects.filter(phone=phone) @@ -77,4 +78,17 @@ class CustomerForm(forms.ModelForm): if queryset.exists(): raise ValidationError('Клиент с таким номером телефона уже существует.') - return phone \ No newline at end of file + return phone + + def clean(self): + """Дополнительная валидация формы""" + cleaned_data = super().clean() + + # Защита от редактирования системного клиента + if self.instance and self.instance.pk and self.instance.is_system_customer: + raise ValidationError( + 'Системный клиент не может быть изменен. ' + 'Он необходим для корректной работы системы и создается автоматически.' + ) + + return cleaned_data \ No newline at end of file diff --git a/myproject/customers/migrations/0002_customer_is_system_customer.py b/myproject/customers/migrations/0002_customer_is_system_customer.py new file mode 100644 index 0000000..0c86c70 --- /dev/null +++ b/myproject/customers/migrations/0002_customer_is_system_customer.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.10 on 2025-11-19 19:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='customer', + name='is_system_customer', + field=models.BooleanField(db_index=True, default=False, help_text='Автоматически созданный клиент для анонимных покупок и наличных продаж', verbose_name='Системный клиент'), + ), + ] diff --git a/myproject/customers/models.py b/myproject/customers/models.py index 9ea375b..444f651 100644 --- a/myproject/customers/models.py +++ b/myproject/customers/models.py @@ -41,15 +41,23 @@ class Customer(models.Model): ) total_spent = models.DecimalField( - max_digits=10, - decimal_places=2, + 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, + blank=True, null=True, verbose_name="Заметки", help_text="Заметки о клиенте, особые предпочтения и т.д." @@ -168,6 +176,19 @@ class Customer(models.Model): 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 вызывается непосредственно в модели, нам все равно нужно нормализовать @@ -189,6 +210,36 @@ class Customer(models.Model): 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 diff --git a/myproject/customers/templates/customers/customer_detail.html b/myproject/customers/templates/customers/customer_detail.html index 4600eec..2bb82fe 100644 --- a/myproject/customers/templates/customers/customer_detail.html +++ b/myproject/customers/templates/customers/customer_detail.html @@ -32,11 +32,11 @@ Email: - {{ customer.email }} + {{ customer.email|default:"Не указано" }} Телефон: - {{ customer.phone }} + {{ customer.phone|default:"Не указано" }} Уровень лояльности: diff --git a/myproject/customers/templates/customers/customer_system.html b/myproject/customers/templates/customers/customer_system.html new file mode 100644 index 0000000..6f01703 --- /dev/null +++ b/myproject/customers/templates/customers/customer_system.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block title %}Системный клиент{% endblock %} + +{% block content %} +
+
+
+
+
+
+ Системный клиент +
+
+
+

+ Используется для анонимных покупок и наличных продаж +

+ + Вернуться к списку + +
+
+
+
+
+{% endblock %} diff --git a/myproject/customers/views.py b/myproject/customers/views.py index c2e6ced..97d4a33 100644 --- a/myproject/customers/views.py +++ b/myproject/customers/views.py @@ -26,7 +26,8 @@ def normalize_query_phone(q): def customer_list(request): """Список всех клиентов""" query = request.GET.get('q') - customers = Customer.objects.all() + # Исключаем системного клиента из списка + customers = Customer.objects.filter(is_system_customer=False) if query: # Используем ту же логику поиска, что и в AJAX API (api_search_customers) @@ -80,6 +81,10 @@ def customer_detail(request, pk): """Детали клиента""" customer = get_object_or_404(Customer, pk=pk) + # Для системного клиента показываем специальную заглушку + if customer.is_system_customer: + return render(request, 'customers/customer_system.html') + context = { 'customer': customer, } @@ -104,12 +109,20 @@ def customer_update(request, pk): """Редактирование клиента""" customer = get_object_or_404(Customer, pk=pk) + # Проверяем, не системный ли это клиент + if customer.is_system_customer: + messages.warning(request, 'Системный клиент не может быть изменен. Он создается автоматически и необходим для корректной работы системы.') + return redirect('customers:customer-detail', pk=pk) + if request.method == 'POST': form = CustomerForm(request.POST, instance=customer) if form.is_valid(): - form.save() - messages.success(request, f'Клиент {customer.full_name} успешно обновлён.') - return redirect('customers:customer-detail', pk=customer.pk) + try: + form.save() + messages.success(request, f'Клиент {customer.full_name} успешно обновлён.') + return redirect('customers:customer-detail', pk=customer.pk) + except ValidationError as e: + messages.error(request, str(e)) else: form = CustomerForm(instance=customer) @@ -120,11 +133,20 @@ def customer_delete(request, pk): """Удаление клиента""" customer = get_object_or_404(Customer, pk=pk) + # Проверяем, не системный ли это клиент + if customer.is_system_customer: + messages.error(request, 'Невозможно удалить системного клиента. Он необходим для корректной работы системы.') + return redirect('customers:customer-detail', pk=pk) + if request.method == 'POST': customer_name = customer.full_name - customer.delete() - messages.success(request, f'Клиент {customer_name} успешно удален.') - return redirect('customers:customer-list') + try: + customer.delete() + messages.success(request, f'Клиент {customer_name} успешно удален.') + return redirect('customers:customer-list') + except ValidationError as e: + messages.error(request, str(e)) + return redirect('customers:customer-detail', pk=pk) context = { 'customer': customer @@ -316,7 +338,8 @@ def api_search_customers(request): if customers_by_phone.exists(): q_objects |= Q(pk__in=customers_by_phone.values_list('pk', flat=True)) - customers = Customer.objects.filter(q_objects).distinct().order_by('name')[:20] + # Исключаем системного клиента из результатов поиска + customers = Customer.objects.filter(q_objects).filter(is_system_customer=False).distinct().order_by('name')[:20] results = [] @@ -442,46 +465,3 @@ def api_create_customer(request): 'success': False, 'error': f'Ошибка сервера: {str(e)}' }, status=500) - - -@require_http_methods(["POST"]) -def api_create_system_customer(request): - """ - Создать или получить системного анонимного клиента для POS. - - Идентификаторы системного клиента: - - email: system@pos.customer - - name: АНОНИМНЫЙ ПОКУПАТЕЛЬ (POS) - - loyalty_tier: 'no_discount' - - notes: 'SYSTEM_CUSTOMER' - - Поведение: - - Если клиент уже существует (по уникальному email), новый не создаётся. - - Если не существует — создаётся с указанными полями. - - Возвращает JSON с признаком, был ли создан новый клиент. - - Возвращаемый JSON: - { - "success": true, - "created": false, # или true, если впервые создан - "id": 123, - "name": "АНОНИМНЫЙ ПОКУПАТЕЛЬ (POS)", - "email": "system@pos.customer" - } - """ - customer, created = Customer.objects.get_or_create( - email="system@pos.customer", - defaults={ - "name": "АНОНИМНЫЙ ПОКУПАТЕЛЬ (POS)", - "loyalty_tier": "no_discount", - "notes": "SYSTEM_CUSTOMER", - }, - ) - - return JsonResponse({ - "success": True, - "created": created, - "id": customer.pk, - "name": customer.name, - "email": customer.email, - }) \ No newline at end of file diff --git a/myproject/tenants/admin.py b/myproject/tenants/admin.py index 3722110..7b9e0a2 100644 --- a/myproject/tenants/admin.py +++ b/myproject/tenants/admin.py @@ -285,6 +285,20 @@ class TenantRegistrationAdmin(admin.ModelAdmin): else: logger.warning(f"Пользователь с email {settings.TENANT_ADMIN_EMAIL} уже существует в тенанте") + # Создаем системного клиента для анонимных продаж + logger.info(f"Создание системного клиента для тенанта: {client.id}") + from customers.models import Customer + + try: + system_customer, created = Customer.get_or_create_system_customer() + if created: + logger.info(f"Системный клиент создан: {system_customer.id} ({system_customer.name})") + else: + logger.info(f"Системный клиент уже существует: {system_customer.id} ({system_customer.name})") + except Exception as e: + logger.error(f"Ошибка при создании системного клиента: {e}", exc_info=True) + # Не прерываем процесс, т.к. это не критично + # Возвращаемся в public схему connection.set_schema_to_public()