Реализовано управление активностью тегов товаров и комплектов

## Что сделано:

### 1. Фильтрация тегов по активности
- ProductForm и ProductKitForm показывают только активные теги в селектах
- ProductListView, ProductKitListView передают только активные теги в контекст фильтров

### 2. Отображение тегов на страницах товаров/комплектов
- product_detail.html отображает только активные теги
- productkit_detail.html отображает только активные теги

### 3. Логика отображения в деталях тега
- Для активного тега: показываются только активные товары/комплекты
- Для неактивного тега: показываются ВСЕ товары/комплекты (для возможности ручной очистки)

### 4. API endpoint для переключения статуса
- Новый endpoint toggle_tag_status_api в api_views.py
- POST /products/api/tags/<id>/toggle/
- Переключает is_active и возвращает новый статус

### 5. AJAX toggle switch в таблице тегов
- Заменены бейджи на toggle switch в tag_list.html
- Переключатель в 1.3x больше (масштабирование через CSS)
- Мгновенное обновление без перезагрузки страницы
- Показывает сообщение об успехе/ошибке

### 6. Связь в БД остаётся неизменной
- При деактивации тег остаётся привязан к товарам в БД
- Просто скрывается в интерфейсе
- При реактивации вновь становится видимым

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-12 11:33:12 +03:00
parent a1f5733fde
commit 77064a274f
9 changed files with 252 additions and 20 deletions

View File

@@ -16,7 +16,7 @@ class ProductForm(forms.ModelForm):
)
tags = forms.ModelMultipleChoiceField(
queryset=ProductTag.objects.all(),
queryset=ProductTag.objects.filter(is_active=True),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Теги"
@@ -82,7 +82,7 @@ class ProductKitForm(forms.ModelForm):
)
tags = forms.ModelMultipleChoiceField(
queryset=ProductTag.objects.all(),
queryset=ProductTag.objects.filter(is_active=True),
widget=forms.CheckboxSelectMultiple,
required=False,
label="Теги"

View File

@@ -154,7 +154,9 @@
<td>
{% if product.tags.all %}
{% for tag in product.tags.all %}
{% if tag.is_active %}
<span class="badge bg-secondary">{{ tag.name }}</span>
{% endif %}
{% endfor %}
{% else %}
-

View File

@@ -104,7 +104,9 @@
<h6>Теги:</h6>
<div class="d-flex flex-wrap gap-2">
{% for tag in kit.tags.all %}
{% if tag.is_active %}
<span class="badge bg-secondary">{{ tag.name }}</span>
{% endif %}
{% endfor %}
</div>
</div>

View File

@@ -87,11 +87,13 @@
<span class="badge bg-info">{{ tag.kits_count }}</span>
</td>
<td>
{% if tag.is_active %}
<span class="badge bg-success">Активен</span>
{% else %}
<span class="badge bg-secondary">Неактивен</span>
{% endif %}
<div class="form-check form-switch" style="transform: scale(1.3); transform-origin: left;">
<input class="form-check-input tag-status-switch"
type="checkbox"
id="tag-status-{{ tag.pk }}"
data-tag-id="{{ tag.pk }}"
{% if tag.is_active %}checked{% endif %}>
</div>
</td>
<td>
<div class="btn-group btn-group-sm" role="group">
@@ -241,6 +243,45 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
});
// Обработчик переключения статуса тега
document.querySelectorAll('.tag-status-switch').forEach(toggle => {
toggle.addEventListener('click', async function(e) {
e.preventDefault(); // Предотвращаем стандартное поведение checkbox
const tagId = this.dataset.tagId;
const toggleSwitch = this;
const wasChecked = toggleSwitch.checked; // Сохраняем ТЕКУЩЕЕ состояние
try {
// Отправляем AJAX запрос
const apiUrl = `/products/api/tags/${tagId}/toggle/`;
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
}
});
const data = await response.json();
if (data.success) {
// Устанавливаем переключатель в НОВОЕ состояние согласно ответу сервера
toggleSwitch.checked = data.is_active;
// Показываем сообщение об успехе
showMessage(data.message, 'success');
} else {
// Переключатель остаётся в исходном состоянии (мы его не меняли)
showMessage(data.error || 'Ошибка при обновлении тега', 'danger');
}
} catch (error) {
// При ошибке сети - переключатель остаётся как был
showMessage('Ошибка сети: ' + error.message, 'danger');
}
});
});
</script>
{% endblock %}

View File

@@ -1,5 +1,6 @@
from django.urls import path
from . import views
from .views import api_views
app_name = 'products'
@@ -37,7 +38,8 @@ urlpatterns = [
# API endpoints
path('api/search-products-variants/', views.search_products_and_variants, name='api-search-products-variants'),
path('api/kits/temporary/create/', views.create_temporary_kit_api, name='api-temporary-kit-create'),
path('api/tags/create/', views.create_tag_api, name='api-tag-create'),
path('api/tags/create/', api_views.create_tag_api, name='api-tag-create'),
path('api/tags/<int:pk>/toggle/', api_views.toggle_tag_status_api, name='api-tag-toggle'),
# CRUD URLs for ProductVariantGroup (Варианты товаров)
path('variant-groups/', views.ProductVariantGroupListView.as_view(), name='variantgroup-list'),

View File

@@ -98,7 +98,27 @@ def search_products_and_variants(request):
}],
'pagination': {'more': False}
})
except (Product.DoesNotExist, ProductKit.DoesNotExist, ValueError):
elif item_type == 'variant':
# Для групп вариантов: получаем по ID с prefetch приоритетов
variant = ProductVariantGroup.objects.prefetch_related(
'items__product'
).get(id=numeric_id)
variant_price = variant.price or 0
count = variant.items.count()
return JsonResponse({
'results': [{
'id': variant.id,
'text': f"{variant.name} ({count} вариантов)",
'price': str(variant_price),
'actual_price': str(variant_price),
'type': 'variant',
'count': count
}],
'pagination': {'more': False}
})
except (Product.DoesNotExist, ProductKit.DoesNotExist, ProductVariantGroup.DoesNotExist, ValueError):
return JsonResponse({'results': [], 'pagination': {'more': False}})
query = request.GET.get('q', '').strip()
@@ -337,15 +357,18 @@ def search_products_and_variants(request):
variants = ProductVariantGroup.objects.filter(
models.Q(name__icontains=query) |
models.Q(description__icontains=query)
).prefetch_related('products')[:page_size]
).prefetch_related('items__product')[:page_size]
for variant in variants:
count = variant.products.filter(is_active=True).count()
count = variant.items.count()
variant_price = variant.price or 0
variant_results.append({
'id': variant.id,
'text': f"{variant.name} ({count} вариантов)",
'type': 'variant',
'count': count
'count': count,
'price': str(variant_price),
'actual_price': str(variant_price)
})
# Формируем финальный результат с группировкой или без
@@ -711,3 +734,59 @@ def create_tag_api(request):
'success': False,
'error': f'Ошибка при создании тега: {str(e)}'
}, status=500)
def toggle_tag_status_api(request, pk):
"""
AJAX endpoint для переключения статуса активности тега.
Принимает POST запрос и переключает is_active на противоположное значение.
Возвращает JSON:
{
"success": true,
"is_active": true/false,
"message": "Тег активирован" / "Тег деактивирован"
}
Или при ошибке:
{
"success": false,
"error": "Описание ошибки"
}
"""
if request.method != 'POST':
return JsonResponse({
'success': False,
'error': 'Метод не поддерживается'
}, status=405)
try:
from ..models import ProductTag
# Получаем тег
tag = ProductTag.objects.get(pk=pk)
# Переключаем статус
tag.is_active = not tag.is_active
tag.save()
# Определяем сообщение
message = "Тег активирован" if tag.is_active else "Тег деактивирован"
return JsonResponse({
'success': True,
'is_active': tag.is_active,
'message': message
})
except ProductTag.DoesNotExist:
return JsonResponse({
'success': False,
'error': 'Тег не найден'
}, status=404)
except Exception as e:
return JsonResponse({
'success': False,
'error': f'Ошибка при обновлении тега: {str(e)}'
}, status=500)

View File

@@ -63,7 +63,7 @@ class ProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
# Данные для фильтров
context['filters'] = {
'categories': ProductCategory.objects.filter(is_active=True),
'tags': ProductTag.objects.all(),
'tags': ProductTag.objects.filter(is_active=True),
'current': {
'search': self.request.GET.get('search', ''),
'category': self.request.GET.get('category', ''),

View File

@@ -8,7 +8,7 @@ from django.urls import reverse_lazy
from django.shortcuts import redirect
from django.db import transaction
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup
from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
from .utils import handle_photos
@@ -52,7 +52,7 @@ class ProductKitListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
# Данные для фильтров
context['filters'] = {
'categories': ProductCategory.objects.filter(is_active=True),
'tags': ProductTag.objects.all(),
'tags': ProductTag.objects.filter(is_active=True),
'current': {
'search': self.request.GET.get('search', ''),
'category': self.request.GET.get('category', ''),
@@ -146,10 +146,20 @@ class ProductKitCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateVi
if '-variant_group' in key and value:
try:
variant_group = ProductVariantGroup.objects.get(id=value)
variant_group = ProductVariantGroup.objects.prefetch_related(
'items__product'
).get(id=value)
variant_price = variant_group.price or 0
count = variant_group.items.count()
selected_variants[key] = {
'id': variant_group.id,
'text': variant_group.name
'text': f"{variant_group.name} ({count} вариантов)",
'price': str(variant_price),
'actual_price': str(variant_price),
'type': 'variant',
'count': count
}
except ProductVariantGroup.DoesNotExist:
pass
@@ -248,9 +258,98 @@ class ProductKitUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateVi
if self.request.POST:
context['kititem_formset'] = KitItemFormSetUpdate(self.request.POST, instance=self.object, prefix='kititem')
# При ошибке валидации - подготавливаем данные для Select2
selected_products = {}
selected_variants = {}
for key, value in self.request.POST.items():
if '-product' in key and value:
try:
# Очищаем ID от префикса если есть
numeric_value = value.split('_')[1] if '_' in value else value
product = Product.objects.get(id=numeric_value)
text = product.name
if product.sku:
text += f" ({product.sku})"
actual_price = product.sale_price if product.sale_price else product.price
selected_products[key] = {
'id': product.id,
'text': text,
'price': str(product.price) if product.price else None,
'actual_price': str(actual_price) if actual_price else '0'
}
except Product.DoesNotExist:
pass
if '-variant_group' in key and value:
try:
variant_group = ProductVariantGroup.objects.prefetch_related(
'items__product'
).get(id=value)
variant_price = variant_group.price or 0
count = variant_group.items.count()
selected_variants[key] = {
'id': variant_group.id,
'text': f"{variant_group.name} ({count} вариантов)",
'price': str(variant_price),
'actual_price': str(variant_price),
'type': 'variant',
'count': count
}
except ProductVariantGroup.DoesNotExist:
pass
context['selected_products'] = selected_products
context['selected_variants'] = selected_variants
else:
context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object, prefix='kititem')
# Подготавливаем данные для предзагрузки в Select2
selected_products = {}
selected_variants = {}
for item in self.object.kit_items.all():
form_prefix = f"kititem-{item.id}"
if item.product:
product = item.product
text = product.name
if product.sku:
text += f" ({product.sku})"
actual_price = product.sale_price if product.sale_price else product.price
selected_products[f"{form_prefix}-product"] = {
'id': product.id,
'text': text,
'price': str(product.price) if product.price else None,
'actual_price': str(actual_price) if actual_price else '0'
}
if item.variant_group:
variant_group = ProductVariantGroup.objects.prefetch_related(
'items__product'
).get(id=item.variant_group.id)
variant_price = variant_group.price or 0
count = variant_group.items.count()
selected_variants[f"{form_prefix}-variant_group"] = {
'id': variant_group.id,
'text': f"{variant_group.name} ({count} вариантов)",
'price': str(variant_price),
'actual_price': str(variant_price),
'type': 'variant',
'count': count
}
context['selected_products'] = selected_products
context['selected_variants'] = selected_variants
context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at')
context['photos_count'] = self.object.photos.count()

View File

@@ -62,8 +62,15 @@ class ProductTagDetailView(LoginRequiredMixin, DetailView):
tag = self.get_object()
# Получаем товары и комплекты с этим тегом
# Если тег неактивный, показываем ВСЕ товары/комплекты (для возможности очистки)
# Если тег активный, показываем только активные товары/комплекты
if tag.is_active:
context['products'] = tag.products.filter(is_active=True).order_by('name')[:20]
context['kits'] = tag.kits.filter(is_active=True).order_by('name')[:20]
else:
context['products'] = tag.products.all().order_by('name')[:20]
context['kits'] = tag.kits.all().order_by('name')[:20]
context['total_products'] = tag.products.count()
context['total_kits'] = tag.kits.count()