refactor: rename primary_category to external_category

This commit is contained in:
2026-01-14 02:59:11 +03:00
parent 1fb280607a
commit e7672588c6
12 changed files with 306 additions and 85 deletions

View File

@@ -48,7 +48,7 @@ def to_api_product(
data['name'] = product.name data['name'] = product.name
if 'parent_category_sku' in fields: if 'parent_category_sku' in fields:
# Получаем категорию через primary_category или fallback на M2M categories # Получаем категорию через external_category или fallback на M2M categories
_, category_sku = product.get_category_for_integration('recommerce') _, category_sku = product.get_category_for_integration('recommerce')
if category_sku: if category_sku:
data['parent_category_sku'] = category_sku data['parent_category_sku'] = category_sku

View File

@@ -396,7 +396,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
search_fields = ('name', 'sku', 'description', 'search_keywords') search_fields = ('name', 'sku', 'description', 'search_keywords')
filter_horizontal = ('categories', 'tags', 'variant_groups') filter_horizontal = ('categories', 'tags', 'variant_groups')
readonly_fields = ('photo_preview_large', 'archived_at', 'archived_by', 'cost_price', 'cost_price_details_display') 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 = [ actions = [
restore_items, restore_items,
delete_selected, delete_selected,
@@ -408,7 +408,7 @@ class ProductAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
fieldsets = ( 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',), 'fields': ('cost_price_details_display',),
@@ -589,7 +589,7 @@ class ProductKitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}
filter_horizontal = ('categories', 'tags') filter_horizontal = ('categories', 'tags')
readonly_fields = ('photo_preview_large', 'base_price', 'archived_at', 'archived_by', 'order') readonly_fields = ('photo_preview_large', 'base_price', 'archived_at', 'archived_by', 'order')
autocomplete_fields = ['primary_category'] autocomplete_fields = ['external_category']
actions = [ actions = [
show_poor_quality_photos, show_poor_quality_photos,
show_excellent_quality_photos, show_excellent_quality_photos,
@@ -598,7 +598,7 @@ class ProductKitAdmin(TenantAdminOnlyMixin, admin.ModelAdmin):
fieldsets = ( 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'), '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') search_fields = ('name', 'sku', 'description')
readonly_fields = ('created_at', 'updated_at', 'slug') readonly_fields = ('created_at', 'updated_at', 'slug')
inlines = [ConfigurableProductOptionInline, ConfigurableProductAttributeInline] inlines = [ConfigurableProductOptionInline, ConfigurableProductAttributeInline]
autocomplete_fields = ['primary_category'] autocomplete_fields = ['external_category']
fieldsets = ( fieldsets = (
('Основная информация', { ('Основная информация', {
'fields': ('name', 'sku', 'slug', 'status', 'primary_category') 'fields': ('name', 'sku', 'slug', 'status', 'external_category')
}), }),
('Описание', { ('Описание', {
'fields': ('short_description', 'description') 'fields': ('short_description', 'description')

View File

@@ -87,12 +87,12 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm):
label="Категории" label="Категории"
) )
primary_category = forms.ModelChoiceField( external_category = forms.ModelChoiceField(
queryset=ProductCategory.objects.filter(is_active=True), queryset=ProductCategory.objects.filter(is_active=True),
required=False, required=False,
empty_label="Не выбрана", empty_label="Не выбрана",
label="Основная категория", label="Внешняя категория",
help_text="Используется для интеграций с внешними площадками", help_text="Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)",
widget=forms.Select(attrs={'class': 'form-select'}) widget=forms.Select(attrs={'class': 'form-select'})
) )
@@ -107,7 +107,7 @@ class ProductForm(SKUUniqueMixin, forms.ModelForm):
model = Product model = Product
fields = [ fields = [
'name', 'sku', 'description', 'short_description', 'categories', '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' 'is_new', 'is_popular', 'is_special'
] ]
labels = { labels = {
@@ -202,12 +202,12 @@ class ProductKitForm(SKUUniqueMixin, forms.ModelForm):
label="Категории" label="Категории"
) )
primary_category = forms.ModelChoiceField( external_category = forms.ModelChoiceField(
queryset=ProductCategory.objects.filter(is_active=True), queryset=ProductCategory.objects.filter(is_active=True),
required=False, required=False,
empty_label="Не выбрана", empty_label="Не выбрана",
label="Основная категория", label="Внешняя категория",
help_text="Используется для интеграций с внешними площадками", help_text="Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)",
widget=forms.Select(attrs={'class': 'form-select'}) widget=forms.Select(attrs={'class': 'form-select'})
) )
@@ -222,7 +222,7 @@ class ProductKitForm(SKUUniqueMixin, forms.ModelForm):
model = ProductKit model = ProductKit
fields = [ fields = [
'name', 'sku', 'description', 'short_description', 'categories', '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 = { labels = {
'name': 'Название', '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), queryset=ProductCategory.objects.filter(is_active=True),
required=False, required=False,
empty_label="Не выбрана", empty_label="Не выбрана",
label="Основная категория", label="Внешняя категория",
help_text="Используется для интеграций с внешними площадками", help_text="Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)",
widget=forms.Select(attrs={'class': 'form-select'}) widget=forms.Select(attrs={'class': 'form-select'})
) )
class Meta: class Meta:
model = ConfigurableProduct model = ConfigurableProduct
fields = ['name', 'sku', 'description', 'short_description', 'primary_category', 'status'] fields = ['name', 'sku', 'description', 'short_description', 'external_category', 'status']
labels = { labels = {
'name': 'Название', 'name': 'Название',
'sku': 'Артикул', 'sku': 'Артикул',

View File

@@ -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='Внешняя категория'
),
),
]

View File

@@ -207,15 +207,15 @@ class BaseProductEntity(models.Model):
verbose_name="Архивировано пользователем" verbose_name="Архивировано пользователем"
) )
# Основная категория для интеграций # Внешняя категория для интеграций
primary_category = models.ForeignKey( external_category = models.ForeignKey(
'ProductCategory', 'ProductCategory',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
null=True, null=True,
blank=True, blank=True,
related_name='primary_%(class)ss', related_name='external_%(class)ss',
verbose_name="Основная категория", verbose_name="Внешняя категория",
help_text="Используется для интеграций с внешними площадками" help_text="Категория для интеграций с внешними площадками (Recommerce, WooCommerce и др.)"
) )
# Manager # Manager
@@ -281,7 +281,7 @@ class BaseProductEntity(models.Model):
Получить категорию товара для конкретной интеграции. Получить категорию товара для конкретной интеграции.
Приоритет: Приоритет:
1. primary_category (если указана и есть маппинг) 1. external_category (если указана и есть маппинг)
2. Первая категория из categories с существующим маппингом (если есть M2M) 2. Первая категория из categories с существующим маппингом (если есть M2M)
Args: Args:
@@ -292,14 +292,14 @@ class BaseProductEntity(models.Model):
""" """
from integrations.models import IntegrationCategoryMapping from integrations.models import IntegrationCategoryMapping
# Приоритет 1: primary_category # Приоритет 1: external_category
if self.primary_category: if self.external_category:
mapping = IntegrationCategoryMapping.objects.filter( mapping = IntegrationCategoryMapping.objects.filter(
category=self.primary_category, category=self.external_category,
integration_type=integration_type integration_type=integration_type
).first() ).first()
if mapping: if mapping:
return self.primary_category, mapping.external_category_sku return self.external_category, mapping.external_category_sku
# Приоритет 2: первая категория из M2M (если есть) # Приоритет 2: первая категория из M2M (если есть)
if hasattr(self, 'categories'): if hasattr(self, 'categories'):

View File

@@ -22,6 +22,8 @@
const selectedItemsBreakdownSpan = document.getElementById('selectedItemsBreakdown'); const selectedItemsBreakdownSpan = document.getElementById('selectedItemsBreakdown');
const modeHint = document.getElementById('bulkCategoryModeHint'); const modeHint = document.getElementById('bulkCategoryModeHint');
const categoryListSection = document.getElementById('bulkCategoryListSection'); const categoryListSection = document.getElementById('bulkCategoryListSection');
const externalCategorySelect = document.getElementById('externalCategorySelect');
const clearExternalCategory = document.getElementById('clearExternalCategory');
/** /**
* Initialize the bulk category modal functionality * 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 // Listen for modal close to reset state
modal.addEventListener('hidden.bs.modal', resetModalState); modal.addEventListener('hidden.bs.modal', resetModalState);
@@ -90,6 +114,14 @@
// Reset state // Reset state
selectedCategoryIds.clear(); selectedCategoryIds.clear();
hideError(); hideError();
// Reset external category
if (externalCategorySelect) {
externalCategorySelect.value = '';
}
if (clearExternalCategory) {
clearExternalCategory.checked = false;
}
// Open modal and load categories // Open modal and load categories
modalInstance.show(); modalInstance.show();
@@ -330,13 +362,16 @@
} }
const mode = getCurrentMode(); const mode = getCurrentMode();
const hasExternalCategory = externalCategorySelect && externalCategorySelect.value;
const shouldClearExternal = clearExternalCategory && clearExternalCategory.checked;
if (mode === 'clear') { if (mode === 'clear') {
// В режиме очистки категории не требуются // В режиме очистки M2M категорий не требуются
// Но можно также очистить внешнюю категорию
applyBtn.disabled = false; applyBtn.disabled = false;
} else { } else {
// Для add/replace нужна хотя бы одна выбранная категория // Для add/replace нужна хотя бы одна выбранная категория ИЛИ внешняя категория ИЛИ очистка
applyBtn.disabled = selectedCategoryIds.size === 0; applyBtn.disabled = selectedCategoryIds.size === 0 && !hasExternalCategory && !shouldClearExternal;
} }
} }
@@ -346,17 +381,27 @@
async function handleApply() { async function handleApply() {
const mode = getCurrentMode(); const mode = getCurrentMode();
const selectedItems = getSelectedItems(); const selectedItems = getSelectedItems();
const externalCategoryId = externalCategorySelect ? externalCategorySelect.value : null;
const shouldClearExternal = clearExternalCategory && clearExternalCategory.checked;
if (selectedItems.length === 0) { if (selectedItems.length === 0) {
showError('Нет выбранных товаров'); showError('Нет выбранных товаров');
return; return;
} }
// Режим очистки категорий // Проверка: если выбрана внешняя категория и стоит чекбокс очистки
if (externalCategoryId && shouldClearExternal) {
showError('Нельзя одновременно выбрать и очистить внешнюю категорию');
return;
}
// Режим очистки M2M категорий
if (mode === 'clear') { if (mode === 'clear') {
const confirmed = confirm( let confirmMsg = `Вы уверены, что хотите удалить ВСЕ категории у ${selectedItems.length} выбранных товаров?\n\nЭто действие нельзя отменить!`;
`Вы уверены, что хотите удалить ВСЕ категории у ${selectedItems.length} выбранных товаров?\n\nЭто действие нельзя отменить!` if (shouldClearExternal) {
); confirmMsg += '\n\nТакже будет очищена внешняя категория для интеграций.';
}
const confirmed = confirm(confirmMsg);
if (!confirmed) { if (!confirmed) {
return; return;
} }
@@ -374,6 +419,11 @@
action_mode: 'clear' action_mode: 'clear'
}; };
// Добавляем external_category_id только если нужно очистить
if (shouldClearExternal) {
requestData.external_category_id = null;
}
applyBtn.disabled = true; applyBtn.disabled = true;
applyBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Применение...'; applyBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Применение...';
@@ -436,18 +486,25 @@
} }
// Режим add/replace // Режим add/replace
if (selectedCategoryIds.size === 0) { if (selectedCategoryIds.size === 0 && !externalCategoryId && !shouldClearExternal) {
showError('Выберите хотя бы одну категорию'); showError('Выберите хотя бы одну категорию или внешнюю категорию');
return; return;
} }
const actionMode = mode === 'replace' ? 'replace' : 'add'; const actionMode = mode === 'replace' ? 'replace' : 'add';
const requestData = { const requestData = {
items: selectedItems, items: selectedItems,
category_ids: Array.from(selectedCategoryIds), category_ids: Array.from(selectedCategoryIds),
action_mode: actionMode action_mode: actionMode
}; };
// Добавляем внешнюю категорию если выбрана
if (externalCategoryId) {
requestData.external_category_id = parseInt(externalCategoryId);
} else if (shouldClearExternal) {
requestData.clear_external_category = true;
}
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
if (!csrfToken) { if (!csrfToken) {
@@ -610,6 +667,14 @@
addModeRadio.checked = true; addModeRadio.checked = true;
} }
// Сбрасываем внешнюю категорию
if (externalCategorySelect) {
externalCategorySelect.value = '';
}
if (clearExternalCategory) {
clearExternalCategory.checked = false;
}
updateModeUI(); updateModeUI();
updateApplyButtonState(); updateApplyButtonState();
} }

View File

@@ -109,18 +109,18 @@ input[name*="DELETE"] {
{% endif %} {% endif %}
</div> </div>
<!-- Основная категория для интеграций --> <!-- Внешняя категория для интеграций -->
<div class="mb-3"> <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> <small class="text-muted">(для интеграций)</small>
</label> </label>
{{ form.primary_category }} {{ form.external_category }}
{% if form.primary_category.help_text %} {% if form.external_category.help_text %}
<div class="form-text">{{ form.primary_category.help_text }}</div> <div class="form-text">{{ form.external_category.help_text }}</div>
{% endif %} {% endif %}
{% if form.primary_category.errors %} {% if form.external_category.errors %}
<div class="text-danger small">{{ form.primary_category.errors.0 }}</div> <div class="text-danger small">{{ form.external_category.errors.0 }}</div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -467,18 +467,17 @@
{% endif %} {% endif %}
</div> </div>
<!-- Основная категория для интеграций --> <!-- Внешняя категория для интеграций -->
<div class="mb-3"> <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> </label>
{{ form.primary_category }} {{ form.external_category }}
{% if form.primary_category.help_text %} {% if form.external_category.help_text %}
<div class="form-text">{{ form.primary_category.help_text }}</div> <div class="form-text">{{ form.external_category.help_text }}</div>
{% endif %} {% endif %}
{% if form.primary_category.errors %} {% if form.external_category.errors %}
<div class="text-danger">{{ form.primary_category.errors }}</div> <div class="text-danger">{{ form.external_category.errors }}</div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -183,14 +183,14 @@
{% endif %} {% endif %}
</div> </div>
<!-- Основная категория для интеграций --> <!-- Внешняя категория для интеграций -->
<div class="mb-2"> <div class="mb-2">
<label for="id_primary_category" class="form-label small mb-1 text-muted"> <label for="id_external_category" class="form-label small mb-1 text-muted">
Основная категория <small>(для интеграций)</small> Внешняя категория <small>(для интеграций)</small>
</label> </label>
{{ form.primary_category }} {{ form.external_category }}
{% if form.primary_category.errors %} {% if form.external_category.errors %}
<div class="text-danger small mt-1">{{ form.primary_category.errors }}</div> <div class="text-danger small mt-1">{{ form.external_category.errors }}</div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -184,14 +184,14 @@
{% endif %} {% endif %}
</div> </div>
<!-- Основная категория для интеграций --> <!-- Внешняя категория для интеграций -->
<div class="mb-2"> <div class="mb-2">
<label for="id_primary_category" class="form-label small mb-1 text-muted"> <label for="id_external_category" class="form-label small mb-1 text-muted">
Основная категория <small>(для интеграций)</small> Внешняя категория <small>(для интеграций)</small>
</label> </label>
{{ form.primary_category }} {{ form.external_category }}
{% if form.primary_category.errors %} {% if form.external_category.errors %}
<div class="text-danger small mt-1">{{ form.primary_category.errors }}</div> <div class="text-danger small mt-1">{{ form.external_category.errors }}</div>
{% endif %} {% endif %}
</div> </div>

View File

@@ -259,6 +259,7 @@
<th style="width: 120px;">Артикул</th> <th style="width: 120px;">Артикул</th>
<th style="width: 80px;">Тип</th> <th style="width: 80px;">Тип</th>
<th>Категория</th> <th>Категория</th>
<th style="width: 100px;">Внешняя кат.</th>
<th style="width: 150px;">Теги</th> <th style="width: 150px;">Теги</th>
<th style="width: 130px;">Цена</th> <th style="width: 130px;">Цена</th>
<th style="width: 120px;">Остаток</th> <th style="width: 120px;">Остаток</th>
@@ -314,6 +315,15 @@
<span class="text-muted">-</span> <span class="text-muted">-</span>
{% endfor %} {% endfor %}
</td> </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 %}"> <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" %} {% for tag in item.tags.all|slice:":2" %}
<span class="badge bg-primary">{{ tag.name }}</span> <span class="badge bg-primary">{{ tag.name }}</span>
@@ -528,6 +538,35 @@
</div> </div>
</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 id="bulkCategoryError" class="alert alert-danger d-none" role="alert"></div>
</div> </div>

View File

@@ -1581,7 +1581,8 @@ def bulk_update_categories(request):
{"type": "kit", "id": 456} {"type": "kit", "id": 456}
], ],
"category_ids": [5, 12, 18], "category_ids": [5, 12, 18],
"action_mode": "add" // "add" или "replace" "action_mode": "add", // "add", "replace" или "clear"
"external_category_id": 7 // опционально, для установки внешней категории
} }
Response JSON: Response JSON:
@@ -1615,6 +1616,8 @@ def bulk_update_categories(request):
items = data.get('items', []) items = data.get('items', [])
category_ids = data.get('category_ids', []) category_ids = data.get('category_ids', [])
action_mode = data.get('action_mode', 'add') 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: if not items:
return JsonResponse({ return JsonResponse({
@@ -1622,8 +1625,8 @@ def bulk_update_categories(request):
'message': 'Не выбраны товары для обновления' 'message': 'Не выбраны товары для обновления'
}, status=400) }, status=400)
# Для режима 'clear' категории не обязательны # Для режима 'clear' категории не обязательны, но нужна либо M2M категория, либо внешняя
if action_mode != 'clear' and not category_ids: if action_mode != 'clear' and not category_ids and external_category_id is None and not should_clear_external:
return JsonResponse({ return JsonResponse({
'success': False, 'success': False,
'message': 'Не выбраны категории' 'message': 'Не выбраны категории'
@@ -1635,7 +1638,7 @@ def bulk_update_categories(request):
'message': 'Неверный режим действия (должен быть add, replace или clear)' 'message': 'Неверный режим действия (должен быть add, replace или clear)'
}, status=400) }, status=400)
# Проверка существования категорий (только если они указаны) # Проверка существования M2M категорий (только если они указаны)
categories = [] categories = []
if category_ids: if category_ids:
categories = ProductCategory.objects.filter(id__in=category_ids, is_active=True) categories = ProductCategory.objects.filter(id__in=category_ids, is_active=True)
@@ -1645,6 +1648,24 @@ def bulk_update_categories(request):
'message': 'Некоторые категории не найдены или неактивны' 'message': 'Некоторые категории не найдены или неактивны'
}, status=400) }, 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 updated_count = 0
skipped_count = 0 skipped_count = 0
@@ -1681,7 +1702,7 @@ def bulk_update_categories(request):
skipped_count += 1 skipped_count += 1
continue continue
# Применяем изменения категорий # Применяем изменения M2M категорий
if action_mode == 'add': if action_mode == 'add':
# Добавляем категории к существующим # Добавляем категории к существующим
obj.categories.add(*categories) obj.categories.add(*categories)
@@ -1692,6 +1713,14 @@ def bulk_update_categories(request):
# Очищаем все категории # Очищаем все категории
obj.categories.clear() 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 updated_count += 1
except (Product.DoesNotExist, ProductKit.DoesNotExist): except (Product.DoesNotExist, ProductKit.DoesNotExist):
@@ -1706,19 +1735,36 @@ def bulk_update_categories(request):
# Формируем сообщение результата # Формируем сообщение результата
if updated_count > 0: if updated_count > 0:
message_parts = []
# Сообщение про M2M категории
if action_mode == 'clear': if action_mode == 'clear':
message = f'Категории удалены у {updated_count} товаров' if categories or action_mode == 'clear':
message_parts.append(f'Категории удалены')
else: else:
category_names = ', '.join([c.name for c in categories[:3]]) if categories:
if len(categories) > 3: category_names = ', '.join([c.name for c in categories[:3]])
category_names += f' и ещё {len(categories) - 3}' if len(categories) > 3:
category_names += f' и ещё {len(categories) - 3}'
if action_mode == 'add':
action_text = 'добавлены' if action_mode == 'add':
else: action_text = 'добавлены'
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: if skipped_count > 0:
message += f'. Пропущено: {skipped_count}' message += f'. Пропущено: {skipped_count}'