Реализован полный CRUD для тегов товаров
Упрощена модель 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 <noreply@anthropic.com>
This commit is contained in:
@@ -360,29 +360,10 @@ class ProductCategoryAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
class ProductTagAdmin(admin.ModelAdmin):
|
class ProductTagAdmin(admin.ModelAdmin):
|
||||||
list_display = ('name', 'slug', 'get_deleted_status')
|
list_display = ('name', 'slug', 'is_active')
|
||||||
list_filter = (DeletedFilter,)
|
list_filter = ('is_active',)
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
search_fields = ('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(
|
|
||||||
'<span style="color: red; font-weight: bold;">🗑️ Удален</span>'
|
|
||||||
)
|
|
||||||
return format_html('<span style="color: green;">✓ Активен</span>')
|
|
||||||
get_deleted_status.short_description = 'Статус'
|
|
||||||
|
|
||||||
|
|
||||||
class ProductAdmin(admin.ModelAdmin):
|
class ProductAdmin(admin.ModelAdmin):
|
||||||
|
|||||||
@@ -451,3 +451,40 @@ ProductVariantGroupItemFormSetUpdate = inlineformset_factory(
|
|||||||
validate_min=False, # Не требовать минимум товаров
|
validate_min=False, # Не требовать минимум товаров
|
||||||
can_delete_extra=True, # Разрешить удалять дополнительные формы
|
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
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -129,30 +129,18 @@ class ProductTag(models.Model):
|
|||||||
"""
|
"""
|
||||||
name = models.CharField(max_length=100, unique=True, verbose_name="Название")
|
name = models.CharField(max_length=100, unique=True, verbose_name="Название")
|
||||||
slug = models.SlugField(max_length=100, unique=True, verbose_name="URL-идентификатор")
|
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)
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания", null=True)
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления", null=True)
|
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления", null=True)
|
||||||
|
|
||||||
# Поля для мягкого удаления
|
objects = models.Manager()
|
||||||
is_deleted = models.BooleanField(default=False, verbose_name="Удален", db_index=True)
|
active = ActiveManager()
|
||||||
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() # Менеджер для доступа ко ВСЕМ объектам (включая удаленные)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "Тег товара"
|
verbose_name = "Тег товара"
|
||||||
verbose_name_plural = "Теги товаров"
|
verbose_name_plural = "Теги товаров"
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=['is_deleted']),
|
models.Index(fields=['is_active']),
|
||||||
models.Index(fields=['is_deleted', 'created_at']),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
@@ -162,14 +150,3 @@ class ProductTag(models.Model):
|
|||||||
if not self.slug:
|
if not self.slug:
|
||||||
self.slug = SlugService.generate_unique_slug(self.name, ProductTag, self.pk)
|
self.slug = SlugService.generate_unique_slug(self.name, ProductTag, self.pk)
|
||||||
super().save(*args, **kwargs)
|
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()
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Удалить тег: {{ tag.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card border-danger">
|
||||||
|
<div class="card-header bg-danger text-white">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle"></i> Подтверждение удаления
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="lead">
|
||||||
|
Вы уверены, что хотите удалить тег <strong>"{{ tag.name }}"</strong>?
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if products_count > 0 or kits_count > 0 %}
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<h6 class="alert-heading">
|
||||||
|
<i class="bi bi-exclamation-circle"></i> Внимание!
|
||||||
|
</h6>
|
||||||
|
<p class="mb-2">Этот тег используется в:</p>
|
||||||
|
<ul class="mb-0">
|
||||||
|
{% if products_count > 0 %}
|
||||||
|
<li><strong>{{ products_count }}</strong> товарах</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if kits_count > 0 %}
|
||||||
|
<li><strong>{{ kits_count }}</strong> комплектах</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<p class="mb-0 small">
|
||||||
|
При удалении тега он будет удален из всех связанных товаров и комплектов.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
<i class="bi bi-info-circle"></i> Этот тег не используется в товарах или комплектах.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="d-grid gap-2 d-md-flex justify-content-md-end mt-4">
|
||||||
|
<a href="{% url 'products:tag-detail' tag.pk %}" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-x-circle"></i> Отмена
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<i class="bi bi-trash"></i> Да, удалить тег
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Дополнительная информация -->
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">Информация о теге</h6>
|
||||||
|
<dl class="row mb-0 small">
|
||||||
|
<dt class="col-sm-4">Название:</dt>
|
||||||
|
<dd class="col-sm-8">{{ tag.name }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Slug:</dt>
|
||||||
|
<dd class="col-sm-8"><code>{{ tag.slug }}</code></dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Создан:</dt>
|
||||||
|
<dd class="col-sm-8">{{ tag.created_at|date:"d.m.Y H:i" }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Обновлен:</dt>
|
||||||
|
<dd class="col-sm-8">{{ tag.updated_at|date:"d.m.Y H:i" }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
175
myproject/products/templates/products/tag_detail.html
Normal file
175
myproject/products/templates/products/tag_detail.html
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Тег: {{ tag.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<!-- Заголовок с кнопками действий -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>
|
||||||
|
<i class="bi bi-tag"></i> {{ tag.name }}
|
||||||
|
{% if not tag.is_active %}
|
||||||
|
<span class="badge bg-secondary">Неактивен</span>
|
||||||
|
{% endif %}
|
||||||
|
</h2>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<a href="{% url 'products:tag-update' tag.pk %}" class="btn btn-warning">
|
||||||
|
<i class="bi bi-pencil"></i> Изменить
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:tag-delete' tag.pk %}" class="btn btn-danger">
|
||||||
|
<i class="bi bi-trash"></i> Удалить
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:tag-list' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-arrow-left"></i> К списку
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Основная информация -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Информация о теге</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-4">Название:</dt>
|
||||||
|
<dd class="col-sm-8">{{ tag.name }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Slug:</dt>
|
||||||
|
<dd class="col-sm-8"><code>{{ tag.slug }}</code></dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Статус:</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
{% if tag.is_active %}
|
||||||
|
<span class="badge bg-success">Активен</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Неактивен</span>
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Создан:</dt>
|
||||||
|
<dd class="col-sm-8">{{ tag.created_at|date:"d.m.Y H:i" }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Обновлен:</dt>
|
||||||
|
<dd class="col-sm-8">{{ tag.updated_at|date:"d.m.Y H:i" }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Статистика -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Статистика</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-6">Товаров:</dt>
|
||||||
|
<dd class="col-sm-6">
|
||||||
|
<span class="badge bg-info">{{ total_products }}</span>
|
||||||
|
</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-6">Комплектов:</dt>
|
||||||
|
<dd class="col-sm-6">
|
||||||
|
<span class="badge bg-info">{{ total_kits }}</span>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Товары с этим тегом -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Товары с тегом "{{ tag.name }}"</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if products %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Артикул</th>
|
||||||
|
<th>Цена</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for product in products %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ product.name }}</td>
|
||||||
|
<td><code>{{ product.sku }}</code></td>
|
||||||
|
<td>{{ product.actual_price|floatformat:0 }} руб.</td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'products:product-detail' product.pk %}"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
Просмотр
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% if total_products > 20 %}
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
Показано первых 20 из {{ total_products }} товаров.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Нет товаров с этим тегом</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Комплекты с этим тегом -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0">Комплекты с тегом "{{ tag.name }}"</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
{% if kits %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Артикул</th>
|
||||||
|
<th>Цена</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for kit in kits %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ kit.name }}</td>
|
||||||
|
<td><code>{{ kit.sku }}</code></td>
|
||||||
|
<td>{{ kit.get_sale_price|floatformat:0 }} руб.</td>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'products:productkit-detail' kit.pk %}"
|
||||||
|
class="btn btn-sm btn-outline-primary">
|
||||||
|
Просмотр
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% if total_kits > 20 %}
|
||||||
|
<div class="alert alert-info mb-0">
|
||||||
|
Показано первых 20 из {{ total_kits }} комплектов.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted mb-0">Нет комплектов с этим тегом</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
99
myproject/products/templates/products/tag_form.html
Normal file
99
myproject/products/templates/products/tag_form.html
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}{% if object %}Редактировать тег{% else %}Создать тег{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
{% if object %}
|
||||||
|
<i class="bi bi-pencil"></i> Редактировать тег
|
||||||
|
{% else %}
|
||||||
|
<i class="bi bi-plus-circle"></i> Создать новый тег
|
||||||
|
{% endif %}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<!-- Блок 1: Название -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="id_name" class="form-label fw-bold fs-5">
|
||||||
|
{{ form.name.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.name }}
|
||||||
|
{% if form.name.errors %}
|
||||||
|
<div class="text-danger mt-1">
|
||||||
|
{{ form.name.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if form.name.help_text %}
|
||||||
|
<div class="form-text">{{ form.name.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Блок 2: Slug -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="id_slug" class="form-label">
|
||||||
|
{{ form.slug.label }}
|
||||||
|
</label>
|
||||||
|
{{ form.slug }}
|
||||||
|
{% if form.slug.errors %}
|
||||||
|
<div class="text-danger mt-1">
|
||||||
|
{{ form.slug.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if form.slug.help_text %}
|
||||||
|
<div class="form-text">{{ form.slug.help_text }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Блок 3: Активность -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
{{ form.is_active }}
|
||||||
|
<label class="form-check-label" for="id_is_active">
|
||||||
|
{{ form.is_active.label }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% if form.is_active.errors %}
|
||||||
|
<div class="text-danger mt-1">
|
||||||
|
{{ form.is_active.errors }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кнопки действий -->
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-check-circle"></i>
|
||||||
|
{% if object %}Сохранить изменения{% else %}Создать тег{% endif %}
|
||||||
|
</button>
|
||||||
|
<a href="{% url 'products:tag-list' %}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-circle"></i> Отмена
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Дополнительная информация при редактировании -->
|
||||||
|
{% if object %}
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title">Дополнительная информация</h6>
|
||||||
|
<ul class="list-unstyled mb-0">
|
||||||
|
<li><strong>Создан:</strong> {{ object.created_at|date:"d.m.Y H:i" }}</li>
|
||||||
|
<li><strong>Обновлен:</strong> {{ object.updated_at|date:"d.m.Y H:i" }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
147
myproject/products/templates/products/tag_list.html
Normal file
147
myproject/products/templates/products/tag_list.html
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Теги товаров{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2>Теги товаров</h2>
|
||||||
|
<a href="{% url 'products:tag-create' %}" class="btn btn-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Создать тег
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Панель поиска и фильтров -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="get" class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="search" class="form-label">Поиск</label>
|
||||||
|
<input type="text" class="form-control" id="search" name="search"
|
||||||
|
value="{{ search_query }}" placeholder="Поиск по названию или slug...">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="is_active" class="form-label">Статус</label>
|
||||||
|
<select class="form-select" id="is_active" name="is_active">
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option value="1" {% if is_active_filter == '1' %}selected{% endif %}>Активные</option>
|
||||||
|
<option value="0" {% if is_active_filter == '0' %}selected{% endif %}>Неактивные</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex align-items-end">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary w-100">
|
||||||
|
<i class="bi bi-search"></i> Найти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Таблица с тегами -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if tags %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Slug</th>
|
||||||
|
<th>Товары</th>
|
||||||
|
<th>Комплекты</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for tag in tags %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="{% url 'products:tag-detail' tag.pk %}" class="text-decoration-none fw-semibold">
|
||||||
|
{{ tag.name }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td><code>{{ tag.slug }}</code></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-info">{{ tag.products_count }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<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 %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
<a href="{% url 'products:tag-detail' tag.pk %}"
|
||||||
|
class="btn btn-outline-primary" title="Просмотр">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:tag-update' tag.pk %}"
|
||||||
|
class="btn btn-outline-warning" title="Изменить">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:tag-delete' tag.pk %}"
|
||||||
|
class="btn btn-outline-danger" title="Удалить">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Пагинация -->
|
||||||
|
{% if is_paginated %}
|
||||||
|
<nav aria-label="Pagination" class="mt-4">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page=1{% if search_query %}&search={{ search_query }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}">
|
||||||
|
Первая
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if search_query %}&search={{ search_query }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}">
|
||||||
|
Назад
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">
|
||||||
|
Страница {{ page_obj.number }} из {{ page_obj.paginator.num_pages }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% if search_query %}&search={{ search_query }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}">
|
||||||
|
Вперед
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% if search_query %}&search={{ search_query }}{% endif %}{% if is_active_filter %}&is_active={{ is_active_filter }}{% endif %}">
|
||||||
|
Последняя
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
<i class="bi bi-info-circle"></i> Теги не найдены.
|
||||||
|
<a href="{% url 'products:tag-create' %}" class="alert-link">Создать первый тег</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -60,4 +60,11 @@ urlpatterns = [
|
|||||||
path('categories/photo/<int:pk>/set-main/', views.category_photo_set_main, name='category-photo-set-main'),
|
path('categories/photo/<int:pk>/set-main/', views.category_photo_set_main, name='category-photo-set-main'),
|
||||||
path('categories/photo/<int:pk>/move-up/', views.category_photo_move_up, name='category-photo-move-up'),
|
path('categories/photo/<int:pk>/move-up/', views.category_photo_move_up, name='category-photo-move-up'),
|
||||||
path('categories/photo/<int:pk>/move-down/', views.category_photo_move_down, name='category-photo-move-down'),
|
path('categories/photo/<int:pk>/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/<int:pk>/', views.ProductTagDetailView.as_view(), name='tag-detail'),
|
||||||
|
path('tags/<int:pk>/update/', views.ProductTagUpdateView.as_view(), name='tag-update'),
|
||||||
|
path('tags/<int:pk>/delete/', views.ProductTagDeleteView.as_view(), name='tag-delete'),
|
||||||
]
|
]
|
||||||
@@ -70,6 +70,15 @@ from .variant_group_views import (
|
|||||||
product_variant_group_item_move,
|
product_variant_group_item_move,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# CRUD представления для ProductTag
|
||||||
|
from .tag_views import (
|
||||||
|
ProductTagListView,
|
||||||
|
ProductTagCreateView,
|
||||||
|
ProductTagDetailView,
|
||||||
|
ProductTagUpdateView,
|
||||||
|
ProductTagDeleteView,
|
||||||
|
)
|
||||||
|
|
||||||
# API представления
|
# API представления
|
||||||
from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api
|
from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api
|
||||||
|
|
||||||
@@ -129,6 +138,13 @@ __all__ = [
|
|||||||
'ProductVariantGroupDeleteView',
|
'ProductVariantGroupDeleteView',
|
||||||
'product_variant_group_item_move',
|
'product_variant_group_item_move',
|
||||||
|
|
||||||
|
# ProductTag CRUD
|
||||||
|
'ProductTagListView',
|
||||||
|
'ProductTagCreateView',
|
||||||
|
'ProductTagDetailView',
|
||||||
|
'ProductTagUpdateView',
|
||||||
|
'ProductTagDeleteView',
|
||||||
|
|
||||||
# API
|
# API
|
||||||
'search_products_and_variants',
|
'search_products_and_variants',
|
||||||
'validate_kit_cost',
|
'validate_kit_cost',
|
||||||
|
|||||||
120
myproject/products/views/tag_views.py
Normal file
120
myproject/products/views/tag_views.py
Normal file
@@ -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
|
||||||
@@ -18,6 +18,9 @@
|
|||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'products:variantgroup-list' %}">Варианты</a>
|
<a class="nav-link" href="{% url 'products:variantgroup-list' %}">Варианты</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{% url 'products:tag-list' %}">Теги</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'orders:order-list' %}">Заказы</a>
|
<a class="nav-link" href="{% url 'orders:order-list' %}">Заказы</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
Reference in New Issue
Block a user