Реализован полный 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):
|
||||
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(
|
||||
'<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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="Название")
|
||||
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()
|
||||
|
||||
@@ -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>/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'),
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
# 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',
|
||||
|
||||
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">
|
||||
<a class="nav-link" href="{% url 'products:variantgroup-list' %}">Варианты</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'products:tag-list' %}">Теги</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{% url 'orders:order-list' %}">Заказы</a>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user