diff --git a/myproject/products/admin.py b/myproject/products/admin.py
index 6025f88..d2a5bb0 100644
--- a/myproject/products/admin.py
+++ b/myproject/products/admin.py
@@ -360,29 +360,10 @@ class ProductCategoryAdmin(admin.ModelAdmin):
class ProductTagAdmin(admin.ModelAdmin):
- list_display = ('name', 'slug', 'get_deleted_status')
- list_filter = (DeletedFilter,)
+ list_display = ('name', 'slug', 'is_active')
+ list_filter = ('is_active',)
prepopulated_fields = {'slug': ('name',)}
search_fields = ('name',)
- readonly_fields = ('deleted_at', 'deleted_by')
- actions = [restore_items, delete_selected, hard_delete_selected]
-
- def get_queryset(self, request):
- """Переопределяем queryset для доступа ко всем тегам (включая удаленные)"""
- qs = ProductTag.all_objects.all()
- ordering = self.get_ordering(request)
- if ordering:
- qs = qs.order_by(*ordering)
- return qs
-
- def get_deleted_status(self, obj):
- """Показывает статус удаления"""
- if obj.is_deleted:
- return format_html(
- '🗑️ Удален'
- )
- return format_html('✓ Активен')
- get_deleted_status.short_description = 'Статус'
class ProductAdmin(admin.ModelAdmin):
diff --git a/myproject/products/forms.py b/myproject/products/forms.py
index 3f965c3..a24dada 100644
--- a/myproject/products/forms.py
+++ b/myproject/products/forms.py
@@ -451,3 +451,40 @@ ProductVariantGroupItemFormSetUpdate = inlineformset_factory(
validate_min=False, # Не требовать минимум товаров
can_delete_extra=True, # Разрешить удалять дополнительные формы
)
+
+
+class ProductTagForm(forms.ModelForm):
+ """
+ Форма для создания и редактирования тегов товаров.
+ """
+ class Meta:
+ model = ProductTag
+ fields = ['name', 'slug', 'is_active']
+ labels = {
+ 'name': 'Название',
+ 'slug': 'URL-идентификатор',
+ 'is_active': 'Активен'
+ }
+ help_texts = {
+ 'slug': 'Оставьте пустым для автоматической генерации из названия',
+ }
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.fields['name'].widget.attrs.update({
+ 'class': 'form-control form-control-lg fw-semibold',
+ 'placeholder': 'Введите название тега'
+ })
+ self.fields['slug'].widget.attrs.update({
+ 'class': 'form-control',
+ 'placeholder': 'url-identifier (автоматически)'
+ })
+ self.fields['slug'].required = False
+ self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
+
+ def clean_slug(self):
+ """Разрешаем пустой slug - он сгенерируется в модели"""
+ slug = self.cleaned_data.get('slug')
+ if slug == '' or slug is None:
+ return None
+ return slug
diff --git a/myproject/products/migrations/0002_remove_producttag_products_pr_is_dele_ea9be0_idx_and_more.py b/myproject/products/migrations/0002_remove_producttag_products_pr_is_dele_ea9be0_idx_and_more.py
new file mode 100644
index 0000000..0ff89ff
--- /dev/null
+++ b/myproject/products/migrations/0002_remove_producttag_products_pr_is_dele_ea9be0_idx_and_more.py
@@ -0,0 +1,42 @@
+# Generated by Django 5.0.10 on 2025-11-11 18:31
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('products', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.RemoveIndex(
+ model_name='producttag',
+ name='products_pr_is_dele_ea9be0_idx',
+ ),
+ migrations.RemoveIndex(
+ model_name='producttag',
+ name='products_pr_is_dele_bc2d9c_idx',
+ ),
+ migrations.RemoveField(
+ model_name='producttag',
+ name='deleted_at',
+ ),
+ migrations.RemoveField(
+ model_name='producttag',
+ name='deleted_by',
+ ),
+ migrations.RemoveField(
+ model_name='producttag',
+ name='is_deleted',
+ ),
+ migrations.AddField(
+ model_name='producttag',
+ name='is_active',
+ field=models.BooleanField(db_index=True, default=True, verbose_name='Активен'),
+ ),
+ migrations.AddIndex(
+ model_name='producttag',
+ index=models.Index(fields=['is_active'], name='products_pr_is_acti_7f288f_idx'),
+ ),
+ ]
diff --git a/myproject/products/models/categories.py b/myproject/products/models/categories.py
index f809f57..88ee683 100644
--- a/myproject/products/models/categories.py
+++ b/myproject/products/models/categories.py
@@ -129,30 +129,18 @@ class ProductTag(models.Model):
"""
name = models.CharField(max_length=100, unique=True, verbose_name="Название")
slug = models.SlugField(max_length=100, unique=True, verbose_name="URL-идентификатор")
+ is_active = models.BooleanField(default=True, verbose_name="Активен", db_index=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True)
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления", null=True)
- # Поля для мягкого удаления
- is_deleted = models.BooleanField(default=False, verbose_name="Удален", db_index=True)
- deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Время удаления")
- deleted_by = models.ForeignKey(
- User,
- on_delete=models.SET_NULL,
- null=True,
- blank=True,
- related_name='deleted_tags',
- verbose_name="Удален пользователем"
- )
-
- objects = SoftDeleteManager.from_queryset(SoftDeleteQuerySet)() # Менеджер по умолчанию (исключает удаленные)
- all_objects = models.Manager() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные)
+ objects = models.Manager()
+ active = ActiveManager()
class Meta:
verbose_name = "Тег товара"
verbose_name_plural = "Теги товаров"
indexes = [
- models.Index(fields=['is_deleted']),
- models.Index(fields=['is_deleted', 'created_at']),
+ models.Index(fields=['is_active']),
]
def __str__(self):
@@ -162,14 +150,3 @@ class ProductTag(models.Model):
if not self.slug:
self.slug = SlugService.generate_unique_slug(self.name, ProductTag, self.pk)
super().save(*args, **kwargs)
-
- def delete(self, *args, **kwargs):
- """Soft delete вместо hard delete - марк как удаленный"""
- self.is_deleted = True
- self.deleted_at = timezone.now()
- self.save(update_fields=['is_deleted', 'deleted_at'])
- return 1, {self.__class__._meta.label: 1}
-
- def hard_delete(self):
- """Полное удаление из БД (необратимо!)"""
- super().delete()
diff --git a/myproject/products/templates/products/tag_confirm_delete.html b/myproject/products/templates/products/tag_confirm_delete.html
new file mode 100644
index 0000000..abb1f0c
--- /dev/null
+++ b/myproject/products/templates/products/tag_confirm_delete.html
@@ -0,0 +1,81 @@
+{% extends 'base.html' %}
+
+{% block title %}Удалить тег: {{ tag.name }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+ Вы уверены, что хотите удалить тег "{{ tag.name }}"?
+
+
+ {% if products_count > 0 or kits_count > 0 %}
+
+
+ Внимание!
+
+
Этот тег используется в:
+
+ {% if products_count > 0 %}
+ - {{ products_count }} товарах
+ {% endif %}
+ {% if kits_count > 0 %}
+ - {{ kits_count }} комплектах
+ {% endif %}
+
+
+
+ При удалении тега он будет удален из всех связанных товаров и комплектов.
+
+
+ {% else %}
+
+ Этот тег не используется в товарах или комплектах.
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
Информация о теге
+
+ - Название:
+ - {{ tag.name }}
+
+ - Slug:
+ {{ tag.slug }}
+
+ - Создан:
+ - {{ tag.created_at|date:"d.m.Y H:i" }}
+
+ - Обновлен:
+ - {{ tag.updated_at|date:"d.m.Y H:i" }}
+
+
+
+
+
+
+{% endblock %}
diff --git a/myproject/products/templates/products/tag_detail.html b/myproject/products/templates/products/tag_detail.html
new file mode 100644
index 0000000..fe62539
--- /dev/null
+++ b/myproject/products/templates/products/tag_detail.html
@@ -0,0 +1,175 @@
+{% extends 'base.html' %}
+
+{% block title %}Тег: {{ tag.name }}{% endblock %}
+
+{% block content %}
+
+
+
+
+ {{ tag.name }}
+ {% if not tag.is_active %}
+ Неактивен
+ {% endif %}
+
+
+
+
+
+
+
+
+
+
+
+ - Название:
+ - {{ tag.name }}
+
+ - Slug:
+ {{ tag.slug }}
+
+ - Статус:
+ -
+ {% if tag.is_active %}
+ Активен
+ {% else %}
+ Неактивен
+ {% endif %}
+
+
+ - Создан:
+ - {{ tag.created_at|date:"d.m.Y H:i" }}
+
+ - Обновлен:
+ - {{ tag.updated_at|date:"d.m.Y H:i" }}
+
+
+
+
+
+
+
+
+
+ - Товаров:
+ -
+ {{ total_products }}
+
+
+ - Комплектов:
+ -
+ {{ total_kits }}
+
+
+
+
+
+
+
+
+
+
+
+ {% if products %}
+
+
+
+
+ | Название |
+ Артикул |
+ Цена |
+ Действия |
+
+
+
+ {% for product in products %}
+
+ | {{ product.name }} |
+ {{ product.sku }} |
+ {{ product.actual_price|floatformat:0 }} руб. |
+
+
+ Просмотр
+
+ |
+
+ {% endfor %}
+
+
+
+ {% if total_products > 20 %}
+
+ Показано первых 20 из {{ total_products }} товаров.
+
+ {% endif %}
+ {% else %}
+
Нет товаров с этим тегом
+ {% endif %}
+
+
+
+
+
+
+
+ {% if kits %}
+
+
+
+
+ | Название |
+ Артикул |
+ Цена |
+ Действия |
+
+
+
+ {% for kit in kits %}
+
+ | {{ kit.name }} |
+ {{ kit.sku }} |
+ {{ kit.get_sale_price|floatformat:0 }} руб. |
+
+
+ Просмотр
+
+ |
+
+ {% endfor %}
+
+
+
+ {% if total_kits > 20 %}
+
+ Показано первых 20 из {{ total_kits }} комплектов.
+
+ {% endif %}
+ {% else %}
+
Нет комплектов с этим тегом
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/myproject/products/templates/products/tag_form.html b/myproject/products/templates/products/tag_form.html
new file mode 100644
index 0000000..dae88f2
--- /dev/null
+++ b/myproject/products/templates/products/tag_form.html
@@ -0,0 +1,99 @@
+{% extends 'base.html' %}
+
+{% block title %}{% if object %}Редактировать тег{% else %}Создать тег{% endif %}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+ {% if object %}
+
+
+
Дополнительная информация
+
+ - Создан: {{ object.created_at|date:"d.m.Y H:i" }}
+ - Обновлен: {{ object.updated_at|date:"d.m.Y H:i" }}
+
+
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/myproject/products/templates/products/tag_list.html b/myproject/products/templates/products/tag_list.html
new file mode 100644
index 0000000..c69f259
--- /dev/null
+++ b/myproject/products/templates/products/tag_list.html
@@ -0,0 +1,147 @@
+{% extends 'base.html' %}
+
+{% block title %}Теги товаров{% endblock %}
+
+{% block content %}
+
+
+
Теги товаров
+
+ Создать тег
+
+
+
+
+
+
+
+
+
+ {% if tags %}
+
+
+
+
+ | Название |
+ Slug |
+ Товары |
+ Комплекты |
+ Статус |
+ Действия |
+
+
+
+ {% for tag in tags %}
+
+ |
+
+ {{ tag.name }}
+
+ |
+ {{ tag.slug }} |
+
+ {{ tag.products_count }}
+ |
+
+ {{ tag.kits_count }}
+ |
+
+ {% if tag.is_active %}
+ Активен
+ {% else %}
+ Неактивен
+ {% endif %}
+ |
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+
+ {% if is_paginated %}
+
+ {% endif %}
+ {% else %}
+
+ {% endif %}
+
+
+
+{% endblock %}
diff --git a/myproject/products/urls.py b/myproject/products/urls.py
index 3feba6c..cace000 100644
--- a/myproject/products/urls.py
+++ b/myproject/products/urls.py
@@ -60,4 +60,11 @@ urlpatterns = [
path('categories/photo//set-main/', views.category_photo_set_main, name='category-photo-set-main'),
path('categories/photo//move-up/', views.category_photo_move_up, name='category-photo-move-up'),
path('categories/photo//move-down/', views.category_photo_move_down, name='category-photo-move-down'),
+
+ # CRUD URLs for ProductTag
+ path('tags/', views.ProductTagListView.as_view(), name='tag-list'),
+ path('tags/create/', views.ProductTagCreateView.as_view(), name='tag-create'),
+ path('tags//', views.ProductTagDetailView.as_view(), name='tag-detail'),
+ path('tags//update/', views.ProductTagUpdateView.as_view(), name='tag-update'),
+ path('tags//delete/', views.ProductTagDeleteView.as_view(), name='tag-delete'),
]
\ No newline at end of file
diff --git a/myproject/products/views/__init__.py b/myproject/products/views/__init__.py
index 537d006..5499667 100644
--- a/myproject/products/views/__init__.py
+++ b/myproject/products/views/__init__.py
@@ -70,6 +70,15 @@ from .variant_group_views import (
product_variant_group_item_move,
)
+# CRUD представления для ProductTag
+from .tag_views import (
+ ProductTagListView,
+ ProductTagCreateView,
+ ProductTagDetailView,
+ ProductTagUpdateView,
+ ProductTagDeleteView,
+)
+
# API представления
from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api
@@ -129,6 +138,13 @@ __all__ = [
'ProductVariantGroupDeleteView',
'product_variant_group_item_move',
+ # ProductTag CRUD
+ 'ProductTagListView',
+ 'ProductTagCreateView',
+ 'ProductTagDetailView',
+ 'ProductTagUpdateView',
+ 'ProductTagDeleteView',
+
# API
'search_products_and_variants',
'validate_kit_cost',
diff --git a/myproject/products/views/tag_views.py b/myproject/products/views/tag_views.py
new file mode 100644
index 0000000..0df5401
--- /dev/null
+++ b/myproject/products/views/tag_views.py
@@ -0,0 +1,120 @@
+"""
+CRUD представления для тегов товаров (ProductTag).
+"""
+from django.contrib import messages
+from django.contrib.auth.mixins import LoginRequiredMixin
+from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
+from django.urls import reverse_lazy
+from django.db.models import Q, Count
+
+from ..models import ProductTag
+from ..forms import ProductTagForm
+
+
+class ProductTagListView(LoginRequiredMixin, ListView):
+ """Список всех тегов с поиском и фильтрацией"""
+ model = ProductTag
+ template_name = 'products/tag_list.html'
+ context_object_name = 'tags'
+ paginate_by = 20
+
+ def get_queryset(self):
+ queryset = super().get_queryset()
+
+ # Аннотируем количество товаров и комплектов для каждого тега
+ queryset = queryset.annotate(
+ products_count=Count('products', distinct=True),
+ kits_count=Count('kits', distinct=True)
+ )
+
+ # Поиск по названию и slug
+ search_query = self.request.GET.get('search')
+ if search_query:
+ queryset = queryset.filter(
+ Q(name__icontains=search_query) |
+ Q(slug__icontains=search_query)
+ )
+
+ # Фильтр по статусу активности
+ is_active = self.request.GET.get('is_active')
+ if is_active == '1':
+ queryset = queryset.filter(is_active=True)
+ elif is_active == '0':
+ queryset = queryset.filter(is_active=False)
+
+ return queryset.order_by('name')
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ context['search_query'] = self.request.GET.get('search', '')
+ context['is_active_filter'] = self.request.GET.get('is_active', '')
+ return context
+
+
+class ProductTagDetailView(LoginRequiredMixin, DetailView):
+ """Детальная информация о теге с привязанными товарами и комплектами"""
+ model = ProductTag
+ template_name = 'products/tag_detail.html'
+ context_object_name = 'tag'
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ 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]
+ context['total_products'] = tag.products.count()
+ context['total_kits'] = tag.kits.count()
+
+ return context
+
+
+class ProductTagCreateView(LoginRequiredMixin, CreateView):
+ """Создание нового тега"""
+ model = ProductTag
+ form_class = ProductTagForm
+ template_name = 'products/tag_form.html'
+ success_url = reverse_lazy('products:tag-list')
+
+ def form_valid(self, form):
+ response = super().form_valid(form)
+ messages.success(self.request, f'Тег "{self.object.name}" успешно создан.')
+ return response
+
+
+class ProductTagUpdateView(LoginRequiredMixin, UpdateView):
+ """Редактирование существующего тега"""
+ model = ProductTag
+ form_class = ProductTagForm
+ template_name = 'products/tag_form.html'
+ success_url = reverse_lazy('products:tag-list')
+
+ def form_valid(self, form):
+ response = super().form_valid(form)
+ messages.success(self.request, f'Тег "{self.object.name}" успешно обновлен.')
+ return response
+
+
+class ProductTagDeleteView(LoginRequiredMixin, DeleteView):
+ """Удаление тега с подтверждением"""
+ model = ProductTag
+ template_name = 'products/tag_confirm_delete.html'
+ success_url = reverse_lazy('products:tag-list')
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ tag = self.get_object()
+
+ # Передаем информацию о связанных объектах для предупреждения
+ context['products_count'] = tag.products.count()
+ context['kits_count'] = tag.kits.count()
+
+ return context
+
+ def delete(self, request, *args, **kwargs):
+ tag = self.get_object()
+ tag_name = tag.name
+ response = super().delete(request, *args, **kwargs)
+ messages.success(request, f'Тег "{tag_name}" успешно удален.')
+ return response
diff --git a/myproject/templates/navbar.html b/myproject/templates/navbar.html
index a5dc2eb..f9dcc17 100644
--- a/myproject/templates/navbar.html
+++ b/myproject/templates/navbar.html
@@ -18,6 +18,9 @@
Варианты
+
+ Теги
+
Заказы