From eb7569f9830c7a230d073af08b37726f344acdc1 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Tue, 27 Jan 2026 22:37:00 +0300 Subject: [PATCH] =?UTF-8?q?feat(products):=20=D0=BE=D0=B1=D0=B5=D1=81?= =?UTF-8?q?=D0=BF=D0=B5=D1=87=D0=B8=D1=82=D1=8C=20=D1=83=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D0=B8=D1=86=D1=8B=20=D0=BF=D1=80=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=D0=B6=D0=B8=20=D0=BF=D0=BE=20=D1=83=D0=BC=D0=BE=D0=BB=D1=87?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D1=8E=20=D0=B8=20=D1=83=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D1=88=D0=B8=D1=82=D1=8C=20UI=20=D1=84=D0=BE=D1=80=D0=BC=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлено ограничение на уровне базы данных и валидация форм для обеспечения, что у товара может быть только одна единица продажи с флагом "по умолчанию". Переработан интерфейс маркетинговых флагов и единиц продажи для улучшения UX. Основные изменения: - Добавлен UniqueConstraint в модель ProductSalesUnit для валидации на уровне БД - Создан BaseProductSalesUnitFormSet с кастомной валидацией формы - Обновлен метод save() для корректной обработки новых и существующих записей - Добавлена транзакционная обертка в представлениях ProductCreateView и ProductUpdateView - Переработан блок маркетинговых флагов с карточным дизайном и интерактивными переключателями - Переработан блок единиц продажи в табличный вид с улучшенным UX - Добавлена клиентская логика для взаимного исключения чекбоксов "По умолчанию --- myproject/products/forms.py | 39 +- .../commands/check_default_units.py | 144 ++++ ...dd_unique_default_sales_unit_constraint.py | 17 + myproject/products/models/units.py | 17 +- .../templates/products/product_form.html | 675 +++++++++++++++--- myproject/products/views/product_views.py | 102 +-- 6 files changed, 831 insertions(+), 163 deletions(-) create mode 100644 myproject/products/management/commands/check_default_units.py create mode 100644 myproject/products/migrations/0004_add_unique_default_sales_unit_constraint.py diff --git a/myproject/products/forms.py b/myproject/products/forms.py index 4a5276a..88cc3f0 100644 --- a/myproject/products/forms.py +++ b/myproject/products/forms.py @@ -1321,12 +1321,49 @@ class ProductSalesUnitInlineForm(forms.ModelForm): return super().has_changed() +class BaseProductSalesUnitFormSet(forms.BaseInlineFormSet): + """ + Базовый формсет для единиц продажи с валидацией. + Обеспечивает, что только одна единица продажи может быть по умолчанию. + """ + def clean(self): + if any(self.errors): + return + + default_count = 0 + default_forms = [] + + for form in self.forms: + if self.can_delete and self._should_delete_form(form): + continue + + if not form.cleaned_data: + continue + + if form.cleaned_data.get('is_default'): + default_count += 1 + default_forms.append(form) + + if default_count > 1: + # Находим названия единиц с is_default для более информативного сообщения + unit_names = [] + for form in default_forms: + name = form.cleaned_data.get('name', 'Без названия') + unit_names.append(f'"{name}"') + + raise forms.ValidationError( + f'Можно установить только одну единицу продажи как "по умолчанию". ' + f'Найдено {default_count} единиц с флагом "по умолчанию": {", ".join(unit_names)}.' + ) + + # Inline formset для единиц продажи ProductSalesUnitFormSet = inlineformset_factory( Product, ProductSalesUnit, form=ProductSalesUnitInlineForm, - extra=1, + formset=BaseProductSalesUnitFormSet, + extra=0, can_delete=True, min_num=0, validate_min=False, diff --git a/myproject/products/management/commands/check_default_units.py b/myproject/products/management/commands/check_default_units.py new file mode 100644 index 0000000..cb062b6 --- /dev/null +++ b/myproject/products/management/commands/check_default_units.py @@ -0,0 +1,144 @@ +""" +Команда для проверки дубликатов is_default в ProductSalesUnit. + +Проверяет, есть ли товары с несколькими единицами продажи, у которых is_default=True. +Если дубликаты найдены, выводит детальную информацию и предлагает варианты исправления. + +Использование: + python manage.py check_default_units + python manage.py check_default_units --fix # автоматически исправить дубликаты +""" +from django.core.management.base import BaseCommand +from django.db import transaction, models +from django.db.models import Count +from products.models.units import ProductSalesUnit + + +class Command(BaseCommand): + help = 'Проверить и исправить дубликаты is_default в ProductSalesUnit' + + def add_arguments(self, parser): + parser.add_argument( + '--fix', + action='store_true', + dest='fix', + help='Автоматически исправить найденные дубликаты', + ) + parser.add_argument( + '--dry-run', + action='store_true', + dest='dry_run', + help='Показать, что будет изменено, но не применять изменения', + ) + + def handle(self, *args, **options): + fix = options.get('fix') + dry_run = options.get('dry_run') + + self.stdout.write(self.style.WARNING('Проверка дубликатов is_default в ProductSalesUnit...')) + self.stdout.write('') + + # Находим все товары с несколькими is_default=True + products_with_duplicates = ( + ProductSalesUnit.objects + .filter(is_default=True) + .values('product_id') + .annotate(default_count=models.Count('id')) + .filter(default_count__gt=1) + ) + + if not products_with_duplicates.exists(): + self.stdout.write(self.style.SUCCESS('✓ Дубликаты не найдены. Все в порядке!')) + return + + # Собираем детальную информацию о дубликатах + duplicates_info = [] + for item in products_with_duplicates: + product_id = item['product_id'] + default_units = list( + ProductSalesUnit.objects.filter( + product_id=product_id, + is_default=True + ).order_by('id') + ) + duplicates_info.append({ + 'product_id': product_id, + 'product_name': default_units[0].product.name if default_units else f'ID: {product_id}', + 'units': default_units + }) + + # Выводим информацию о найденных дубликатах + self.stdout.write( + self.style.ERROR(f'Найдено {len(duplicates_info)} товаров с дубликатами is_default:') + ) + self.stdout.write('') + + for info in duplicates_info: + self.stdout.write(f' Товар: {info["product_name"]} (ID: {info["product_id"]})') + self.stdout.write(f' Единиц с is_default=True: {len(info["units"])}') + for unit in info['units']: + self.stdout.write(f' - ID: {unit.id}, Название: "{unit.name}", Цена: {unit.price}₽') + self.stdout.write('') + + if not fix: + self.stdout.write('Для исправления дубликатов запустите:') + self.stdout.write(' python manage.py check_default_units --fix') + self.stdout.write('') + self.stdout.write('Для проверки что будет изменено (без применения):') + self.stdout.write(' python manage.py check_default_units --fix --dry-run') + return + + # Исправляем дубликаты + if dry_run: + self.stdout.write(self.style.WARNING('РЕЖИМ DRY-RUN - изменения не будут применены')) + self.stdout.write('') + + self.stdout.write(self.style.WARNING('Исправление дубликатов...')) + self.stdout.write('') + + with transaction.atomic(): + if dry_run: + # В режиме dry-run просто показываем что будет сделано + for info in duplicates_info: + units = info['units'] + if len(units) > 1: + # Оставляем первую, остальные снимаем флаг + keep_unit = units[0] + remove_units = units[1:] + self.stdout.write( + f' Товар: {info["product_name"]} (ID: {info["product_id"]})' + ) + self.stdout.write(f' Оставить: ID {keep_unit.id}, "{keep_unit.name}"') + for unit in remove_units: + self.stdout.write( + f' Снять is_default: ID {unit.id}, "{unit.name}"' + ) + else: + # Реальное исправление + for info in duplicates_info: + units = info['units'] + if len(units) > 1: + keep_unit = units[0] + remove_units = units[1:] + + # Снимаем флаг is_default со всех кроме первой + removed_ids = [unit.id for unit in remove_units] + ProductSalesUnit.objects.filter(id__in=removed_ids).update(is_default=False) + + self.stdout.write( + f' ✓ Товар: {info["product_name"]} (ID: {info["product_id"]})' + ) + self.stdout.write( + f' Оставлен: ID {keep_unit.id}, "{keep_unit.name}"' + ) + self.stdout.write( + f' Снято is_default с {len(remove_units)} записей: {removed_ids}' + ) + + if not dry_run: + self.stdout.write('') + self.stdout.write( + self.style.SUCCESS( + f'✓ Исправлено {len(duplicates_info)} товаров с дубликатами!' + ) + ) diff --git a/myproject/products/migrations/0004_add_unique_default_sales_unit_constraint.py b/myproject/products/migrations/0004_add_unique_default_sales_unit_constraint.py new file mode 100644 index 0000000..e3dd90a --- /dev/null +++ b/myproject/products/migrations/0004_add_unique_default_sales_unit_constraint.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.10 on 2026-01-27 18:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0003_productkit_showcase_created_at'), + ] + + operations = [ + migrations.AddConstraint( + model_name='productsalesunit', + constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('product',), name='unique_default_sales_unit_per_product', violation_error_message='У товара может быть только одна единица продажи по умолчанию'), + ), + ] diff --git a/myproject/products/models/units.py b/myproject/products/models/units.py index 8624994..1bfb750 100644 --- a/myproject/products/models/units.py +++ b/myproject/products/models/units.py @@ -128,6 +128,14 @@ class ProductSalesUnit(models.Model): verbose_name_plural = "Единицы продажи товаров" ordering = ['position', 'id'] unique_together = [['product', 'name']] + constraints = [ + models.UniqueConstraint( + fields=['product'], + condition=models.Q(is_default=True), + name='unique_default_sales_unit_per_product', + violation_error_message='У товара может быть только одна единица продажи по умолчанию' + ) + ] def __str__(self): return f"{self.product.name} - {self.name}" @@ -146,10 +154,15 @@ class ProductSalesUnit(models.Model): def save(self, *args, **kwargs): # Если это единица по умолчанию, снимаем флаг с других if self.is_default: - ProductSalesUnit.objects.filter( + # Используем exclude только если pk уже существует + # Для новых записей (pk=None) exclude не нужен, т.к. запись еще не в БД + queryset = ProductSalesUnit.objects.filter( product=self.product, is_default=True - ).exclude(pk=self.pk).update(is_default=False) + ) + if self.pk: + queryset = queryset.exclude(pk=self.pk) + queryset.update(is_default=False) super().save(*args, **kwargs) @property diff --git a/myproject/products/templates/products/product_form.html b/myproject/products/templates/products/product_form.html index f4a68ab..869047f 100644 --- a/myproject/products/templates/products/product_form.html +++ b/myproject/products/templates/products/product_form.html @@ -119,6 +119,365 @@ .photo-card:hover .card { border-color: #667eea; } + + /* === Стили для маркетинговых флагов === */ + .marketing-flag-card { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 0.5rem; + padding: 1rem; + transition: all 0.3s ease; + height: 100%; + } + + .marketing-flag-card:hover { + border-color: #28a745; + box-shadow: 0 2px 8px rgba(40, 167, 69, 0.1); + transform: translateY(-2px); + } + + .marketing-flag-card:has(input:checked) { + background: linear-gradient(135deg, #f8fff9 0%, #ffffff 100%); + border-color: #28a745; + border-width: 2px; + box-shadow: 0 2px 8px rgba(40, 167, 69, 0.15); + } + + .marketing-flag-card:has(input:checked) .marketing-flag-title { + color: #28a745; + font-weight: 700; + } + + .marketing-flag-card:has(input:checked) .marketing-flag-icon { + transform: scale(1.1); + } + + .marketing-flag-icon { + font-size: 1.75rem; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .marketing-flag-title { + font-weight: 600; + font-size: 0.95rem; + color: #212529; + margin-bottom: 0.125rem; + } + + .marketing-flag-card small { + font-size: 0.75rem; + line-height: 1.2; + } + + /* Крупные выключатели для маркетинговых флагов */ + .marketing-flag-switch { + margin: 0; + padding: 0; + flex-shrink: 0; + position: relative; + } + + .marketing-flag-switch .form-check-input { + width: 3.5rem !important; + height: 1.875rem !important; + cursor: pointer; + margin: 0; + background-color: #ced4da; + border: 2px solid #ced4da; + border-radius: 2rem !important; + transition: all 0.3s ease; + position: relative; + background-image: none !important; + } + + .marketing-flag-switch .form-check-input::after { + width: 1.5rem !important; + height: 1.5rem !important; + border-radius: 50% !important; + background-color: #fff; + transition: transform 0.3s ease; + transform: translateX(0.125rem); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + } + + .marketing-flag-switch .form-check-input:checked { + background-color: #28a745 !important; + border-color: #28a745 !important; + background-image: none !important; + } + + .marketing-flag-switch .form-check-input:checked::after { + transform: translateX(1.625rem); + } + + .marketing-flag-switch .form-check-input:focus { + box-shadow: 0 0 0 0.25rem rgba(40, 167, 69, 0.25); + border-color: #28a745; + } + + .marketing-flag-switch .form-check-label { + cursor: pointer; + margin: 0; + padding: 0; + } + + /* Адаптивность для маркетинговых флагов */ + @media (max-width: 768px) { + .marketing-flag-card { + padding: 0.75rem; + } + + .marketing-flag-icon { + font-size: 1.5rem; + width: 35px; + height: 35px; + } + + .marketing-flag-title { + font-size: 0.875rem; + } + + .marketing-flag-switch .form-check-input { + width: 3rem !important; + height: 1.625rem !important; + } + + .marketing-flag-switch .form-check-input::after { + width: 1.375rem !important; + height: 1.375rem !important; + } + + .marketing-flag-switch .form-check-input:checked::after { + transform: translateX(1.375rem); + } + } + + /* === Стили для таблицы единиц продажи === */ + #sales-units-container { + border-radius: 0.5rem; + overflow: hidden; + } + + #sales-units-container .table-responsive { + border-radius: 0.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + } + + #sales-units-container .table { + margin-bottom: 0; + border-collapse: separate; + border-spacing: 0; + background-color: #fff; + } + + #sales-units-container .table thead th { + background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%); + border-bottom: 2px solid #dee2e6; + font-weight: 600; + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 0.75rem 0.5rem; + vertical-align: middle; + white-space: nowrap; + position: sticky; + top: 0; + z-index: 10; + } + + #sales-units-container .table tbody tr { + transition: all 0.2s ease; + height: 60px; /* Фиксированная высота строки */ + } + + #sales-units-container .table tbody tr:hover { + background-color: #f8f9fa !important; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + } + + #sales-units-container .table tbody tr.bg-light { + background-color: #f8f9fa !important; + } + + #sales-units-container .table tbody td { + padding: 0.5rem; + vertical-align: middle; + border-top: 1px solid #e9ecef; + height: 60px; /* Фиксированная высота ячейки */ + border-right: 1px solid #f0f0f0; + } + + #sales-units-container .table tbody td:last-child { + border-right: none; + } + + #sales-units-container .table thead th:last-child { + border-right: none; + } + + /* Стили для полей ввода в таблице */ + #sales-units-container .table input[type="text"], + #sales-units-container .table input[type="number"] { + width: 100%; + min-width: 60px; + max-width: 100%; + padding: 0.375rem 0.5rem; + font-size: 0.875rem; + border: 1px solid #ced4da; + border-radius: 0.25rem; + transition: all 0.2s ease; + background-color: #fff; + } + + #sales-units-container .table input[type="text"]:focus, + #sales-units-container .table input[type="number"]:focus { + border-color: #28a745; + box-shadow: 0 0 0 0.2rem rgba(40, 167, 69, 0.15); + outline: none; + background-color: #fff; + } + + #sales-units-container .table input[type="text"]:hover, + #sales-units-container .table input[type="number"]:hover { + border-color: #adb5bd; + } + + /* Стили для оберток полей */ + #sales-units-container .table td > div { + min-height: 38px; + } + + /* Стили для чекбоксов */ + #sales-units-container .table input[type="checkbox"] { + width: 20px; + height: 20px; + cursor: pointer; + margin: 0 auto; + display: block; + accent-color: #28a745; + } + + #sales-units-container .table td.text-center { + text-align: center; + } + + /* Стилизация чекбоксов "Активна" и "Удалить" */ + #sales-units-container .table .form-check { + margin: 0; + display: flex; + align-items: center; + justify-content: center; + min-height: auto; + padding: 0.25rem; + border-radius: 0.25rem; + transition: background-color 0.2s ease; + } + + #sales-units-container .table .form-check:hover { + background-color: rgba(0, 0, 0, 0.03); + } + + #sales-units-container .table .form-check input[type="checkbox"] { + width: 18px; + height: 18px; + margin-right: 0.25rem; + cursor: pointer; + flex-shrink: 0; + } + + #sales-units-container .table .form-check-label { + font-size: 0.75rem; + margin: 0; + cursor: pointer; + user-select: none; + white-space: nowrap; + } + + /* Стили для чекбокса "Удалить" */ + #sales-units-container .table .form-check:has(input[name*="-DELETE"]) .form-check-label { + color: #dc3545; + } + + #sales-units-container .table .form-check:has(input[name*="-DELETE"]:checked) { + background-color: rgba(220, 53, 69, 0.1); + } + + /* Выделение строки с единицей по умолчанию */ + #sales-units-container .table tbody tr:has(input[name*="-is_default"]:checked) { + background-color: #d4edda !important; + border-left: 3px solid #28a745; + } + + #sales-units-container .table tbody tr:has(input[name*="-is_default"]:checked) td:first-child { + padding-left: calc(0.5rem - 3px); + } + + /* Стили для ошибок валидации */ + #sales-units-container .table tbody tr + tr[style*="display"] td { + padding: 0.75rem; + background-color: #f8d7da; + border-top: 2px solid #dc3545; + } + + /* Улучшение для кнопки добавления */ + #add-sales-unit { + transition: all 0.2s ease; + font-weight: 500; + } + + #add-sales-unit:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(40, 167, 69, 0.2); + } + + /* Стили для пустых полей */ + #sales-units-container .table input[type="text"]:placeholder-shown, + #sales-units-container .table input[type="number"]:placeholder-shown { + background-color: #f8f9fa; + } + + /* Улучшение для числовых полей */ + #sales-units-container .table input[type="number"] { + text-align: right; + } + + #sales-units-container .table td:has(input[type="number"]) { + text-align: right; + } + + /* Стили для первой колонки (название) */ + #sales-units-container .table tbody td:first-child input { + font-weight: 500; + text-align: left; + } + + /* Адаптивность для маленьких экранов */ + @media (max-width: 768px) { + #sales-units-container .table { + font-size: 0.8rem; + } + + #sales-units-container .table thead th, + #sales-units-container .table tbody td { + padding: 0.375rem 0.25rem; + } + + #sales-units-container .table tbody tr { + height: auto; + min-height: 50px; + } + + #sales-units-container .table input[type="text"], + #sales-units-container .table input[type="number"] { + font-size: 0.8rem; + padding: 0.25rem 0.375rem; + } + } {% endblock %} @@ -546,35 +905,61 @@
-
Маркетинговые флаги
-

Отображаются на внешних площадках (Recommerce и др.)

-
+
+
Маркетинговые флаги
+ Отображаются на внешних площадках +
+
-
- {{ form.is_new }} - +
+
+
+ +
+
Новинка
+ Товар как новый +
+
+
+ {{ form.is_new }} + +
+
- Товар отображается как новый
-
- {{ form.is_popular }} - +
+
+
+ +
+
Популярный
+ Популярный товар +
+
+
+ {{ form.is_popular }} + +
+
- Товар отображается как популярный
-
- {{ form.is_special }} - +
+
+
+ +
+
Спецпредложение
+ Акционный товар +
+
+
+ {{ form.is_special }} + +
+
- Акционный товар (+ автоматически при скидке)
@@ -605,122 +990,159 @@
- {% for form in sales_unit_formset %} -
-
- {% if form.instance.pk %} - - {% endif %} - -
- - {{ form.unit }} -
-
- - {{ form.name }} -
-
- - {{ form.conversion_factor }} -
-
- - {{ form.price }} -
-
- - {{ form.sale_price }} -
-
- - {{ form.min_quantity }} -
-
- - {{ form.quantity_step }} -
-
- - {{ form.position }} -
-
- - {{ form.is_default }} -
-
-
-
- {{ form.is_active }} -
+
+ + + + + + + + + + + + + + + + {% for form in sales_unit_formset %} + {% if form.instance.pk %} -
- {{ form.DELETE }} -
+ {% endif %} - - - - {% if form.errors %} -
- {% for field, errors in form.errors.items %} - {{ field }}: {{ errors|join:", " }} - {% endfor %} -
- {% endif %} + + + + + + + + + + + + {% if form.errors %} + + + + {% endif %} + {% endfor %} + +
НазваниеКоэфф.ЦенаСкидкаМин.колШагПоз.По умолч.Действия
+
+ {{ form.name }} +
+
+
+ {{ form.conversion_factor }} +
+
+
+ {{ form.price }} +
+
+
+ {{ form.sale_price }} +
+
+
+ {{ form.min_quantity }} +
+
+
+ {{ form.quantity_step }} +
+
+
+ {{ form.position }} +
+
+
+ {{ form.is_default }} +
+
+
+
+ {{ form.is_active }} + +
+ {% if form.instance.pk %} +
+ {{ form.DELETE }} + +
+ {% endif %} +
+
+
+ + {% for field, errors in form.errors.items %} + {{ field }}: {{ errors|join:", " }} + {% endfor %} +
+
- {% endfor %}