From e7672588c665cdfef773d4246abf251b8a4e84db Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Wed, 14 Jan 2026 02:59:11 +0300 Subject: [PATCH] refactor: rename primary_category to external_category --- myproject/integrations/recommerce/mappers.py | 2 +- myproject/products/admin.py | 12 +-- myproject/products/forms.py | 24 ++--- ...e_primary_category_to_external_category.py | 72 +++++++++++++++ myproject/products/models/base.py | 20 ++--- .../static/products/js/bulk-category-modal.js | 87 ++++++++++++++++--- .../products/configurableproduct_form.html | 16 ++-- .../templates/products/product_form.html | 17 ++-- .../templates/products/productkit_create.html | 12 +-- .../templates/products/productkit_edit.html | 12 +-- .../templates/products/products_list.html | 39 +++++++++ myproject/products/views/api_views.py | 78 +++++++++++++---- 12 files changed, 306 insertions(+), 85 deletions(-) create mode 100644 myproject/products/migrations/0005_rename_primary_category_to_external_category.py diff --git a/myproject/integrations/recommerce/mappers.py b/myproject/integrations/recommerce/mappers.py index 28a36d8..bcb2368 100644 --- a/myproject/integrations/recommerce/mappers.py +++ b/myproject/integrations/recommerce/mappers.py @@ -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 diff --git a/myproject/products/admin.py b/myproject/products/admin.py index db1da29..485839c 100644 --- a/myproject/products/admin.py +++ b/myproject/products/admin.py @@ -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') diff --git a/myproject/products/forms.py b/myproject/products/forms.py index 1113665..2953dc3 100644 --- a/myproject/products/forms.py +++ b/myproject/products/forms.py @@ -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': 'Артикул', diff --git a/myproject/products/migrations/0005_rename_primary_category_to_external_category.py b/myproject/products/migrations/0005_rename_primary_category_to_external_category.py new file mode 100644 index 0000000..ad4a8a2 --- /dev/null +++ b/myproject/products/migrations/0005_rename_primary_category_to_external_category.py @@ -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='Внешняя категория' + ), + ), + ] diff --git a/myproject/products/models/base.py b/myproject/products/models/base.py index c332cef..32a784d 100644 --- a/myproject/products/models/base.py +++ b/myproject/products/models/base.py @@ -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'): diff --git a/myproject/products/static/products/js/bulk-category-modal.js b/myproject/products/static/products/js/bulk-category-modal.js index bb2e7e3..e1b0bc6 100644 --- a/myproject/products/static/products/js/bulk-category-modal.js +++ b/myproject/products/static/products/js/bulk-category-modal.js @@ -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); @@ -90,6 +114,14 @@ // Reset state selectedCategoryIds.clear(); hideError(); + + // Reset external category + if (externalCategorySelect) { + externalCategorySelect.value = ''; + } + if (clearExternalCategory) { + clearExternalCategory.checked = false; + } // Open modal and load categories modalInstance.show(); @@ -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 = 'Применение...'; @@ -436,18 +486,25 @@ } // Режим add/replace - if (selectedCategoryIds.size === 0) { - showError('Выберите хотя бы одну категорию'); + if (selectedCategoryIds.size === 0 && !externalCategoryId && !shouldClearExternal) { + showError('Выберите хотя бы одну категорию или внешнюю категорию'); return; } const actionMode = mode === 'replace' ? 'replace' : 'add'; - + const requestData = { items: selectedItems, category_ids: Array.from(selectedCategoryIds), action_mode: actionMode }; + + // Добавляем внешнюю категорию если выбрана + if (externalCategoryId) { + requestData.external_category_id = parseInt(externalCategoryId); + } else if (shouldClearExternal) { + requestData.clear_external_category = true; + } const csrfToken = getCsrfToken(); if (!csrfToken) { @@ -610,6 +667,14 @@ addModeRadio.checked = true; } + // Сбрасываем внешнюю категорию + if (externalCategorySelect) { + externalCategorySelect.value = ''; + } + if (clearExternalCategory) { + clearExternalCategory.checked = false; + } + updateModeUI(); updateApplyButtonState(); } diff --git a/myproject/products/templates/products/configurableproduct_form.html b/myproject/products/templates/products/configurableproduct_form.html index 487300a..03f3564 100644 --- a/myproject/products/templates/products/configurableproduct_form.html +++ b/myproject/products/templates/products/configurableproduct_form.html @@ -109,18 +109,18 @@ input[name*="DELETE"] { {% endif %} - +
-
diff --git a/myproject/products/templates/products/product_form.html b/myproject/products/templates/products/product_form.html index d842bda..d00ffa9 100644 --- a/myproject/products/templates/products/product_form.html +++ b/myproject/products/templates/products/product_form.html @@ -467,18 +467,17 @@ {% endif %} - +
-
diff --git a/myproject/products/templates/products/productkit_create.html b/myproject/products/templates/products/productkit_create.html index da84930..96445b3 100644 --- a/myproject/products/templates/products/productkit_create.html +++ b/myproject/products/templates/products/productkit_create.html @@ -183,14 +183,14 @@ {% endif %} - +
-
diff --git a/myproject/products/templates/products/productkit_edit.html b/myproject/products/templates/products/productkit_edit.html index f73a194..a917b43 100644 --- a/myproject/products/templates/products/productkit_edit.html +++ b/myproject/products/templates/products/productkit_edit.html @@ -184,14 +184,14 @@ {% endif %} - +
-
diff --git a/myproject/products/templates/products/products_list.html b/myproject/products/templates/products/products_list.html index 0161b6a..23703f4 100644 --- a/myproject/products/templates/products/products_list.html +++ b/myproject/products/templates/products/products_list.html @@ -259,6 +259,7 @@ Артикул Тип Категория + Внешняя кат. Теги Цена Остаток @@ -314,6 +315,15 @@ - {% endfor %} + + {% if item.external_category %} + + {{ item.external_category.name|truncatechars:15 }} + + {% else %} + + {% endif %} + {% for tag in item.tags.all|slice:":2" %} {{ tag.name }} @@ -528,6 +538,35 @@ + +
+ +

+ Внешняя категория используется при экспорте на внешние площадки (Recommerce, WooCommerce и др.) +

+ + +
+ +
+ + +
+ + +
+
+ diff --git a/myproject/products/views/api_views.py b/myproject/products/views/api_views.py index 099d6eb..ded7d62 100644 --- a/myproject/products/views/api_views.py +++ b/myproject/products/views/api_views.py @@ -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 action_mode == 'add': - action_text = 'добавлены' - else: - action_text = 'установлены' - - message = f'Категории "{category_names}" {action_text} для {updated_count} товаров' + 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 = 'установлены' + + 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}'