feat(products): обеспечить уникальность единицы продажи по умолчанию и улучшить UI формы
Добавлено ограничение на уровне базы данных и валидация форм для обеспечения, что у товара может быть только одна единица продажи с флагом "по умолчанию". Переработан интерфейс маркетинговых флагов и единиц продажи для улучшения UX. Основные изменения: - Добавлен UniqueConstraint в модель ProductSalesUnit для валидации на уровне БД - Создан BaseProductSalesUnitFormSet с кастомной валидацией формы - Обновлен метод save() для корректной обработки новых и существующих записей - Добавлена транзакционная обертка в представлениях ProductCreateView и ProductUpdateView - Переработан блок маркетинговых флагов с карточным дизайном и интерактивными переключателями - Переработан блок единиц продажи в табличный вид с улучшенным UX - Добавлена клиентская логика для взаимного исключения чекбоксов "По умолчанию
This commit is contained in:
@@ -1321,12 +1321,49 @@ class ProductSalesUnitInlineForm(forms.ModelForm):
|
|||||||
return super().has_changed()
|
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 для единиц продажи
|
# Inline formset для единиц продажи
|
||||||
ProductSalesUnitFormSet = inlineformset_factory(
|
ProductSalesUnitFormSet = inlineformset_factory(
|
||||||
Product,
|
Product,
|
||||||
ProductSalesUnit,
|
ProductSalesUnit,
|
||||||
form=ProductSalesUnitInlineForm,
|
form=ProductSalesUnitInlineForm,
|
||||||
extra=1,
|
formset=BaseProductSalesUnitFormSet,
|
||||||
|
extra=0,
|
||||||
can_delete=True,
|
can_delete=True,
|
||||||
min_num=0,
|
min_num=0,
|
||||||
validate_min=False,
|
validate_min=False,
|
||||||
|
|||||||
144
myproject/products/management/commands/check_default_units.py
Normal file
144
myproject/products/management/commands/check_default_units.py
Normal file
@@ -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)} товаров с дубликатами!'
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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='У товара может быть только одна единица продажи по умолчанию'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -128,6 +128,14 @@ class ProductSalesUnit(models.Model):
|
|||||||
verbose_name_plural = "Единицы продажи товаров"
|
verbose_name_plural = "Единицы продажи товаров"
|
||||||
ordering = ['position', 'id']
|
ordering = ['position', 'id']
|
||||||
unique_together = [['product', 'name']]
|
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):
|
def __str__(self):
|
||||||
return f"{self.product.name} - {self.name}"
|
return f"{self.product.name} - {self.name}"
|
||||||
@@ -146,10 +154,15 @@ class ProductSalesUnit(models.Model):
|
|||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
# Если это единица по умолчанию, снимаем флаг с других
|
# Если это единица по умолчанию, снимаем флаг с других
|
||||||
if self.is_default:
|
if self.is_default:
|
||||||
ProductSalesUnit.objects.filter(
|
# Используем exclude только если pk уже существует
|
||||||
|
# Для новых записей (pk=None) exclude не нужен, т.к. запись еще не в БД
|
||||||
|
queryset = ProductSalesUnit.objects.filter(
|
||||||
product=self.product,
|
product=self.product,
|
||||||
is_default=True
|
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)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -119,6 +119,365 @@
|
|||||||
.photo-card:hover .card {
|
.photo-card:hover .card {
|
||||||
border-color: #667eea;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -546,35 +905,61 @@
|
|||||||
|
|
||||||
<!-- Блок: Маркетинговые флаги -->
|
<!-- Блок: Маркетинговые флаги -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h5 class="mb-3"><i class="bi bi-tag"></i> Маркетинговые флаги</h5>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<p class="text-muted small mb-3">Отображаются на внешних площадках (Recommerce и др.)</p>
|
<h5 class="mb-0"><i class="bi bi-tag"></i> Маркетинговые флаги</h5>
|
||||||
<div class="row">
|
<small class="text-muted">Отображаются на внешних площадках</small>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="form-check form-switch">
|
<div class="marketing-flag-card">
|
||||||
{{ form.is_new }}
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
<label class="form-check-label" for="id_is_new">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<i class="bi bi-stars text-warning"></i> Новинка
|
<i class="bi bi-stars marketing-flag-icon text-warning"></i>
|
||||||
</label>
|
<div>
|
||||||
|
<div class="marketing-flag-title">Новинка</div>
|
||||||
|
<small class="text-muted">Товар как новый</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch marketing-flag-switch">
|
||||||
|
{{ form.is_new }}
|
||||||
|
<label class="form-check-label" for="id_is_new"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted d-block ms-4">Товар отображается как новый</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="form-check form-switch">
|
<div class="marketing-flag-card">
|
||||||
{{ form.is_popular }}
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
<label class="form-check-label" for="id_is_popular">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<i class="bi bi-fire text-danger"></i> Популярный
|
<i class="bi bi-fire marketing-flag-icon text-danger"></i>
|
||||||
</label>
|
<div>
|
||||||
|
<div class="marketing-flag-title">Популярный</div>
|
||||||
|
<small class="text-muted">Популярный товар</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch marketing-flag-switch">
|
||||||
|
{{ form.is_popular }}
|
||||||
|
<label class="form-check-label" for="id_is_popular"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted d-block ms-4">Товар отображается как популярный</small>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="form-check form-switch">
|
<div class="marketing-flag-card">
|
||||||
{{ form.is_special }}
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
<label class="form-check-label" for="id_is_special">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<i class="bi bi-percent text-success"></i> Спецпредложение
|
<i class="bi bi-percent marketing-flag-icon text-success"></i>
|
||||||
</label>
|
<div>
|
||||||
|
<div class="marketing-flag-title">Спецпредложение</div>
|
||||||
|
<small class="text-muted">Акционный товар</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-switch marketing-flag-switch">
|
||||||
|
{{ form.is_special }}
|
||||||
|
<label class="form-check-label" for="id_is_special"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted d-block ms-4">Акционный товар (+ автоматически при скидке)</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -605,122 +990,159 @@
|
|||||||
<!-- Шаблон для новых форм (скрыт) -->
|
<!-- Шаблон для новых форм (скрыт) -->
|
||||||
<template id="empty-sales-unit-template">
|
<template id="empty-sales-unit-template">
|
||||||
{% with form=sales_unit_formset.empty_form %}
|
{% with form=sales_unit_formset.empty_form %}
|
||||||
<div class="sales-unit-row border rounded p-3 mb-2">
|
<tr class="sales-unit-row">
|
||||||
<div class="row g-2 align-items-end">
|
<td>
|
||||||
<div class="col-md-2">
|
<div class="d-flex align-items-center h-100">
|
||||||
<label class="form-label small">Ед. измерения</label>
|
|
||||||
{{ form.unit }}
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label small">Название</label>
|
|
||||||
{{ form.name }}
|
{{ form.name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-1">
|
</td>
|
||||||
<label class="form-label small">Коэфф.</label>
|
<td>
|
||||||
|
<div class="d-flex align-items-center h-100">
|
||||||
{{ form.conversion_factor }}
|
{{ form.conversion_factor }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-1">
|
</td>
|
||||||
<label class="form-label small">Цена</label>
|
<td>
|
||||||
|
<div class="d-flex align-items-center h-100">
|
||||||
{{ form.price }}
|
{{ form.price }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-1">
|
</td>
|
||||||
<label class="form-label small">Скидка</label>
|
<td>
|
||||||
|
<div class="d-flex align-items-center h-100">
|
||||||
{{ form.sale_price }}
|
{{ form.sale_price }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-1">
|
</td>
|
||||||
<label class="form-label small">Мин.кол</label>
|
<td>
|
||||||
|
<div class="d-flex align-items-center h-100">
|
||||||
{{ form.min_quantity }}
|
{{ form.min_quantity }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-1">
|
</td>
|
||||||
<label class="form-label small">Шаг</label>
|
<td>
|
||||||
|
<div class="d-flex align-items-center h-100">
|
||||||
{{ form.quantity_step }}
|
{{ form.quantity_step }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-1">
|
</td>
|
||||||
<label class="form-label small">Поз.</label>
|
<td>
|
||||||
|
<div class="d-flex align-items-center h-100">
|
||||||
{{ form.position }}
|
{{ form.position }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-1 text-center">
|
</td>
|
||||||
<label class="form-label small d-block">По умолч.</label>
|
<td class="text-center">
|
||||||
|
<div class="d-flex align-items-center justify-content-center h-100">
|
||||||
{{ form.is_default }}
|
{{ form.is_default }}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-1">
|
</td>
|
||||||
<div class="d-flex gap-1">
|
<td>
|
||||||
<div class="form-check" title="Активна">
|
<div class="d-flex gap-2 align-items-center justify-content-center h-100 flex-wrap">
|
||||||
{{ form.is_active }}
|
<div class="form-check mb-0" title="Активна">
|
||||||
</div>
|
{{ form.is_active }}
|
||||||
|
<label class="form-check-label small" for="{{ form.is_active.id_for_label }}">
|
||||||
|
Активна
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div id="sales-units-container">
|
<div id="sales-units-container">
|
||||||
{% for form in sales_unit_formset %}
|
<div class="table-responsive">
|
||||||
<div class="sales-unit-row border rounded p-3 mb-2 {% if form.instance.pk %}bg-light{% endif %}">
|
<table class="table table-sm table-bordered">
|
||||||
<div class="row g-2 align-items-end">
|
<thead class="table-light">
|
||||||
{% if form.instance.pk %}
|
<tr>
|
||||||
<input type="hidden" name="{{ form.prefix }}-id" value="{{ form.instance.pk }}">
|
<th style="width: 20%">Название</th>
|
||||||
{% endif %}
|
<th style="width: 10%">Коэфф.</th>
|
||||||
|
<th style="width: 10%">Цена</th>
|
||||||
<div class="col-md-2">
|
<th style="width: 10%">Скидка</th>
|
||||||
<label class="form-label small">Ед. измерения</label>
|
<th style="width: 10%">Мин.кол</th>
|
||||||
{{ form.unit }}
|
<th style="width: 10%">Шаг</th>
|
||||||
</div>
|
<th style="width: 10%">Поз.</th>
|
||||||
<div class="col-md-2">
|
<th style="width: 10%" class="text-center">По умолч.</th>
|
||||||
<label class="form-label small">Название</label>
|
<th style="width: 10%">Действия</th>
|
||||||
{{ form.name }}
|
</tr>
|
||||||
</div>
|
</thead>
|
||||||
<div class="col-md-1">
|
<tbody>
|
||||||
<label class="form-label small">Коэфф.</label>
|
{% for form in sales_unit_formset %}
|
||||||
{{ form.conversion_factor }}
|
<tr class="{% if form.instance.pk %}bg-light{% endif %} sales-unit-row">
|
||||||
</div>
|
|
||||||
<div class="col-md-1">
|
|
||||||
<label class="form-label small">Цена</label>
|
|
||||||
{{ form.price }}
|
|
||||||
</div>
|
|
||||||
<div class="col-md-1">
|
|
||||||
<label class="form-label small">Скидка</label>
|
|
||||||
{{ form.sale_price }}
|
|
||||||
</div>
|
|
||||||
<div class="col-md-1">
|
|
||||||
<label class="form-label small">Мин.кол</label>
|
|
||||||
{{ form.min_quantity }}
|
|
||||||
</div>
|
|
||||||
<div class="col-md-1">
|
|
||||||
<label class="form-label small">Шаг</label>
|
|
||||||
{{ form.quantity_step }}
|
|
||||||
</div>
|
|
||||||
<div class="col-md-1">
|
|
||||||
<label class="form-label small">Поз.</label>
|
|
||||||
{{ form.position }}
|
|
||||||
</div>
|
|
||||||
<div class="col-md-1 text-center">
|
|
||||||
<label class="form-label small d-block">По умолч.</label>
|
|
||||||
{{ form.is_default }}
|
|
||||||
</div>
|
|
||||||
<div class="col-md-1">
|
|
||||||
<div class="d-flex gap-1">
|
|
||||||
<div class="form-check" title="Активна">
|
|
||||||
{{ form.is_active }}
|
|
||||||
</div>
|
|
||||||
{% if form.instance.pk %}
|
{% if form.instance.pk %}
|
||||||
<div class="form-check" title="Удалить">
|
<input type="hidden" name="{{ form.prefix }}-id" value="{{ form.instance.pk }}">
|
||||||
{{ form.DELETE }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
</div>
|
<td>
|
||||||
</div>
|
<div class="d-flex align-items-center h-100">
|
||||||
{% if form.errors %}
|
{{ form.name }}
|
||||||
<div class="text-danger small mt-1">
|
</div>
|
||||||
{% for field, errors in form.errors.items %}
|
</td>
|
||||||
{{ field }}: {{ errors|join:", " }}
|
<td>
|
||||||
{% endfor %}
|
<div class="d-flex align-items-center h-100">
|
||||||
</div>
|
{{ form.conversion_factor }}
|
||||||
{% endif %}
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center h-100">
|
||||||
|
{{ form.price }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center h-100">
|
||||||
|
{{ form.sale_price }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center h-100">
|
||||||
|
{{ form.min_quantity }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center h-100">
|
||||||
|
{{ form.quantity_step }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex align-items-center h-100">
|
||||||
|
{{ form.position }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="text-center">
|
||||||
|
<div class="d-flex align-items-center justify-content-center h-100">
|
||||||
|
{{ form.is_default }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex gap-2 align-items-center justify-content-center h-100 flex-wrap">
|
||||||
|
<div class="form-check mb-0" title="Активна">
|
||||||
|
{{ form.is_active }}
|
||||||
|
<label class="form-check-label small" for="{{ form.is_active.id_for_label }}">
|
||||||
|
Активна
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% if form.instance.pk %}
|
||||||
|
<div class="form-check mb-0" title="Удалить">
|
||||||
|
{{ form.DELETE }}
|
||||||
|
<label class="form-check-label small text-danger" for="{{ form.DELETE.id_for_label }}">
|
||||||
|
Удалить
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% if form.errors %}
|
||||||
|
<tr class="table-danger">
|
||||||
|
<td colspan="9" class="py-2">
|
||||||
|
<div class="text-danger small">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i>
|
||||||
|
{% for field, errors in form.errors.items %}
|
||||||
|
<strong>{{ field }}:</strong> {{ errors|join:", " }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn btn-outline-success btn-sm mt-2" id="add-sales-unit">
|
<button type="button" class="btn btn-outline-success btn-sm mt-2" id="add-sales-unit">
|
||||||
@@ -840,7 +1262,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
// === Динамическое добавление единиц продажи ===
|
// === Динамическое добавление единиц продажи ===
|
||||||
const addButton = document.getElementById('add-sales-unit');
|
const addButton = document.getElementById('add-sales-unit');
|
||||||
const container = document.getElementById('sales-units-container');
|
const container = document.querySelector('#sales-units-container tbody');
|
||||||
const totalFormsInput = document.querySelector('[name="sales_units-TOTAL_FORMS"]');
|
const totalFormsInput = document.querySelector('[name="sales_units-TOTAL_FORMS"]');
|
||||||
|
|
||||||
if (addButton && container && totalFormsInput) {
|
if (addButton && container && totalFormsInput) {
|
||||||
@@ -851,10 +1273,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (template) {
|
if (template) {
|
||||||
// Клонируем содержимое шаблона
|
// Клонируем содержимое шаблона
|
||||||
const newRow = template.content.cloneNode(true);
|
const newRow = template.content.cloneNode(true);
|
||||||
const rowDiv = newRow.querySelector('.sales-unit-row');
|
const row = newRow.querySelector('tr');
|
||||||
|
|
||||||
// Обновляем имена и id полей
|
// Обновляем имена и id полей
|
||||||
rowDiv.querySelectorAll('input, select').forEach(input => {
|
row.querySelectorAll('input, select').forEach(input => {
|
||||||
const name = input.getAttribute('name');
|
const name = input.getAttribute('name');
|
||||||
const id = input.getAttribute('id');
|
const id = input.getAttribute('id');
|
||||||
|
|
||||||
@@ -884,11 +1306,32 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
container.appendChild(rowDiv);
|
container.appendChild(row);
|
||||||
totalFormsInput.value = formCount + 1;
|
totalFormsInput.value = formCount + 1;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Логика для чекбоксов "По умолчанию" ===
|
||||||
|
// Используем делегирование событий для обработки всех чекбоксов is_default
|
||||||
|
// (включая динамически добавляемые)
|
||||||
|
const salesUnitsContainer = document.getElementById('sales-units-container');
|
||||||
|
if (salesUnitsContainer) {
|
||||||
|
salesUnitsContainer.addEventListener('change', function(e) {
|
||||||
|
// Проверяем, что это чекбокс is_default
|
||||||
|
if (e.target && e.target.name && e.target.name.includes('-is_default')) {
|
||||||
|
if (e.target.checked) {
|
||||||
|
// Если этот чекбокс отмечен, снимаем галочки с остальных
|
||||||
|
const allIsDefaultCheckboxes = document.querySelectorAll('input[name*="-is_default"]');
|
||||||
|
allIsDefaultCheckboxes.forEach(cb => {
|
||||||
|
if (cb !== e.target) {
|
||||||
|
cb.checked = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -122,34 +122,35 @@ class ProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateVie
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError, transaction
|
||||||
|
|
||||||
context = self.get_context_data()
|
context = self.get_context_data()
|
||||||
sales_unit_formset = context['sales_unit_formset']
|
sales_unit_formset = context['sales_unit_formset']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Сначала сохраняем товар
|
with transaction.atomic():
|
||||||
self.object = form.save()
|
# Сначала сохраняем товар
|
||||||
|
self.object = form.save()
|
||||||
|
|
||||||
# Затем сохраняем единицы продажи
|
# Затем сохраняем единицы продажи
|
||||||
if sales_unit_formset.is_valid():
|
if sales_unit_formset.is_valid():
|
||||||
sales_unit_formset.instance = self.object
|
sales_unit_formset.instance = self.object
|
||||||
sales_unit_formset.save()
|
sales_unit_formset.save()
|
||||||
else:
|
else:
|
||||||
# Если formset невалиден, показываем ошибки
|
# Если formset невалиден, показываем ошибки
|
||||||
for error in sales_unit_formset.errors:
|
for error in sales_unit_formset.errors:
|
||||||
if error:
|
if error:
|
||||||
messages.warning(self.request, f'Ошибка в единицах продажи: {error}')
|
messages.warning(self.request, f'Ошибка в единицах продажи: {error}')
|
||||||
|
|
||||||
# Обработка загрузки фотографий
|
# Обработка загрузки фотографий
|
||||||
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
||||||
if photo_errors:
|
if photo_errors:
|
||||||
for error in photo_errors:
|
for error in photo_errors:
|
||||||
# Если это предупреждение о лимите фото - warning, иначе - error
|
# Если это предупреждение о лимите фото - warning, иначе - error
|
||||||
if 'Загружено' in error and 'обработано только' in error:
|
if 'Загружено' in error and 'обработано только' in error:
|
||||||
messages.warning(self.request, error)
|
messages.warning(self.request, error)
|
||||||
else:
|
else:
|
||||||
messages.error(self.request, error)
|
messages.error(self.request, error)
|
||||||
|
|
||||||
messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!')
|
messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!')
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
@@ -157,7 +158,13 @@ class ProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateVie
|
|||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
# Обработка ошибки дублирования slug'а или других unique constraints
|
# Обработка ошибки дублирования slug'а или других unique constraints
|
||||||
error_msg = str(e).lower()
|
error_msg = str(e).lower()
|
||||||
if 'slug' in error_msg or 'duplicate key' in error_msg:
|
if 'unique_default_sales_unit_per_product' in error_msg or 'is_default' in error_msg:
|
||||||
|
messages.error(
|
||||||
|
self.request,
|
||||||
|
'Ошибка: у товара может быть только одна единица продажи по умолчанию. '
|
||||||
|
'Пожалуйста, выберите только одну единицу как "по умолчанию".'
|
||||||
|
)
|
||||||
|
elif 'slug' in error_msg or 'duplicate key' in error_msg:
|
||||||
messages.error(
|
messages.error(
|
||||||
self.request,
|
self.request,
|
||||||
f'Ошибка: товар с названием "{form.instance.name}" уже существует. '
|
f'Ошибка: товар с названием "{form.instance.name}" уже существует. '
|
||||||
@@ -249,34 +256,35 @@ class ProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateVie
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
from django.db import IntegrityError
|
from django.db import IntegrityError, transaction
|
||||||
|
|
||||||
context = self.get_context_data()
|
context = self.get_context_data()
|
||||||
sales_unit_formset = context['sales_unit_formset']
|
sales_unit_formset = context['sales_unit_formset']
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Сначала сохраняем товар
|
with transaction.atomic():
|
||||||
self.object = form.save()
|
# Сначала сохраняем товар
|
||||||
|
self.object = form.save()
|
||||||
|
|
||||||
# Затем сохраняем единицы продажи
|
# Затем сохраняем единицы продажи
|
||||||
if sales_unit_formset.is_valid():
|
if sales_unit_formset.is_valid():
|
||||||
sales_unit_formset.instance = self.object
|
sales_unit_formset.instance = self.object
|
||||||
sales_unit_formset.save()
|
sales_unit_formset.save()
|
||||||
else:
|
else:
|
||||||
# Если formset невалиден, показываем ошибки
|
# Если formset невалиден, показываем ошибки
|
||||||
for error in sales_unit_formset.errors:
|
for error in sales_unit_formset.errors:
|
||||||
if error:
|
if error:
|
||||||
messages.warning(self.request, f'Ошибка в единицах продажи: {error}')
|
messages.warning(self.request, f'Ошибка в единицах продажи: {error}')
|
||||||
|
|
||||||
# Обработка загрузки фотографий
|
# Обработка загрузки фотографий
|
||||||
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
||||||
if photo_errors:
|
if photo_errors:
|
||||||
for error in photo_errors:
|
for error in photo_errors:
|
||||||
# Если это предупреждение о лимите фото - warning, иначе - error
|
# Если это предупреждение о лимите фото - warning, иначе - error
|
||||||
if 'Загружено' in error and 'обработано только' in error:
|
if 'Загружено' in error and 'обработано только' in error:
|
||||||
messages.warning(self.request, error)
|
messages.warning(self.request, error)
|
||||||
else:
|
else:
|
||||||
messages.error(self.request, error)
|
messages.error(self.request, error)
|
||||||
|
|
||||||
messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!')
|
messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!')
|
||||||
return super().form_valid(form)
|
return super().form_valid(form)
|
||||||
@@ -284,7 +292,13 @@ class ProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateVie
|
|||||||
except IntegrityError as e:
|
except IntegrityError as e:
|
||||||
# Обработка ошибки дублирования slug'а или других unique constraints
|
# Обработка ошибки дублирования slug'а или других unique constraints
|
||||||
error_msg = str(e).lower()
|
error_msg = str(e).lower()
|
||||||
if 'slug' in error_msg or 'duplicate key' in error_msg:
|
if 'unique_default_sales_unit_per_product' in error_msg or 'is_default' in error_msg:
|
||||||
|
messages.error(
|
||||||
|
self.request,
|
||||||
|
'Ошибка: у товара может быть только одна единица продажи по умолчанию. '
|
||||||
|
'Пожалуйста, выберите только одну единицу как "по умолчанию".'
|
||||||
|
)
|
||||||
|
elif 'slug' in error_msg or 'duplicate key' in error_msg:
|
||||||
messages.error(
|
messages.error(
|
||||||
self.request,
|
self.request,
|
||||||
f'Ошибка: товар с названием "{form.instance.name}" уже существует. '
|
f'Ошибка: товар с названием "{form.instance.name}" уже существует. '
|
||||||
|
|||||||
Reference in New Issue
Block a user