From 1a0360f8c02bea122327d7bac3449373e9cbce9d Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Tue, 11 Nov 2025 23:14:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B=D0=B9=20CRU?= =?UTF-8?q?D=20=D0=B4=D0=BB=D1=8F=20=D1=82=D0=B5=D0=B3=D0=BE=D0=B2=20?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Упрощена модель ProductTag: - Удалены поля soft delete (is_deleted, deleted_at, deleted_by) - Добавлено поле is_active для управления статусом - Упрощены менеджеры и методы модели Создан CRUD функционал: - ProductTagForm: форма с автогенерацией slug - Views: список, создание, просмотр, редактирование, удаление - URL маршруты: /products/tags/* - Шаблоны: list, form, detail, confirm_delete Особенности: - Поиск по названию и slug - Фильтрация по статусу активности - Статистика использования тегов в товарах/комплектах - Пагинация (20 на страницу) - Предупреждение при удалении с отображением связанных объектов - Добавлена ссылка "Теги" в навигацию 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- myproject/products/admin.py | 23 +-- myproject/products/forms.py | 37 ++++ ...products_pr_is_dele_ea9be0_idx_and_more.py | 42 +++++ myproject/products/models/categories.py | 31 +--- .../products/tag_confirm_delete.html | 81 ++++++++ .../templates/products/tag_detail.html | 175 ++++++++++++++++++ .../products/templates/products/tag_form.html | 99 ++++++++++ .../products/templates/products/tag_list.html | 147 +++++++++++++++ myproject/products/urls.py | 7 + myproject/products/views/__init__.py | 16 ++ myproject/products/views/tag_views.py | 120 ++++++++++++ myproject/templates/navbar.html | 3 + 12 files changed, 733 insertions(+), 48 deletions(-) create mode 100644 myproject/products/migrations/0002_remove_producttag_products_pr_is_dele_ea9be0_idx_and_more.py create mode 100644 myproject/products/templates/products/tag_confirm_delete.html create mode 100644 myproject/products/templates/products/tag_detail.html create mode 100644 myproject/products/templates/products/tag_form.html create mode 100644 myproject/products/templates/products/tag_list.html create mode 100644 myproject/products/views/tag_views.py 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 %} + + {% else %} + + {% endif %} + +
+ {% csrf_token %} +
+ + Отмена + + +
+
+
+
+ + +
+
+
Информация о теге
+
+
Название:
+
{{ 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 }} +
+
+
+
+
+ + +
+
+
+
Товары с тегом "{{ tag.name }}"
+
+
+ {% if products %} +
+ + + + + + + + + + + {% for product in products %} + + + + + + + {% endfor %} + +
НазваниеАртикулЦенаДействия
{{ product.name }}{{ product.sku }}{{ product.actual_price|floatformat:0 }} руб. + + Просмотр + +
+
+ {% if total_products > 20 %} +
+ Показано первых 20 из {{ total_products }} товаров. +
+ {% endif %} + {% else %} +

Нет товаров с этим тегом

+ {% endif %} +
+
+ + +
+
+
Комплекты с тегом "{{ tag.name }}"
+
+
+ {% if kits %} +
+ + + + + + + + + + + {% for kit in kits %} + + + + + + + {% endfor %} + +
НазваниеАртикулЦенаДействия
{{ kit.name }}{{ kit.sku }}{{ kit.get_sale_price|floatformat:0 }} руб. + + Просмотр + +
+
+ {% 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 %} + Редактировать тег + {% else %} + Создать новый тег + {% endif %} +

+
+
+
+ {% csrf_token %} + + +
+ + {{ form.name }} + {% if form.name.errors %} +
+ {{ form.name.errors }} +
+ {% endif %} + {% if form.name.help_text %} +
{{ form.name.help_text }}
+ {% endif %} +
+ + +
+ + {{ form.slug }} + {% if form.slug.errors %} +
+ {{ form.slug.errors }} +
+ {% endif %} + {% if form.slug.help_text %} +
{{ form.slug.help_text }}
+ {% endif %} +
+ + +
+
+ {{ form.is_active }} + +
+ {% if form.is_active.errors %} +
+ {{ form.is_active.errors }} +
+ {% endif %} +
+ + +
+ + + Отмена + +
+
+
+
+ + + {% 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 %} +
+ + + + + + + + + + + + + {% for tag in tags %} + + + + + + + + + {% endfor %} + +
НазваниеSlugТоварыКомплектыСтатусДействия
+ + {{ tag.name }} + + {{ tag.slug }} + {{ tag.products_count }} + + {{ tag.kits_count }} + + {% if tag.is_active %} + Активен + {% else %} + Неактивен + {% endif %} + + +
+
+ + + {% 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 @@ +