Реализовано управление активностью тегов товаров и комплектов
## Что сделано: ### 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(
|
tags = forms.ModelMultipleChoiceField(
|
||||||
queryset=ProductTag.objects.all(),
|
queryset=ProductTag.objects.filter(is_active=True),
|
||||||
widget=forms.CheckboxSelectMultiple,
|
widget=forms.CheckboxSelectMultiple,
|
||||||
required=False,
|
required=False,
|
||||||
label="Теги"
|
label="Теги"
|
||||||
@@ -82,7 +82,7 @@ class ProductKitForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
tags = forms.ModelMultipleChoiceField(
|
tags = forms.ModelMultipleChoiceField(
|
||||||
queryset=ProductTag.objects.all(),
|
queryset=ProductTag.objects.filter(is_active=True),
|
||||||
widget=forms.CheckboxSelectMultiple,
|
widget=forms.CheckboxSelectMultiple,
|
||||||
required=False,
|
required=False,
|
||||||
label="Теги"
|
label="Теги"
|
||||||
|
|||||||
@@ -154,7 +154,9 @@
|
|||||||
<td>
|
<td>
|
||||||
{% if product.tags.all %}
|
{% if product.tags.all %}
|
||||||
{% for tag in product.tags.all %}
|
{% for tag in product.tags.all %}
|
||||||
|
{% if tag.is_active %}
|
||||||
<span class="badge bg-secondary">{{ tag.name }}</span>
|
<span class="badge bg-secondary">{{ tag.name }}</span>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
-
|
-
|
||||||
|
|||||||
@@ -104,7 +104,9 @@
|
|||||||
<h6>Теги:</h6>
|
<h6>Теги:</h6>
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
{% for tag in kit.tags.all %}
|
{% for tag in kit.tags.all %}
|
||||||
|
{% if tag.is_active %}
|
||||||
<span class="badge bg-secondary">{{ tag.name }}</span>
|
<span class="badge bg-secondary">{{ tag.name }}</span>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -87,11 +87,13 @@
|
|||||||
<span class="badge bg-info">{{ tag.kits_count }}</span>
|
<span class="badge bg-info">{{ tag.kits_count }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if tag.is_active %}
|
<div class="form-check form-switch" style="transform: scale(1.3); transform-origin: left;">
|
||||||
<span class="badge bg-success">Активен</span>
|
<input class="form-check-input tag-status-switch"
|
||||||
{% else %}
|
type="checkbox"
|
||||||
<span class="badge bg-secondary">Неактивен</span>
|
id="tag-status-{{ tag.pk }}"
|
||||||
{% endif %}
|
data-tag-id="{{ tag.pk }}"
|
||||||
|
{% if tag.is_active %}checked{% endif %}>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<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>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import views
|
from . import views
|
||||||
|
from .views import api_views
|
||||||
|
|
||||||
app_name = 'products'
|
app_name = 'products'
|
||||||
|
|
||||||
@@ -37,7 +38,8 @@ urlpatterns = [
|
|||||||
# API endpoints
|
# API endpoints
|
||||||
path('api/search-products-variants/', views.search_products_and_variants, name='api-search-products-variants'),
|
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/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 (Варианты товаров)
|
# CRUD URLs for ProductVariantGroup (Варианты товаров)
|
||||||
path('variant-groups/', views.ProductVariantGroupListView.as_view(), name='variantgroup-list'),
|
path('variant-groups/', views.ProductVariantGroupListView.as_view(), name='variantgroup-list'),
|
||||||
|
|||||||
@@ -98,7 +98,27 @@ def search_products_and_variants(request):
|
|||||||
}],
|
}],
|
||||||
'pagination': {'more': False}
|
'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}})
|
return JsonResponse({'results': [], 'pagination': {'more': False}})
|
||||||
|
|
||||||
query = request.GET.get('q', '').strip()
|
query = request.GET.get('q', '').strip()
|
||||||
@@ -337,15 +357,18 @@ def search_products_and_variants(request):
|
|||||||
variants = ProductVariantGroup.objects.filter(
|
variants = ProductVariantGroup.objects.filter(
|
||||||
models.Q(name__icontains=query) |
|
models.Q(name__icontains=query) |
|
||||||
models.Q(description__icontains=query)
|
models.Q(description__icontains=query)
|
||||||
).prefetch_related('products')[:page_size]
|
).prefetch_related('items__product')[:page_size]
|
||||||
|
|
||||||
for variant in variants:
|
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({
|
variant_results.append({
|
||||||
'id': variant.id,
|
'id': variant.id,
|
||||||
'text': f"{variant.name} ({count} вариантов)",
|
'text': f"{variant.name} ({count} вариантов)",
|
||||||
'type': 'variant',
|
'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,
|
'success': False,
|
||||||
'error': f'Ошибка при создании тега: {str(e)}'
|
'error': f'Ошибка при создании тега: {str(e)}'
|
||||||
}, status=500)
|
}, 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'] = {
|
context['filters'] = {
|
||||||
'categories': ProductCategory.objects.filter(is_active=True),
|
'categories': ProductCategory.objects.filter(is_active=True),
|
||||||
'tags': ProductTag.objects.all(),
|
'tags': ProductTag.objects.filter(is_active=True),
|
||||||
'current': {
|
'current': {
|
||||||
'search': self.request.GET.get('search', ''),
|
'search': self.request.GET.get('search', ''),
|
||||||
'category': self.request.GET.get('category', ''),
|
'category': self.request.GET.get('category', ''),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from django.urls import reverse_lazy
|
|||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.db import transaction
|
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 ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
|
||||||
from .utils import handle_photos
|
from .utils import handle_photos
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ class ProductKitListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
|||||||
# Данные для фильтров
|
# Данные для фильтров
|
||||||
context['filters'] = {
|
context['filters'] = {
|
||||||
'categories': ProductCategory.objects.filter(is_active=True),
|
'categories': ProductCategory.objects.filter(is_active=True),
|
||||||
'tags': ProductTag.objects.all(),
|
'tags': ProductTag.objects.filter(is_active=True),
|
||||||
'current': {
|
'current': {
|
||||||
'search': self.request.GET.get('search', ''),
|
'search': self.request.GET.get('search', ''),
|
||||||
'category': self.request.GET.get('category', ''),
|
'category': self.request.GET.get('category', ''),
|
||||||
@@ -146,10 +146,20 @@ class ProductKitCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateVi
|
|||||||
|
|
||||||
if '-variant_group' in key and value:
|
if '-variant_group' in key and value:
|
||||||
try:
|
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] = {
|
selected_variants[key] = {
|
||||||
'id': variant_group.id,
|
'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:
|
except ProductVariantGroup.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
@@ -248,9 +258,98 @@ class ProductKitUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateVi
|
|||||||
|
|
||||||
if self.request.POST:
|
if self.request.POST:
|
||||||
context['kititem_formset'] = KitItemFormSetUpdate(self.request.POST, instance=self.object, prefix='kititem')
|
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:
|
else:
|
||||||
context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object, prefix='kititem')
|
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['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at')
|
||||||
context['photos_count'] = self.object.photos.count()
|
context['photos_count'] = self.object.photos.count()
|
||||||
|
|
||||||
|
|||||||
@@ -62,8 +62,15 @@ class ProductTagDetailView(LoginRequiredMixin, DetailView):
|
|||||||
tag = self.get_object()
|
tag = self.get_object()
|
||||||
|
|
||||||
# Получаем товары и комплекты с этим тегом
|
# Получаем товары и комплекты с этим тегом
|
||||||
|
# Если тег неактивный, показываем ВСЕ товары/комплекты (для возможности очистки)
|
||||||
|
# Если тег активный, показываем только активные товары/комплекты
|
||||||
|
if tag.is_active:
|
||||||
context['products'] = tag.products.filter(is_active=True).order_by('name')[:20]
|
context['products'] = tag.products.filter(is_active=True).order_by('name')[:20]
|
||||||
context['kits'] = tag.kits.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_products'] = tag.products.count()
|
||||||
context['total_kits'] = tag.kits.count()
|
context['total_kits'] = tag.kits.count()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user