diff --git a/myproject/products/forms.py b/myproject/products/forms.py index ff2bedc..7c59b92 100644 --- a/myproject/products/forms.py +++ b/myproject/products/forms.py @@ -1,6 +1,10 @@ from django import forms from django.forms import inlineformset_factory -from .models import Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem, ProductKitPhoto, ProductCategoryPhoto, ProductVariantGroup, ProductVariantGroupItem +from .models import ( + Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem, + ProductKitPhoto, ProductCategoryPhoto, ProductVariantGroup, ProductVariantGroupItem, + ConfigurableKitProduct, ConfigurableKitOption, ConfigurableKitProductAttribute +) class ProductForm(forms.ModelForm): @@ -577,3 +581,229 @@ class ProductTagForm(forms.ModelForm): if slug == '' or slug is None: return None return slug + + +# ==================== CONFIGURABLE KIT FORMS ==================== + +class ConfigurableKitProductForm(forms.ModelForm): + """ + Форма для создания и редактирования вариативного товара. + """ + class Meta: + model = ConfigurableKitProduct + fields = ['name', 'sku', 'description', 'short_description', 'status'] + labels = { + 'name': 'Название', + 'sku': 'Артикул', + 'description': 'Полное описание', + 'short_description': 'Краткое описание', + 'status': 'Статус' + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['name'].widget.attrs.update({ + 'class': 'form-control', + 'placeholder': 'Введите название вариативного товара' + }) + self.fields['sku'].widget.attrs.update({ + 'class': 'form-control', + 'placeholder': 'Артикул (необязательно)' + }) + self.fields['description'].widget.attrs.update({ + 'class': 'form-control', + 'rows': 5 + }) + self.fields['short_description'].widget.attrs.update({ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Краткое описание для экспорта' + }) + self.fields['status'].widget.attrs.update({'class': 'form-select'}) + + +class ConfigurableKitOptionForm(forms.ModelForm): + """ + Форма для добавления варианта (комплекта) к вариативному товару. + """ + kit = forms.ModelChoiceField( + queryset=ProductKit.objects.filter(status='active', is_temporary=False).order_by('name'), + required=True, + label="Комплект", + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + class Meta: + model = ConfigurableKitOption + fields = ['kit', 'attributes', 'is_default'] + labels = { + 'kit': 'Комплект', + 'attributes': 'Атрибуты варианта', + 'is_default': 'По умолчанию' + } + widgets = { + 'attributes': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Например: Количество:15;Длина:60см' + }), + 'is_default': forms.CheckboxInput(attrs={ + 'class': 'form-check-input is-default-switch', + 'role': 'switch' + }) + } + + +class BaseConfigurableKitOptionFormSet(forms.BaseInlineFormSet): + def clean(self): + """Проверка на дубликаты комплектов в вариативном товаре""" + if any(self.errors): + return + + kits = [] + default_count = 0 + + for form in self.forms: + if self.can_delete and self._should_delete_form(form): + continue + + kit = form.cleaned_data.get('kit') + is_default = form.cleaned_data.get('is_default') + + # Проверка дубликатов комплектов + if kit: + if kit in kits: + raise forms.ValidationError( + f'Комплект "{kit.name}" добавлен более одного раза. ' + f'Каждый комплект может быть добавлен только один раз.' + ) + kits.append(kit) + + # Считаем количество "is_default" + if is_default: + default_count += 1 + + # Проверяем, что не более одного "is_default" + if default_count > 1: + raise forms.ValidationError( + 'Можно установить только один вариант как "по умолчанию".' + ) + + +# Формсет для создания вариативного товара +ConfigurableKitOptionFormSetCreate = inlineformset_factory( + ConfigurableKitProduct, + ConfigurableKitOption, + form=ConfigurableKitOptionForm, + formset=BaseConfigurableKitOptionFormSet, + fields=['kit', 'attributes', 'is_default'], + extra=1, # Показать 1 пустую форму + can_delete=True, + min_num=0, + validate_min=False, + can_delete_extra=True, +) + +# Формсет для редактирования вариативного товара +ConfigurableKitOptionFormSetUpdate = inlineformset_factory( + ConfigurableKitProduct, + ConfigurableKitOption, + form=ConfigurableKitOptionForm, + formset=BaseConfigurableKitOptionFormSet, + fields=['kit', 'attributes', 'is_default'], + extra=0, # НЕ показывать пустые формы + can_delete=True, + min_num=0, + validate_min=False, + can_delete_extra=True, +) + + +# === Формы для атрибутов родительского вариативного товара === + +class ConfigurableKitProductAttributeForm(forms.ModelForm): + """ + Форма для добавления атрибута родительского товара. + Пример: name="Цвет", option="Красный" + """ + class Meta: + model = ConfigurableKitProductAttribute + fields = ['name', 'option', 'position', 'visible'] + labels = { + 'name': 'Название атрибута', + 'option': 'Значение опции', + 'position': 'Порядок', + 'visible': 'Видимый' + } + widgets = { + 'name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Например: Цвет, Размер, Длина' + }), + 'option': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Например: Красный, M, 60см' + }), + 'position': forms.NumberInput(attrs={ + 'class': 'form-control', + 'min': '0', + 'value': '0' + }), + 'visible': forms.CheckboxInput(attrs={ + 'class': 'form-check-input' + }) + } + + +class BaseConfigurableKitProductAttributeFormSet(forms.BaseInlineFormSet): + def clean(self): + """Проверка на дубликаты атрибутов""" + if any(self.errors): + return + + attributes = [] + + for form in self.forms: + if self.can_delete and self._should_delete_form(form): + continue + + name = form.cleaned_data.get('name') + option = form.cleaned_data.get('option') + + # Проверка дубликатов + if name and option: + attr_tuple = (name.strip(), option.strip()) + if attr_tuple in attributes: + raise forms.ValidationError( + f'Атрибут "{name}: {option}" добавлен более одного раза. ' + f'Каждая комбинация атрибут-значение должна быть уникальной.' + ) + attributes.append(attr_tuple) + + +# Формсет для создания атрибутов родительского товара +ConfigurableKitProductAttributeFormSetCreate = inlineformset_factory( + ConfigurableKitProduct, + ConfigurableKitProductAttribute, + form=ConfigurableKitProductAttributeForm, + formset=BaseConfigurableKitProductAttributeFormSet, + fields=['name', 'option', 'position', 'visible'], + extra=1, + can_delete=True, + min_num=0, + validate_min=False, + can_delete_extra=True, +) + +# Формсет для редактирования атрибутов родительского товара +ConfigurableKitProductAttributeFormSetUpdate = inlineformset_factory( + ConfigurableKitProduct, + ConfigurableKitProductAttribute, + form=ConfigurableKitProductAttributeForm, + formset=BaseConfigurableKitProductAttributeFormSet, + fields=['name', 'option', 'position', 'visible'], + extra=0, + can_delete=True, + min_num=0, + validate_min=False, + can_delete_extra=True, +) diff --git a/myproject/products/migrations/0002_configurablekitproduct_configurablekitoption.py b/myproject/products/migrations/0002_configurablekitproduct_configurablekitoption.py new file mode 100644 index 0000000..a7c904e --- /dev/null +++ b/myproject/products/migrations/0002_configurablekitproduct_configurablekitoption.py @@ -0,0 +1,52 @@ +# Generated by Django 5.0.10 on 2025-11-17 19:29 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ConfigurableKitProduct', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200, verbose_name='Название')), + ('sku', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Артикул')), + ('slug', models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор')), + ('description', models.TextField(blank=True, null=True, verbose_name='Описание')), + ('short_description', models.TextField(blank=True, help_text='Используется для карточек товаров, превью и площадок', null=True, verbose_name='Краткое описание')), + ('status', models.CharField(choices=[('active', 'Активный'), ('archived', 'Архивный'), ('discontinued', 'Снят')], db_index=True, default='active', max_length=20, verbose_name='Статус')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')), + ('archived_at', models.DateTimeField(blank=True, null=True, verbose_name='Время архивирования')), + ('archived_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='archived_%(class)s_set', to=settings.AUTH_USER_MODEL, verbose_name='Архивировано пользователем')), + ], + options={ + 'verbose_name': 'Конфигурируемый товар (из комплектов)', + 'verbose_name_plural': 'Конфигурируемые товары (из комплектов)', + }, + ), + migrations.CreateModel( + name='ConfigurableKitOption', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('attributes', models.TextField(blank=True, verbose_name='Атрибуты варианта (для внешних площадок)')), + ('is_default', models.BooleanField(default=False, verbose_name='Вариант по умолчанию')), + ('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='as_configurable_option_in', to='products.productkit', verbose_name='Комплект (вариант)')), + ('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='products.configurablekitproduct', verbose_name='Родитель (конфигурируемый товар)')), + ], + options={ + 'verbose_name': 'Вариант комплекта', + 'verbose_name_plural': 'Варианты комплектов', + 'indexes': [models.Index(fields=['parent'], name='products_co_parent__56ecfa_idx'), models.Index(fields=['kit'], name='products_co_kit_id_3fa7fe_idx'), models.Index(fields=['parent', 'is_default'], name='products_co_parent__ffa4ca_idx')], + 'unique_together': {('parent', 'kit')}, + }, + ), + ] diff --git a/myproject/products/migrations/0003_alter_configurablekitproduct_options_and_more.py b/myproject/products/migrations/0003_alter_configurablekitproduct_options_and_more.py new file mode 100644 index 0000000..6455340 --- /dev/null +++ b/myproject/products/migrations/0003_alter_configurablekitproduct_options_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.10 on 2025-11-17 19:30 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0002_configurablekitproduct_configurablekitoption'), + ] + + operations = [ + migrations.AlterModelOptions( + name='configurablekitproduct', + options={'verbose_name': 'Вариативный товар (из комплектов)', 'verbose_name_plural': 'Вариативные товары (из комплектов)'}, + ), + migrations.AlterField( + model_name='configurablekitoption', + name='parent', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='products.configurablekitproduct', verbose_name='Родитель (вариативный товар)'), + ), + ] diff --git a/myproject/products/migrations/0004_configurablekitproductattribute.py b/myproject/products/migrations/0004_configurablekitproductattribute.py new file mode 100644 index 0000000..15886f9 --- /dev/null +++ b/myproject/products/migrations/0004_configurablekitproductattribute.py @@ -0,0 +1,32 @@ +# Generated by Django 5.0.10 on 2025-11-17 21:38 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0003_alter_configurablekitproduct_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ConfigurableKitProductAttribute', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Например: Цвет, Размер, Длина', max_length=150, verbose_name='Название атрибута')), + ('option', models.CharField(help_text='Например: Красный, M, 60см', max_length=150, verbose_name='Значение опции')), + ('position', models.PositiveIntegerField(default=0, help_text='Меньше = выше в списке', verbose_name='Порядок отображения')), + ('visible', models.BooleanField(default=True, help_text='Показывать ли атрибут на странице товара', verbose_name='Видимый на витрине')), + ('parent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parent_attributes', to='products.configurablekitproduct', verbose_name='Родительский товар')), + ], + options={ + 'verbose_name': 'Атрибут вариативного товара', + 'verbose_name_plural': 'Атрибуты вариативных товаров', + 'ordering': ['parent', 'position', 'name', 'option'], + 'indexes': [models.Index(fields=['parent', 'name'], name='products_co_parent__4a7869_idx'), models.Index(fields=['parent', 'position'], name='products_co_parent__0904e2_idx')], + 'unique_together': {('parent', 'name', 'option')}, + }, + ), + ] diff --git a/myproject/products/models/__init__.py b/myproject/products/models/__init__.py index 4b2c9b7..ad6da9e 100644 --- a/myproject/products/models/__init__.py +++ b/myproject/products/models/__init__.py @@ -32,7 +32,7 @@ from .variants import ProductVariantGroup, ProductVariantGroupItem from .products import Product # Комплекты -from .kits import ProductKit, KitItem, KitItemPriority +from .kits import ProductKit, KitItem, KitItemPriority, ConfigurableKitProduct, ConfigurableKitOption, ConfigurableKitProductAttribute # Фотографии from .photos import BasePhoto, ProductPhoto, ProductKitPhoto, ProductCategoryPhoto, PhotoProcessingStatus @@ -63,6 +63,9 @@ __all__ = [ 'ProductKit', 'KitItem', 'KitItemPriority', + 'ConfigurableKitProduct', + 'ConfigurableKitOption', + 'ConfigurableKitProductAttribute', # Photos 'BasePhoto', diff --git a/myproject/products/models/kits.py b/myproject/products/models/kits.py index efae9df..d36d066 100644 --- a/myproject/products/models/kits.py +++ b/myproject/products/models/kits.py @@ -378,3 +378,113 @@ class KitItemPriority(models.Model): def __str__(self): return f"{self.product.name} (приоритет {self.priority})" + + +class ConfigurableKitProduct(BaseProductEntity): + """ + Вариативный товар, объединяющий несколько наших ProductKit + как варианты для внешних площадок (WooCommerce и подобные). + """ + class Meta: + verbose_name = "Вариативный товар (из комплектов)" + verbose_name_plural = "Вариативные товары (из комплектов)" + # Уникальность активного имени наследуется из BaseProductEntity + + def __str__(self): + return self.name + + def delete(self, *args, **kwargs): + """ + Физическое удаление вариативного товара из БД. + При удалении удаляются только связи (ConfigurableKitOption), + но сами ProductKit остаются нетронутыми благодаря CASCADE на уровне связей. + """ + # Используем super() для вызова стандартного Django delete, минуя BaseProductEntity.delete() + super(BaseProductEntity, self).delete(*args, **kwargs) + + +class ConfigurableKitProductAttribute(models.Model): + """ + Атрибут родительского вариативного товара. + Определяет схему атрибутов для экспорта на WooCommerce и подобные площадки. + Например: name="Цвет", option="Красный" или name="Размер", option="M". + """ + parent = models.ForeignKey( + ConfigurableKitProduct, + on_delete=models.CASCADE, + related_name='parent_attributes', + verbose_name="Родительский товар" + ) + name = models.CharField( + max_length=150, + verbose_name="Название атрибута", + help_text="Например: Цвет, Размер, Длина" + ) + option = models.CharField( + max_length=150, + verbose_name="Значение опции", + help_text="Например: Красный, M, 60см" + ) + position = models.PositiveIntegerField( + default=0, + verbose_name="Порядок отображения", + help_text="Меньше = выше в списке" + ) + visible = models.BooleanField( + default=True, + verbose_name="Видимый на витрине", + help_text="Показывать ли атрибут на странице товара" + ) + + class Meta: + verbose_name = "Атрибут вариативного товара" + verbose_name_plural = "Атрибуты вариативных товаров" + ordering = ['parent', 'position', 'name', 'option'] + unique_together = [['parent', 'name', 'option']] + indexes = [ + models.Index(fields=['parent', 'name']), + models.Index(fields=['parent', 'position']), + ] + + def __str__(self): + return f"{self.parent.name} - {self.name}: {self.option}" + + +class ConfigurableKitOption(models.Model): + """ + Отдельный вариант внутри ConfigurableKitProduct, указывающий на конкретный ProductKit. + Атрибуты варианта хранятся простым текстом (можно расширить до JSON позже). + """ + parent = models.ForeignKey( + ConfigurableKitProduct, + on_delete=models.CASCADE, + related_name='options', + verbose_name="Родитель (вариативный товар)" + ) + kit = models.ForeignKey( + ProductKit, + on_delete=models.CASCADE, + related_name='as_configurable_option_in', + verbose_name="Комплект (вариант)" + ) + attributes = models.TextField( + blank=True, + verbose_name="Атрибуты варианта (для внешних площадок)" + ) + is_default = models.BooleanField( + default=False, + verbose_name="Вариант по умолчанию" + ) + + class Meta: + verbose_name = "Вариант комплекта" + verbose_name_plural = "Варианты комплектов" + unique_together = [['parent', 'kit']] + indexes = [ + models.Index(fields=['parent']), + models.Index(fields=['kit']), + models.Index(fields=['parent', 'is_default']), + ] + + def __str__(self): + return f"{self.parent.name} → {self.kit.name}" diff --git a/myproject/products/static/products/js/configurablekit_detail.js b/myproject/products/static/products/js/configurablekit_detail.js new file mode 100644 index 0000000..b4c6dac --- /dev/null +++ b/myproject/products/static/products/js/configurablekit_detail.js @@ -0,0 +1,274 @@ +/** + * Управление вариантами вариативного товара (ConfigurableKitProduct) + */ + +document.addEventListener('DOMContentLoaded', function() { + // Проверяем наличие данных конфигурации + const configDataEl = document.getElementById('configurableKitData'); + if (!configDataEl) return; + + const config = JSON.parse(configDataEl.textContent); + const modal = new bootstrap.Modal(document.getElementById('addOptionModal')); + + // Элементы формы + const saveBtn = document.getElementById('saveOptionBtn'); + const kitSelect = document.getElementById('kitSelect'); + const attributesInput = document.getElementById('attributesInput'); + const isDefaultCheck = document.getElementById('isDefaultCheck'); + const errorDiv = document.getElementById('addOptionError'); + const spinner = document.getElementById('saveOptionSpinner'); + + // Добавление варианта + saveBtn.addEventListener('click', async function() { + const kitId = kitSelect.value; + if (!kitId) { + showError('Пожалуйста, выберите комплект'); + return; + } + + // Показываем спиннер + spinner.classList.remove('d-none'); + saveBtn.disabled = true; + hideError(); + + const formData = new FormData(); + formData.append('kit_id', kitId); + formData.append('attributes', attributesInput.value.trim()); + formData.append('is_default', isDefaultCheck.checked ? 'true' : 'false'); + formData.append('csrfmiddlewaretoken', getCsrfToken()); + + try { + const response = await fetch(config.addOptionUrl, { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (data.success) { + // Добавляем строку в таблицу + addOptionToTable(data.option); + + // Закрываем модальное окно + modal.hide(); + + // Очищаем форму + resetForm(); + + // Показываем уведомление + showSuccessMessage('Вариант успешно добавлен'); + } else { + showError(data.error || 'Ошибка при добавлении варианта'); + } + } catch (error) { + console.error('Error:', error); + showError('Произошла ошибка при сохранении'); + } finally { + spinner.classList.add('d-none'); + saveBtn.disabled = false; + } + }); + + // Удаление варианта + document.addEventListener('click', async function(e) { + if (e.target.closest('.remove-option-btn')) { + const btn = e.target.closest('.remove-option-btn'); + const optionId = btn.dataset.optionId; + + if (!confirm('Вы уверены, что хотите удалить этот вариант?')) { + return; + } + + btn.disabled = true; + + try { + const url = config.removeOptionUrlTemplate.replace('{optionId}', optionId); + const response = await fetch(url, { + method: 'POST', + headers: { + 'X-CSRFToken': getCsrfToken() + } + }); + + const data = await response.json(); + + if (data.success) { + // Удаляем строку из таблицы + const row = btn.closest('tr'); + row.remove(); + + // Проверяем, есть ли ещё варианты + checkIfTableEmpty(); + + showSuccessMessage('Вариант удалён'); + } else { + showError(data.error || 'Ошибка при удалении'); + btn.disabled = false; + } + } catch (error) { + console.error('Error:', error); + alert('Произошла ошибка при удалении'); + btn.disabled = false; + } + } + }); + + // Установка по умолчанию + document.addEventListener('click', async function(e) { + if (e.target.closest('.set-default-btn')) { + const btn = e.target.closest('.set-default-btn'); + const optionId = btn.dataset.optionId; + + btn.disabled = true; + + try { + const url = config.setDefaultUrlTemplate.replace('{optionId}', optionId); + const response = await fetch(url, { + method: 'POST', + headers: { + 'X-CSRFToken': getCsrfToken() + } + }); + + const data = await response.json(); + + if (data.success) { + // Обновляем UI + updateDefaultBadges(optionId); + showSuccessMessage('Вариант установлен как по умолчанию'); + } else { + showError(data.error || 'Ошибка при установке'); + btn.disabled = false; + } + } catch (error) { + console.error('Error:', error); + alert('Произошла ошибка'); + btn.disabled = false; + } + } + }); + + // Вспомогательные функции + function addOptionToTable(option) { + const tbody = document.querySelector('#optionsTable tbody'); + const noOptionsMsg = document.getElementById('noOptionsMessage'); + + // Если таблицы нет, создаём её + if (!tbody) { + const container = document.getElementById('optionsTableContainer'); + if (noOptionsMsg) noOptionsMsg.remove(); + + container.innerHTML = ` +
+ + + + + + + + + + + + +
КомплектАртикулЦенаАтрибутыПо умолчаниюДействия
+
+ `; + } + + const newTbody = document.querySelector('#optionsTable tbody'); + const kitDetailUrl = config.kitDetailUrlTemplate.replace('{kitId}', option.kit_id); + + const row = document.createElement('tr'); + row.dataset.optionId = option.id; + row.innerHTML = ` + + + ${option.kit_name} + + + ${option.kit_sku} + ${option.kit_price} руб. + ${option.attributes} + + ${option.is_default + ? 'Да' + : `` + } + + + + + `; + + newTbody.appendChild(row); + } + + function updateDefaultBadges(newDefaultId) { + // Убираем все badges "По умолчанию" + document.querySelectorAll('.default-badge').forEach(badge => { + const td = badge.closest('td'); + const optionId = badge.closest('tr').dataset.optionId; + td.innerHTML = ``; + }); + + // Добавляем badge к новому default + const targetRow = document.querySelector(`tr[data-option-id="${newDefaultId}"]`); + if (targetRow) { + const td = targetRow.querySelector('td:nth-child(5)'); + td.innerHTML = 'Да'; + } + } + + function checkIfTableEmpty() { + const tbody = document.querySelector('#optionsTable tbody'); + if (tbody && tbody.children.length === 0) { + const container = document.getElementById('optionsTableContainer'); + container.innerHTML = '

Нет вариантов. Нажмите "Добавить вариант" для добавления.

'; + } + } + + function resetForm() { + kitSelect.value = ''; + attributesInput.value = ''; + isDefaultCheck.checked = false; + hideError(); + } + + function showError(message) { + errorDiv.textContent = message; + errorDiv.classList.remove('d-none'); + } + + function hideError() { + errorDiv.classList.add('d-none'); + } + + function getCsrfToken() { + return document.querySelector('[name=csrfmiddlewaretoken]').value; + } + + function showSuccessMessage(message) { + // Простое alert, можно заменить на toast-уведомление + const alertDiv = document.createElement('div'); + alertDiv.className = 'alert alert-success alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3'; + alertDiv.style.zIndex = '9999'; + alertDiv.innerHTML = ` + ${message} + + `; + document.body.appendChild(alertDiv); + + setTimeout(() => { + alertDiv.remove(); + }, 3000); + } + + // Сброс формы при закрытии модального окна + document.getElementById('addOptionModal').addEventListener('hidden.bs.modal', function() { + resetForm(); + }); +}); diff --git a/myproject/products/templates/products/configurablekit_confirm_delete.html b/myproject/products/templates/products/configurablekit_confirm_delete.html new file mode 100644 index 0000000..83572d7 --- /dev/null +++ b/myproject/products/templates/products/configurablekit_confirm_delete.html @@ -0,0 +1,49 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Удаление вариативного товара{% endblock %} + +{% block content %} +
+ + +
+
+
+
+
Подтверждение удаления
+
+
+

+ Вы уверены, что хотите удалить вариативный товар {{ object.name }}? +

+ + {% if object.options.count > 0 %} +
+ + Внимание: У этого вариативного товара есть {{ object.options.count }} вариант(ов). + При удалении связи с комплектами будут удалены. +
+ {% endif %} + +
+ {% csrf_token %} + + + Отмена + +
+
+
+
+
+
+{% endblock %} diff --git a/myproject/products/templates/products/configurablekit_detail.html b/myproject/products/templates/products/configurablekit_detail.html new file mode 100644 index 0000000..a78e10f --- /dev/null +++ b/myproject/products/templates/products/configurablekit_detail.html @@ -0,0 +1,198 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}{{ configurable_kit.name }} - Детали{% endblock %} + +{% block content %} +{% csrf_token %} +
+ + + +
+

{{ configurable_kit.name }}

+
+ + Редактировать + + + Удалить + +
+
+ +
+ +
+
+
+
Основная информация
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Название:{{ configurable_kit.name }}
Артикул:{{ configurable_kit.sku|default:"—" }}
Статус: + {% if configurable_kit.status == 'active' %} + Активный + {% elif configurable_kit.status == 'archived' %} + Архивный + {% else %} + Снятый + {% endif %} +
Краткое описание:{{ configurable_kit.short_description|default:"—" }}
Описание:{{ configurable_kit.description|default:"—" }}
Дата создания:{{ configurable_kit.created_at|date:"d.m.Y H:i" }}
Дата обновления:{{ configurable_kit.updated_at|date:"d.m.Y H:i" }}
+
+
+ + +
+
+
Варианты (комплекты)
+
+
+ {% if configurable_kit.options.all %} +
+ + + + + + + + + + + + {% for option in configurable_kit.options.all %} + + + + + + + + {% endfor %} + +
КомплектАртикулЦенаАтрибутыПо умолчанию
+ + {{ option.kit.name }} + + {{ option.kit.sku|default:"—" }}{{ option.kit.actual_price }} руб.{{ option.attributes|default:"—" }} + {% if option.is_default %} + Да + {% else %} + Нет + {% endif %} +
+
+ {% else %} +

+ Нет вариантов. Перейдите в режим редактирования для добавления. +

+ {% endif %} +
+
+ + +
+
+
Атрибуты товара
+
+
+ {% if configurable_kit.parent_attributes.all %} +
+ + + + + + + + + + + {% for attr in configurable_kit.parent_attributes.all %} + + + + + + + {% endfor %} + +
Название атрибутаЗначение опцииПорядокВидимый
{{ attr.name }}{{ attr.option }}{{ attr.position }} + {% if attr.visible %} + Да + {% else %} + Нет + {% endif %} +
+
+ {% else %} +

+ Нет атрибутов. Перейдите в режим редактирования для добавления. +

+ {% endif %} +
+
+
+ + +
+
+
+
Справка
+
+
+

+ Вариативный товар предназначен для экспорта на WooCommerce и подобные площадки как Variable Product. +

+

+ Каждый вариант — это отдельный ProductKit с собственной ценой, артикулом и атрибутами. +

+
+

+ Количество вариантов: {{ configurable_kit.options.count }} +

+

+ Атрибутов товара: {{ configurable_kit.parent_attributes.count }} +

+
+
+
+
+
+{% endblock %} diff --git a/myproject/products/templates/products/configurablekit_form.html b/myproject/products/templates/products/configurablekit_form.html new file mode 100644 index 0000000..7f035be --- /dev/null +++ b/myproject/products/templates/products/configurablekit_form.html @@ -0,0 +1,494 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}{% if object %}Редактирование{% else %}Создание{% endif %} вариативного товара{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ + +
+ {% csrf_token %} + +
+ +
+
+
+
Основная информация
+
+
+
+ + {{ form.name }} + {% if form.name.errors %} +
{{ form.name.errors.0 }}
+ {% endif %} +
+ +
+ + {{ form.sku }} + {% if form.sku.errors %} +
{{ form.sku.errors.0 }}
+ {% endif %} +
+ +
+ + {{ form.short_description }} + {% if form.short_description.errors %} +
{{ form.short_description.errors.0 }}
+ {% endif %} +
+ +
+ + {{ form.description }} + {% if form.description.errors %} +
{{ form.description.errors.0 }}
+ {% endif %} +
+ +
+ + {{ form.status }} + {% if form.status.errors %} +
{{ form.status.errors.0 }}
+ {% endif %} +
+
+
+ + +
+
+
Варианты (комплекты)
+
+
+ {{ option_formset.management_form }} + + {% if option_formset.non_form_errors %} +
+ {{ option_formset.non_form_errors }} +
+ {% endif %} + +
+ {% for form in option_formset %} +
+ {{ form.id }} + {% if form.instance.pk %} + + {% endif %} +
+
+ + {{ form.kit }} + {% if form.kit.errors %} +
{{ form.kit.errors.0 }}
+ {% endif %} +
+
+ + {{ form.attributes }} + {% if form.attributes.errors %} +
{{ form.attributes.errors.0 }}
+ {% endif %} +
+
+ +
+ {{ form.is_default }} + +
+ {% if form.is_default.errors %} +
{{ form.is_default.errors.0 }}
+ {% endif %} +
+
+ {% if option_formset.can_delete %} + + {{ form.DELETE }} + + {% endif %} +
+
+
+ {% endfor %} +
+ + +
+
+ + +
+
+
Атрибуты товара (для WooCommerce)
+
+
+

+ Определите схему атрибутов для вариативного товара. Например: Цвет=Красный, Размер=M, Длина=60см. +

+ + {{ attribute_formset.management_form }} + + {% if attribute_formset.non_form_errors %} +
+ {{ attribute_formset.non_form_errors }} +
+ {% endif %} + +
+ {% for form in attribute_formset %} +
+ {{ form.id }} + {% if form.instance.pk %} + + {% endif %} +
+
+ + {{ form.name }} + {% if form.name.errors %} +
{{ form.name.errors.0 }}
+ {% endif %} +
+
+ + {{ form.option }} + {% if form.option.errors %} +
{{ form.option.errors.0 }}
+ {% endif %} +
+
+ + {{ form.position }} + {% if form.position.errors %} +
{{ form.position.errors.0 }}
+ {% endif %} +
+
+ +
+ {{ form.visible }} + +
+ {% if form.visible.errors %} +
{{ form.visible.errors.0 }}
+ {% endif %} +
+
+ {% if attribute_formset.can_delete %} + + {{ form.DELETE }} + + {% endif %} +
+
+
+ {% endfor %} +
+ + +
+
+ +
+ + + Отмена + +
+
+ + +
+
+
+
Справка
+
+
+

+ Вариативный товар объединяет несколько комплектов как варианты для экспорта на WooCommerce и подобные площадки. +

+

+ Каждый вариант — это отдельный ProductKit с собственной ценой, артикулом и атрибутами. +

+
+
+
+
+
+
+ + +{% endblock %} diff --git a/myproject/products/templates/products/configurablekit_list.html b/myproject/products/templates/products/configurablekit_list.html new file mode 100644 index 0000000..5e32661 --- /dev/null +++ b/myproject/products/templates/products/configurablekit_list.html @@ -0,0 +1,146 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Вариативные товары{% endblock %} + +{% block content %} +
+ + + +
+

Вариативные товары

+
+ {% for button in action_buttons %} + + {{ button.text }} + + {% endfor %} +
+
+ + +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
+ + + + + + + + + + + + + {% for item in configurable_kits %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
НазваниеАртикулСтатусВариантовДата созданияДействия
+ + {{ item.name }} + + + {{ item.sku|default:"-" }} + + {% if item.status == 'active' %} + Активный + {% elif item.status == 'archived' %} + Архивный + {% else %} + Снятый + {% endif %} + + {{ item.options.count }} + {{ item.created_at|date:"d.m.Y H:i" }} + + + + + + + + + +
+ Нет вариативных товаров. Создать первый +
+
+
+ + + {% if is_paginated %} + + {% endif %} +
+{% endblock %} diff --git a/myproject/products/urls.py b/myproject/products/urls.py index e10abf7..c120ad1 100644 --- a/myproject/products/urls.py +++ b/myproject/products/urls.py @@ -77,4 +77,16 @@ urlpatterns = [ path('tags//', views.ProductTagDetailView.as_view(), name='tag-detail'), path('tags//update/', views.ProductTagUpdateView.as_view(), name='tag-update'), path('tags//delete/', views.ProductTagDeleteView.as_view(), name='tag-delete'), + + # CRUD URLs for ConfigurableKitProduct + path('configurable-kits/', views.ConfigurableKitProductListView.as_view(), name='configurablekit-list'), + path('configurable-kits/create/', views.ConfigurableKitProductCreateView.as_view(), name='configurablekit-create'), + path('configurable-kits//', views.ConfigurableKitProductDetailView.as_view(), name='configurablekit-detail'), + path('configurable-kits//update/', views.ConfigurableKitProductUpdateView.as_view(), name='configurablekit-update'), + path('configurable-kits//delete/', views.ConfigurableKitProductDeleteView.as_view(), name='configurablekit-delete'), + + # API для управления вариантами ConfigurableKitProduct + path('configurable-kits//options/add/', views.add_option_to_configurable, name='configurablekit-add-option'), + path('configurable-kits//options//remove/', views.remove_option_from_configurable, name='configurablekit-remove-option'), + path('configurable-kits//options//set-default/', views.set_option_as_default, name='configurablekit-set-default-option'), ] \ No newline at end of file diff --git a/myproject/products/views/__init__.py b/myproject/products/views/__init__.py index cecae97..25d1285 100644 --- a/myproject/products/views/__init__.py +++ b/myproject/products/views/__init__.py @@ -79,6 +79,18 @@ from .tag_views import ( ProductTagDeleteView, ) +# CRUD представления для ConfigurableKitProduct +from .configurablekit_views import ( + ConfigurableKitProductListView, + ConfigurableKitProductCreateView, + ConfigurableKitProductDetailView, + ConfigurableKitProductUpdateView, + ConfigurableKitProductDeleteView, + add_option_to_configurable, + remove_option_from_configurable, + set_option_as_default, +) + # API представления from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api, create_tag_api @@ -145,6 +157,16 @@ __all__ = [ 'ProductTagUpdateView', 'ProductTagDeleteView', + # ConfigurableKitProduct CRUD + 'ConfigurableKitProductListView', + 'ConfigurableKitProductCreateView', + 'ConfigurableKitProductDetailView', + 'ConfigurableKitProductUpdateView', + 'ConfigurableKitProductDeleteView', + 'add_option_to_configurable', + 'remove_option_from_configurable', + 'set_option_as_default', + # API 'search_products_and_variants', 'validate_kit_cost', diff --git a/myproject/products/views/configurablekit_views.py b/myproject/products/views/configurablekit_views.py new file mode 100644 index 0000000..7cf4003 --- /dev/null +++ b/myproject/products/views/configurablekit_views.py @@ -0,0 +1,367 @@ +""" +CRUD представления для вариативных товаров (ConfigurableKitProduct). +""" +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView +from django.urls import reverse_lazy +from django.db.models import Q, Prefetch +from django.http import JsonResponse +from django.shortcuts import get_object_or_404 +from django.views.decorators.http import require_POST +from django.contrib.auth.decorators import login_required +from django.db import transaction + +from ..models import ConfigurableKitProduct, ConfigurableKitOption, ProductKit, ConfigurableKitProductAttribute +from ..forms import ( + ConfigurableKitProductForm, + ConfigurableKitOptionFormSetCreate, + ConfigurableKitOptionFormSetUpdate, + ConfigurableKitProductAttributeFormSetCreate, + ConfigurableKitProductAttributeFormSetUpdate +) + + +class ConfigurableKitProductListView(LoginRequiredMixin, ListView): + model = ConfigurableKitProduct + template_name = 'products/configurablekit_list.html' + context_object_name = 'configurable_kits' + paginate_by = 20 + + def get_queryset(self): + queryset = super().get_queryset().prefetch_related( + Prefetch( + 'options', + queryset=ConfigurableKitOption.objects.select_related('kit') + ) + ) + + # Поиск + search_query = self.request.GET.get('search') + if search_query: + queryset = queryset.filter( + Q(name__icontains=search_query) | + Q(sku__icontains=search_query) | + Q(description__icontains=search_query) + ) + + # Фильтр по статусу + status_filter = self.request.GET.get('status') + if status_filter: + queryset = queryset.filter(status=status_filter) + + return queryset.order_by('-created_at') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Данные для фильтров + context['filters'] = { + 'current': { + 'search': self.request.GET.get('search', ''), + 'status': self.request.GET.get('status', ''), + } + } + + # Кнопки действий + action_buttons = [] + + if self.request.user.has_perm('products.add_configurablekitproduct'): + action_buttons.append({ + 'url': reverse_lazy('products:configurablekit-create'), + 'text': 'Создать вариативный товар', + 'class': 'btn-primary', + 'icon': 'plus-circle' + }) + + context['action_buttons'] = action_buttons + + return context + + +class ConfigurableKitProductDetailView(LoginRequiredMixin, DetailView): + model = ConfigurableKitProduct + template_name = 'products/configurablekit_detail.html' + context_object_name = 'configurable_kit' + + def get_queryset(self): + return super().get_queryset().prefetch_related( + Prefetch( + 'options', + queryset=ConfigurableKitOption.objects.select_related('kit').order_by('id') + ), + 'parent_attributes' + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Добавляем доступные комплекты для выбора (активные, не временные) + context['available_kits'] = ProductKit.objects.filter( + status='active', + is_temporary=False + ).order_by('name') + return context + + +class ConfigurableKitProductCreateView(LoginRequiredMixin, CreateView): + model = ConfigurableKitProduct + form_class = ConfigurableKitProductForm + template_name = 'products/configurablekit_form.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Formset для вариантов + if 'option_formset' in kwargs: + context['option_formset'] = kwargs['option_formset'] + elif self.request.POST: + context['option_formset'] = ConfigurableKitOptionFormSetCreate( + self.request.POST, + prefix='options' + ) + else: + context['option_formset'] = ConfigurableKitOptionFormSetCreate( + prefix='options' + ) + + # Formset для атрибутов родителя + if 'attribute_formset' in kwargs: + context['attribute_formset'] = kwargs['attribute_formset'] + elif self.request.POST: + context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate( + self.request.POST, + prefix='attributes' + ) + else: + context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate( + prefix='attributes' + ) + + return context + + def form_valid(self, form): + # Пересоздаём formsets с POST данными + option_formset = ConfigurableKitOptionFormSetCreate( + self.request.POST, + prefix='options' + ) + attribute_formset = ConfigurableKitProductAttributeFormSetCreate( + self.request.POST, + prefix='attributes' + ) + + if not form.is_valid(): + messages.error(self.request, 'Пожалуйста, исправьте ошибки в основной форме.') + return self.form_invalid(form) + + if not option_formset.is_valid(): + messages.error(self.request, 'Пожалуйста, исправьте ошибки в вариантах.') + return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset)) + + if not attribute_formset.is_valid(): + messages.error(self.request, 'Пожалуйста, исправьте ошибки в атрибутах.') + return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset)) + + try: + with transaction.atomic(): + # Сохраняем основную форму + self.object = form.save() + + # Сохраняем варианты + option_formset.instance = self.object + option_formset.save() + + # Сохраняем атрибуты родителя + attribute_formset.instance = self.object + attribute_formset.save() + + messages.success(self.request, f'Вариативный товар "{self.object.name}" успешно создан!') + return super().form_valid(form) + + except Exception as e: + messages.error(self.request, f'Ошибка при сохранении: {str(e)}') + return self.form_invalid(form) + + def get_success_url(self): + return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk}) + + +class ConfigurableKitProductUpdateView(LoginRequiredMixin, UpdateView): + model = ConfigurableKitProduct + form_class = ConfigurableKitProductForm + template_name = 'products/configurablekit_form.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Formset для вариантов + if 'option_formset' in kwargs: + context['option_formset'] = kwargs['option_formset'] + elif self.request.POST: + context['option_formset'] = ConfigurableKitOptionFormSetUpdate( + self.request.POST, + instance=self.object, + prefix='options' + ) + else: + context['option_formset'] = ConfigurableKitOptionFormSetUpdate( + instance=self.object, + prefix='options' + ) + + # Formset для атрибутов родителя + if 'attribute_formset' in kwargs: + context['attribute_formset'] = kwargs['attribute_formset'] + elif self.request.POST: + context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate( + self.request.POST, + instance=self.object, + prefix='attributes' + ) + else: + context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate( + instance=self.object, + prefix='attributes' + ) + + return context + + def form_valid(self, form): + # Пересоздаём formsets с POST данными + option_formset = ConfigurableKitOptionFormSetUpdate( + self.request.POST, + instance=self.object, + prefix='options' + ) + attribute_formset = ConfigurableKitProductAttributeFormSetUpdate( + self.request.POST, + instance=self.object, + prefix='attributes' + ) + + if not form.is_valid(): + messages.error(self.request, 'Пожалуйста, исправьте ошибки в основной форме.') + return self.form_invalid(form) + + if not option_formset.is_valid(): + messages.error(self.request, 'Пожалуйста, исправьте ошибки в вариантах.') + return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset)) + + if not attribute_formset.is_valid(): + messages.error(self.request, 'Пожалуйста, исправьте ошибки в атрибутах.') + return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset)) + + try: + with transaction.atomic(): + # Сохраняем основную форму + self.object = form.save() + + # Сохраняем варианты + option_formset.save() + + # Сохраняем атрибуты родителя + attribute_formset.save() + + messages.success(self.request, f'Вариативный товар "{self.object.name}" успешно обновлён!') + return super().form_valid(form) + + except Exception as e: + messages.error(self.request, f'Ошибка при сохранении: {str(e)}') + return self.form_invalid(form) + + def get_success_url(self): + return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk}) + + +class ConfigurableKitProductDeleteView(LoginRequiredMixin, DeleteView): + model = ConfigurableKitProduct + template_name = 'products/configurablekit_confirm_delete.html' + success_url = reverse_lazy('products:configurablekit-list') + + def form_valid(self, form): + messages.success(self.request, f'Вариативный товар "{self.object.name}" успешно удалён!') + return super().form_valid(form) + + +# API для управления вариантами + +@login_required +@require_POST +def add_option_to_configurable(request, pk): + """ + Добавить вариант (комплект) к вариативному товару. + """ + configurable = get_object_or_404(ConfigurableKitProduct, pk=pk) + kit_id = request.POST.get('kit_id') + attributes = request.POST.get('attributes', '') + is_default = request.POST.get('is_default') == 'true' + + if not kit_id: + return JsonResponse({'success': False, 'error': 'Не указан комплект'}, status=400) + + try: + kit = ProductKit.objects.get(pk=kit_id) + except ProductKit.DoesNotExist: + return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404) + + # Проверяем, не добавлен ли уже этот комплект + if ConfigurableKitOption.objects.filter(parent=configurable, kit=kit).exists(): + return JsonResponse({'success': False, 'error': 'Этот комплект уже добавлен как вариант'}, status=400) + + # Если is_default=True, снимаем флаг с других + if is_default: + ConfigurableKitOption.objects.filter(parent=configurable, is_default=True).update(is_default=False) + + # Создаём вариант + option = ConfigurableKitOption.objects.create( + parent=configurable, + kit=kit, + attributes=attributes, + is_default=is_default + ) + + return JsonResponse({ + 'success': True, + 'option': { + 'id': option.id, + 'kit_id': kit.id, + 'kit_name': kit.name, + 'kit_sku': kit.sku or '—', + 'kit_price': str(kit.actual_price), + 'attributes': option.attributes or '—', + 'is_default': option.is_default, + } + }) + + +@login_required +@require_POST +def remove_option_from_configurable(request, pk, option_id): + """ + Удалить вариант из вариативного товара. + """ + configurable = get_object_or_404(ConfigurableKitProduct, pk=pk) + option = get_object_or_404(ConfigurableKitOption, pk=option_id, parent=configurable) + + option.delete() + + return JsonResponse({'success': True, 'message': 'Вариант удалён'}) + + +@login_required +@require_POST +def set_option_as_default(request, pk, option_id): + """ + Установить вариант как по умолчанию. + """ + configurable = get_object_or_404(ConfigurableKitProduct, pk=pk) + option = get_object_or_404(ConfigurableKitOption, pk=option_id, parent=configurable) + + # Снимаем флаг со всех других + ConfigurableKitOption.objects.filter(parent=configurable).update(is_default=False) + + # Устанавливаем текущий + option.is_default = True + option.save(update_fields=['is_default']) + + return JsonResponse({'success': True, 'message': 'Вариант установлен как по умолчанию'}) diff --git a/myproject/templates/navbar.html b/myproject/templates/navbar.html index 6442040..fe2f1ee 100644 --- a/myproject/templates/navbar.html +++ b/myproject/templates/navbar.html @@ -21,6 +21,9 @@ +