refactor: rename primary_category to external_category
This commit is contained in:
@@ -48,7 +48,7 @@ def to_api_product(
|
||||
data['name'] = product.name
|
||||
|
||||
if 'parent_category_sku' in fields:
|
||||
# Получаем категорию через primary_category или fallback на M2M categories
|
||||
# Получаем категорию через external_category или fallback на M2M categories
|
||||
_, category_sku = product.get_category_for_integration('recommerce')
|
||||
if category_sku:
|
||||
data['parent_category_sku'] = category_sku
|
||||
|
||||
@@ -396,7 +396,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||
search_fields = ('name', 'sku', 'description', 'search_keywords')
|
||||
filter_horizontal = ('categories', 'tags', 'variant_groups')
|
||||
readonly_fields = ('photo_preview_large', 'archived_at', 'archived_by', 'cost_price', 'cost_price_details_display')
|
||||
autocomplete_fields = ['base_unit', 'primary_category']
|
||||
autocomplete_fields = ['base_unit', 'external_category']
|
||||
actions = [
|
||||
restore_items,
|
||||
delete_selected,
|
||||
@@ -408,7 +408,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'primary_category', 'base_unit', 'unit', 'price', 'sale_price')
|
||||
'fields': ('name', 'sku', 'variant_suffix', 'description', 'short_description', 'categories', 'external_category', 'base_unit', 'unit', 'price', 'sale_price')
|
||||
}),
|
||||
('Себестоимость', {
|
||||
'fields': ('cost_price_details_display',),
|
||||
@@ -589,7 +589,7 @@ class ProductKitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
filter_horizontal = ('categories', 'tags')
|
||||
readonly_fields = ('photo_preview_large', 'base_price', 'archived_at', 'archived_by', 'order')
|
||||
autocomplete_fields = ['primary_category']
|
||||
autocomplete_fields = ['external_category']
|
||||
actions = [
|
||||
show_poor_quality_photos,
|
||||
show_excellent_quality_photos,
|
||||
@@ -598,7 +598,7 @@ class ProductKitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
'fields': ('name', 'sku', 'slug', 'description', 'short_description', 'categories', 'primary_category')
|
||||
'fields': ('name', 'sku', 'slug', 'description', 'short_description', 'categories', 'external_category')
|
||||
}),
|
||||
('Ценообразование', {
|
||||
'fields': ('base_price', 'price', 'sale_price', 'price_adjustment_type', 'price_adjustment_value'),
|
||||
@@ -1063,11 +1063,11 @@ class ConfigurableProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
|
||||
search_fields = ('name', 'sku', 'description')
|
||||
readonly_fields = ('created_at', 'updated_at', 'slug')
|
||||
inlines = [ConfigurableProductOptionInline, ConfigurableProductAttributeInline]
|
||||
autocomplete_fields = ['primary_category']
|
||||
autocomplete_fields = ['external_category']
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
'fields': ('name', 'sku', 'slug', 'status', 'primary_category')
|
||||
'fields': ('name', 'sku', 'slug', 'status', 'external_category')
|
||||
}),
|
||||
('Описание', {
|
||||
'fields': ('short_description', 'description')
|
||||
|
||||
@@ -87,12 +87,12 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm):
|
||||
label="Категории"
|
||||
)
|
||||
|
||||
primary_category = forms.ModelChoiceField(
|
||||
external_category = forms.ModelChoiceField(
|
||||
queryset=ProductCategory.objects.filter(is_active=True),
|
||||
required=False,
|
||||
empty_label="Не выбрана",
|
||||
label="Основная категория",
|
||||
help_text="Используется для интеграций с внешними площадками",
|
||||
label="Внешняя категория",
|
||||
help_text="Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)",
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
@@ -107,7 +107,7 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm):
|
||||
model = Product
|
||||
fields = [
|
||||
'name', 'sku', 'description', 'short_description', 'categories',
|
||||
'primary_category', 'tags', 'base_unit', 'price', 'sale_price', 'status',
|
||||
'external_category', 'tags', 'base_unit', 'price', 'sale_price', 'status',
|
||||
'is_new', 'is_popular', 'is_special'
|
||||
]
|
||||
labels = {
|
||||
@@ -202,12 +202,12 @@ class ProductKitForm(SKUUniqueMixin, forms.ModelForm):
|
||||
label="Категории"
|
||||
)
|
||||
|
||||
primary_category = forms.ModelChoiceField(
|
||||
external_category = forms.ModelChoiceField(
|
||||
queryset=ProductCategory.objects.filter(is_active=True),
|
||||
required=False,
|
||||
empty_label="Не выбрана",
|
||||
label="Основная категория",
|
||||
help_text="Используется для интеграций с внешними площадками",
|
||||
label="Внешняя категория",
|
||||
help_text="Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)",
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
@@ -222,7 +222,7 @@ class ProductKitForm(SKUUniqueMixin, forms.ModelForm):
|
||||
model = ProductKit
|
||||
fields = [
|
||||
'name', 'sku', 'description', 'short_description', 'categories',
|
||||
'primary_category', 'tags', 'sale_price', 'price_adjustment_type', 'price_adjustment_value', 'status'
|
||||
'external_category', 'tags', 'sale_price', 'price_adjustment_type', 'price_adjustment_value', 'status'
|
||||
]
|
||||
labels = {
|
||||
'name': 'Название',
|
||||
@@ -689,18 +689,18 @@ class ConfigurableProductForm(SKUUniqueMixin, forms.ModelForm):
|
||||
"""
|
||||
Форма для создания и редактирования вариативного товара.
|
||||
"""
|
||||
primary_category = forms.ModelChoiceField(
|
||||
external_category = forms.ModelChoiceField(
|
||||
queryset=ProductCategory.objects.filter(is_active=True),
|
||||
required=False,
|
||||
empty_label="Не выбрана",
|
||||
label="Основная категория",
|
||||
help_text="Используется для интеграций с внешними площадками",
|
||||
label="Внешняя категория",
|
||||
help_text="Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)",
|
||||
widget=forms.Select(attrs={'class': 'form-select'})
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ConfigurableProduct
|
||||
fields = ['name', 'sku', 'description', 'short_description', 'primary_category', 'status']
|
||||
fields = ['name', 'sku', 'description', 'short_description', 'external_category', 'status']
|
||||
labels = {
|
||||
'name': 'Название',
|
||||
'sku': 'Артикул',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Generated migration to rename primary_category to external_category
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('products', '0004_configurableproduct_primary_category_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Rename primary_category to external_category for Product
|
||||
migrations.RenameField(
|
||||
model_name='product',
|
||||
old_name='primary_category',
|
||||
new_name='external_category',
|
||||
),
|
||||
# Update related_name for Product
|
||||
migrations.AlterField(
|
||||
model_name='product',
|
||||
name='external_category',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)',
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='external_products',
|
||||
to='products.productcategory',
|
||||
verbose_name='Внешняя категория'
|
||||
),
|
||||
),
|
||||
# Rename primary_category to external_category for ProductKit
|
||||
migrations.RenameField(
|
||||
model_name='productkit',
|
||||
old_name='primary_category',
|
||||
new_name='external_category',
|
||||
),
|
||||
# Update related_name for ProductKit
|
||||
migrations.AlterField(
|
||||
model_name='productkit',
|
||||
name='external_category',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)',
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='external_productkits',
|
||||
to='products.productcategory',
|
||||
verbose_name='Внешняя категория'
|
||||
),
|
||||
),
|
||||
# Rename primary_category to external_category for ConfigurableProduct
|
||||
migrations.RenameField(
|
||||
model_name='configurableproduct',
|
||||
old_name='primary_category',
|
||||
new_name='external_category',
|
||||
),
|
||||
# Update related_name for ConfigurableProduct
|
||||
migrations.AlterField(
|
||||
model_name='configurableproduct',
|
||||
name='external_category',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)',
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='external_configurableproducts',
|
||||
to='products.productcategory',
|
||||
verbose_name='Внешняя категория'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -207,15 +207,15 @@ class BaseProductEntity(models.Model):
|
||||
verbose_name="Архивировано пользователем"
|
||||
)
|
||||
|
||||
# Основная категория для интеграций
|
||||
primary_category = models.ForeignKey(
|
||||
# Внешняя категория для интеграций
|
||||
external_category = models.ForeignKey(
|
||||
'ProductCategory',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='primary_%(class)ss',
|
||||
verbose_name="Основная категория",
|
||||
help_text="Используется для интеграций с внешними площадками"
|
||||
related_name='external_%(class)ss',
|
||||
verbose_name="Внешняя категория",
|
||||
help_text="Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)"
|
||||
)
|
||||
|
||||
# Manager
|
||||
@@ -281,7 +281,7 @@ class BaseProductEntity(models.Model):
|
||||
Получить категорию товара для конкретной интеграции.
|
||||
|
||||
Приоритет:
|
||||
1. primary_category (если указана и есть маппинг)
|
||||
1. external_category (если указана и есть маппинг)
|
||||
2. Первая категория из categories с существующим маппингом (если есть M2M)
|
||||
|
||||
Args:
|
||||
@@ -292,14 +292,14 @@ class BaseProductEntity(models.Model):
|
||||
"""
|
||||
from integrations.models import IntegrationCategoryMapping
|
||||
|
||||
# Приоритет 1: primary_category
|
||||
if self.primary_category:
|
||||
# Приоритет 1: external_category
|
||||
if self.external_category:
|
||||
mapping = IntegrationCategoryMapping.objects.filter(
|
||||
category=self.primary_category,
|
||||
category=self.external_category,
|
||||
integration_type=integration_type
|
||||
).first()
|
||||
if mapping:
|
||||
return self.primary_category, mapping.external_category_sku
|
||||
return self.external_category, mapping.external_category_sku
|
||||
|
||||
# Приоритет 2: первая категория из M2M (если есть)
|
||||
if hasattr(self, 'categories'):
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
const selectedItemsBreakdownSpan = document.getElementById('selectedItemsBreakdown');
|
||||
const modeHint = document.getElementById('bulkCategoryModeHint');
|
||||
const categoryListSection = document.getElementById('bulkCategoryListSection');
|
||||
const externalCategorySelect = document.getElementById('externalCategorySelect');
|
||||
const clearExternalCategory = document.getElementById('clearExternalCategory');
|
||||
|
||||
/**
|
||||
* Initialize the bulk category modal functionality
|
||||
@@ -58,6 +60,28 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for external category changes
|
||||
if (externalCategorySelect) {
|
||||
externalCategorySelect.addEventListener('change', () => {
|
||||
hideError();
|
||||
if (externalCategorySelect.value) {
|
||||
clearExternalCategory.checked = false;
|
||||
}
|
||||
updateApplyButtonState();
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for clear external category checkbox
|
||||
if (clearExternalCategory) {
|
||||
clearExternalCategory.addEventListener('change', () => {
|
||||
hideError();
|
||||
if (clearExternalCategory.checked) {
|
||||
externalCategorySelect.value = '';
|
||||
}
|
||||
updateApplyButtonState();
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for modal close to reset state
|
||||
modal.addEventListener('hidden.bs.modal', resetModalState);
|
||||
|
||||
@@ -91,6 +115,14 @@
|
||||
selectedCategoryIds.clear();
|
||||
hideError();
|
||||
|
||||
// Reset external category
|
||||
if (externalCategorySelect) {
|
||||
externalCategorySelect.value = '';
|
||||
}
|
||||
if (clearExternalCategory) {
|
||||
clearExternalCategory.checked = false;
|
||||
}
|
||||
|
||||
// Open modal and load categories
|
||||
modalInstance.show();
|
||||
loadCategories();
|
||||
@@ -330,13 +362,16 @@
|
||||
}
|
||||
|
||||
const mode = getCurrentMode();
|
||||
const hasExternalCategory = externalCategorySelect && externalCategorySelect.value;
|
||||
const shouldClearExternal = clearExternalCategory && clearExternalCategory.checked;
|
||||
|
||||
if (mode === 'clear') {
|
||||
// В режиме очистки категории не требуются
|
||||
// В режиме очистки M2M категорий не требуются
|
||||
// Но можно также очистить внешнюю категорию
|
||||
applyBtn.disabled = false;
|
||||
} else {
|
||||
// Для add/replace нужна хотя бы одна выбранная категория
|
||||
applyBtn.disabled = selectedCategoryIds.size === 0;
|
||||
// Для add/replace нужна хотя бы одна выбранная категория ИЛИ внешняя категория ИЛИ очистка
|
||||
applyBtn.disabled = selectedCategoryIds.size === 0 && !hasExternalCategory && !shouldClearExternal;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,17 +381,27 @@
|
||||
async function handleApply() {
|
||||
const mode = getCurrentMode();
|
||||
const selectedItems = getSelectedItems();
|
||||
const externalCategoryId = externalCategorySelect ? externalCategorySelect.value : null;
|
||||
const shouldClearExternal = clearExternalCategory && clearExternalCategory.checked;
|
||||
|
||||
if (selectedItems.length === 0) {
|
||||
showError('Нет выбранных товаров');
|
||||
return;
|
||||
}
|
||||
|
||||
// Режим очистки категорий
|
||||
// Проверка: если выбрана внешняя категория и стоит чекбокс очистки
|
||||
if (externalCategoryId && shouldClearExternal) {
|
||||
showError('Нельзя одновременно выбрать и очистить внешнюю категорию');
|
||||
return;
|
||||
}
|
||||
|
||||
// Режим очистки M2M категорий
|
||||
if (mode === 'clear') {
|
||||
const confirmed = confirm(
|
||||
`Вы уверены, что хотите удалить ВСЕ категории у ${selectedItems.length} выбранных товаров?\n\nЭто действие нельзя отменить!`
|
||||
);
|
||||
let confirmMsg = `Вы уверены, что хотите удалить ВСЕ категории у ${selectedItems.length} выбранных товаров?\n\nЭто действие нельзя отменить!`;
|
||||
if (shouldClearExternal) {
|
||||
confirmMsg += '\n\nТакже будет очищена внешняя категория для интеграций.';
|
||||
}
|
||||
const confirmed = confirm(confirmMsg);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
@@ -374,6 +419,11 @@
|
||||
action_mode: 'clear'
|
||||
};
|
||||
|
||||
// Добавляем external_category_id только если нужно очистить
|
||||
if (shouldClearExternal) {
|
||||
requestData.external_category_id = null;
|
||||
}
|
||||
|
||||
applyBtn.disabled = true;
|
||||
applyBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Применение...';
|
||||
|
||||
@@ -436,8 +486,8 @@
|
||||
}
|
||||
|
||||
// Режим add/replace
|
||||
if (selectedCategoryIds.size === 0) {
|
||||
showError('Выберите хотя бы одну категорию');
|
||||
if (selectedCategoryIds.size === 0 && !externalCategoryId && !shouldClearExternal) {
|
||||
showError('Выберите хотя бы одну категорию или внешнюю категорию');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -449,6 +499,13 @@
|
||||
action_mode: actionMode
|
||||
};
|
||||
|
||||
// Добавляем внешнюю категорию если выбрана
|
||||
if (externalCategoryId) {
|
||||
requestData.external_category_id = parseInt(externalCategoryId);
|
||||
} else if (shouldClearExternal) {
|
||||
requestData.clear_external_category = true;
|
||||
}
|
||||
|
||||
const csrfToken = getCsrfToken();
|
||||
if (!csrfToken) {
|
||||
showError('CSRF токен не найден. Обновите страницу и попробуйте снова.');
|
||||
@@ -610,6 +667,14 @@
|
||||
addModeRadio.checked = true;
|
||||
}
|
||||
|
||||
// Сбрасываем внешнюю категорию
|
||||
if (externalCategorySelect) {
|
||||
externalCategorySelect.value = '';
|
||||
}
|
||||
if (clearExternalCategory) {
|
||||
clearExternalCategory.checked = false;
|
||||
}
|
||||
|
||||
updateModeUI();
|
||||
updateApplyButtonState();
|
||||
}
|
||||
|
||||
@@ -109,18 +109,18 @@ input[name*="DELETE"] {
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Основная категория для интеграций -->
|
||||
<!-- Внешняя категория для интеграций -->
|
||||
<div class="mb-3">
|
||||
<label for="id_primary_category" class="form-label">
|
||||
Основная категория
|
||||
<label for="id_external_category" class="form-label">
|
||||
Внешняя категория
|
||||
<small class="text-muted">(для интеграций)</small>
|
||||
</label>
|
||||
{{ form.primary_category }}
|
||||
{% if form.primary_category.help_text %}
|
||||
<div class="form-text">{{ form.primary_category.help_text }}</div>
|
||||
{{ form.external_category }}
|
||||
{% if form.external_category.help_text %}
|
||||
<div class="form-text">{{ form.external_category.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.primary_category.errors %}
|
||||
<div class="text-danger small">{{ form.primary_category.errors.0 }}</div>
|
||||
{% if form.external_category.errors %}
|
||||
<div class="text-danger small">{{ form.external_category.errors.0 }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -467,18 +467,17 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Основная категория для интеграций -->
|
||||
<!-- Внешняя категория для интеграций -->
|
||||
<div class="mb-3">
|
||||
<label for="id_primary_category" class="form-label">
|
||||
Основная категория
|
||||
<small class="text-muted">(для интеграций)</small>
|
||||
<label for="id_external_category" class="form-label">
|
||||
Внешняя категория
|
||||
</label>
|
||||
{{ form.primary_category }}
|
||||
{% if form.primary_category.help_text %}
|
||||
<div class="form-text">{{ form.primary_category.help_text }}</div>
|
||||
{{ form.external_category }}
|
||||
{% if form.external_category.help_text %}
|
||||
<div class="form-text">{{ form.external_category.help_text }}</div>
|
||||
{% endif %}
|
||||
{% if form.primary_category.errors %}
|
||||
<div class="text-danger">{{ form.primary_category.errors }}</div>
|
||||
{% if form.external_category.errors %}
|
||||
<div class="text-danger">{{ form.external_category.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -183,14 +183,14 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Основная категория для интеграций -->
|
||||
<!-- Внешняя категория для интеграций -->
|
||||
<div class="mb-2">
|
||||
<label for="id_primary_category" class="form-label small mb-1 text-muted">
|
||||
Основная категория <small>(для интеграций)</small>
|
||||
<label for="id_external_category" class="form-label small mb-1 text-muted">
|
||||
Внешняя категория <small>(для интеграций)</small>
|
||||
</label>
|
||||
{{ form.primary_category }}
|
||||
{% if form.primary_category.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.primary_category.errors }}</div>
|
||||
{{ form.external_category }}
|
||||
{% if form.external_category.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.external_category.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -184,14 +184,14 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Основная категория для интеграций -->
|
||||
<!-- Внешняя категория для интеграций -->
|
||||
<div class="mb-2">
|
||||
<label for="id_primary_category" class="form-label small mb-1 text-muted">
|
||||
Основная категория <small>(для интеграций)</small>
|
||||
<label for="id_external_category" class="form-label small mb-1 text-muted">
|
||||
Внешняя категория <small>(для интеграций)</small>
|
||||
</label>
|
||||
{{ form.primary_category }}
|
||||
{% if form.primary_category.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.primary_category.errors }}</div>
|
||||
{{ form.external_category }}
|
||||
{% if form.external_category.errors %}
|
||||
<div class="text-danger small mt-1">{{ form.external_category.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -259,6 +259,7 @@
|
||||
<th style="width: 120px;">Артикул</th>
|
||||
<th style="width: 80px;">Тип</th>
|
||||
<th>Категория</th>
|
||||
<th style="width: 100px;">Внешняя кат.</th>
|
||||
<th style="width: 150px;">Теги</th>
|
||||
<th style="width: 130px;">Цена</th>
|
||||
<th style="width: 120px;">Остаток</th>
|
||||
@@ -314,6 +315,15 @@
|
||||
<span class="text-muted">-</span>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.external_category %}
|
||||
<span class="badge bg-warning text-dark" title="{{ item.external_category.name }}">
|
||||
<i class="bi bi-star-fill"></i> {{ item.external_category.name|truncatechars:15 }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td title="{% if item.tags.all|length %}{% for t in item.tags.all %}{{ t.name }}{% if not forloop.last %}, {% endif %}{% endfor %}{% else %}-{% endif %}">
|
||||
{% for tag in item.tags.all|slice:":2" %}
|
||||
<span class="badge bg-primary">{{ tag.name }}</span>
|
||||
@@ -528,6 +538,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Секция ВНЕШНЕЙ категории для интеграций -->
|
||||
<div class="mb-4 border-top pt-3">
|
||||
<label class="form-label fw-bold">
|
||||
<i class="bi bi-star-fill text-warning"></i>
|
||||
Внешняя категория (для интеграций)
|
||||
</label>
|
||||
<p class="small text-muted mb-2">
|
||||
Внешняя категория используется при экспорте на внешние площадки (Recommerce, WooCommerce и др.)
|
||||
</p>
|
||||
|
||||
<!-- Выбор внешней категории -->
|
||||
<div class="mb-2">
|
||||
<select class="form-select" id="externalCategorySelect">
|
||||
<option value="">— Не выбрана —</option>
|
||||
{% for category in filters.categories %}
|
||||
<option value="{{ category.id }}">{{ category.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Чекбокс для очистки -->
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="clearExternalCategory">
|
||||
<label class="form-check-label text-danger" for="clearExternalCategory">
|
||||
<strong>Очистить внешнюю категорию</strong>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сообщения об ошибках -->
|
||||
<div id="bulkCategoryError" class="alert alert-danger d-none" role="alert"></div>
|
||||
</div>
|
||||
|
||||
@@ -1581,7 +1581,8 @@ def bulk_update_categories(request):
|
||||
{"type": "kit", "id": 456}
|
||||
],
|
||||
"category_ids": [5, 12, 18],
|
||||
"action_mode": "add" // "add" или "replace"
|
||||
"action_mode": "add", // "add", "replace" или "clear"
|
||||
"external_category_id": 7 // опционально, для установки внешней категории
|
||||
}
|
||||
|
||||
Response JSON:
|
||||
@@ -1615,6 +1616,8 @@ def bulk_update_categories(request):
|
||||
items = data.get('items', [])
|
||||
category_ids = data.get('category_ids', [])
|
||||
action_mode = data.get('action_mode', 'add')
|
||||
external_category_id = data.get('external_category_id') # Может быть null для очистки
|
||||
should_clear_external = data.get('clear_external_category', False)
|
||||
|
||||
if not items:
|
||||
return JsonResponse({
|
||||
@@ -1622,8 +1625,8 @@ def bulk_update_categories(request):
|
||||
'message': 'Не выбраны товары для обновления'
|
||||
}, status=400)
|
||||
|
||||
# Для режима 'clear' категории не обязательны
|
||||
if action_mode != 'clear' and not category_ids:
|
||||
# Для режима 'clear' категории не обязательны, но нужна либо M2M категория, либо внешняя
|
||||
if action_mode != 'clear' and not category_ids and external_category_id is None and not should_clear_external:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'message': 'Не выбраны категории'
|
||||
@@ -1635,7 +1638,7 @@ def bulk_update_categories(request):
|
||||
'message': 'Неверный режим действия (должен быть add, replace или clear)'
|
||||
}, status=400)
|
||||
|
||||
# Проверка существования категорий (только если они указаны)
|
||||
# Проверка существования M2M категорий (только если они указаны)
|
||||
categories = []
|
||||
if category_ids:
|
||||
categories = ProductCategory.objects.filter(id__in=category_ids, is_active=True)
|
||||
@@ -1645,6 +1648,24 @@ def bulk_update_categories(request):
|
||||
'message': 'Некоторые категории не найдены или неактивны'
|
||||
}, status=400)
|
||||
|
||||
# Проверка существования внешней категории (если указана)
|
||||
external_category = None
|
||||
if external_category_id is not None:
|
||||
try:
|
||||
external_category = ProductCategory.objects.get(id=external_category_id, is_active=True)
|
||||
except ProductCategory.DoesNotExist:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'message': 'Главная категория не найдена или неактивна'
|
||||
}, status=400)
|
||||
|
||||
# Проверка: нельзя одновременно устанавливать и очищать внешнюю категорию
|
||||
if external_category and should_clear_external:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'message': 'Нельзя одновременно установить и очистить главную категорию'
|
||||
}, status=400)
|
||||
|
||||
# Обработка товаров в транзакции
|
||||
updated_count = 0
|
||||
skipped_count = 0
|
||||
@@ -1681,7 +1702,7 @@ def bulk_update_categories(request):
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Применяем изменения категорий
|
||||
# Применяем изменения M2M категорий
|
||||
if action_mode == 'add':
|
||||
# Добавляем категории к существующим
|
||||
obj.categories.add(*categories)
|
||||
@@ -1692,6 +1713,14 @@ def bulk_update_categories(request):
|
||||
# Очищаем все категории
|
||||
obj.categories.clear()
|
||||
|
||||
# Применяем изменения внешней категории
|
||||
if external_category:
|
||||
obj.external_category = external_category
|
||||
obj.save(update_fields=['external_category'])
|
||||
elif should_clear_external:
|
||||
obj.external_category = None
|
||||
obj.save(update_fields=['external_category'])
|
||||
|
||||
updated_count += 1
|
||||
|
||||
except (Product.DoesNotExist, ProductKit.DoesNotExist):
|
||||
@@ -1706,19 +1735,36 @@ def bulk_update_categories(request):
|
||||
|
||||
# Формируем сообщение результата
|
||||
if updated_count > 0:
|
||||
message_parts = []
|
||||
|
||||
# Сообщение про M2M категории
|
||||
if action_mode == 'clear':
|
||||
message = f'Категории удалены у {updated_count} товаров'
|
||||
if categories or action_mode == 'clear':
|
||||
message_parts.append(f'Категории удалены')
|
||||
else:
|
||||
category_names = ', '.join([c.name for c in categories[:3]])
|
||||
if len(categories) > 3:
|
||||
category_names += f' и ещё {len(categories) - 3}'
|
||||
if categories:
|
||||
category_names = ', '.join([c.name for c in categories[:3]])
|
||||
if len(categories) > 3:
|
||||
category_names += f' и ещё {len(categories) - 3}'
|
||||
|
||||
if action_mode == 'add':
|
||||
action_text = 'добавлены'
|
||||
else:
|
||||
action_text = 'установлены'
|
||||
if action_mode == 'add':
|
||||
action_text = 'добавлены'
|
||||
else:
|
||||
action_text = 'установлены'
|
||||
|
||||
message = f'Категории "{category_names}" {action_text} для {updated_count} товаров'
|
||||
message_parts.append(f'Категории "{category_names}" {action_text}')
|
||||
|
||||
# Сообщение про внешнюю категорию
|
||||
if external_category:
|
||||
message_parts.append(f'Внешняя категория "{external_category.name}" установлена')
|
||||
elif should_clear_external:
|
||||
message_parts.append('Внешняя категория очищена')
|
||||
|
||||
# Собираем финальное сообщение
|
||||
if message_parts:
|
||||
message = f'{", ".join(message_parts)} для {updated_count} товаров'
|
||||
else:
|
||||
message = f'Обновлено {updated_count} товаров'
|
||||
|
||||
if skipped_count > 0:
|
||||
message += f'. Пропущено: {skipped_count}'
|
||||
|
||||
Reference in New Issue
Block a user