Реализовано управление активностью тегов товаров и комплектов
## Что сделано: ### 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:
@@ -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="Теги"
|
||||
|
||||
@@ -154,7 +154,9 @@
|
||||
<td>
|
||||
{% if product.tags.all %}
|
||||
{% for tag in product.tags.all %}
|
||||
<span class="badge bg-secondary">{{ tag.name }}</span>
|
||||
{% if tag.is_active %}
|
||||
<span class="badge bg-secondary">{{ tag.name }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
-
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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', ''),
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -62,8 +62,15 @@ class ProductTagDetailView(LoginRequiredMixin, DetailView):
|
||||
tag = self.get_object()
|
||||
|
||||
# Получаем товары и комплекты с этим тегом
|
||||
context['products'] = tag.products.filter(is_active=True).order_by('name')[:20]
|
||||
context['kits'] = tag.kits.filter(is_active=True).order_by('name')[:20]
|
||||
# Если тег неактивный, показываем ВСЕ товары/комплекты (для возможности очистки)
|
||||
# Если тег активный, показываем только активные товары/комплекты
|
||||
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user