Обновили шапку и вывод всехтоваров. Добавили фильтры

This commit is contained in:
2025-10-22 15:49:59 +03:00
parent d78c43d9a9
commit 85801c6c4a
13 changed files with 1849 additions and 1219 deletions

View File

@@ -0,0 +1,242 @@
{% extends 'base.html' %}
{% block title %}Все товары и комплекты{% endblock %}
{% block content %}
<div class="container mt-5">
<h2 class="mb-4">Товары</h2>
<!-- Панель быстрых фильтров по категориям -->
{% include 'components/category_filter_buttons.html' with categories=filters.categories current_category=filters.current.category show_type_filters=True %}
<!-- Панель фильтрации и действий -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<!-- Кнопки действий -->
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap">
<h5 class="card-title mb-0 me-3">
<i class="bi bi-funnel-fill"></i> Поиск и фильтры
</h5>
{% if action_buttons %}
<div class="btn-toolbar" role="toolbar">
{% for button in action_buttons %}
<a href="{{ button.url }}" class="btn {{ button.class|default:'btn-primary' }} btn-sm me-2 mb-2 mb-md-0">
{% if button.icon %}<i class="bi bi-{{ button.icon }}"></i>{% endif %}
{{ button.text }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
<hr class="my-3">
<!-- Форма фильтров -->
<form method="get" id="filterForm">
<div class="row g-3">
<!-- Поле поиска -->
<div class="col-12 col-md-6">
<label for="search" class="form-label">
<i class="bi bi-search"></i> Поиск
</label>
<input
type="text"
class="form-control"
id="search"
name="search"
placeholder="Поиск по названию, артикулу..."
value="{{ filters.current.search|default:'' }}"
>
</div>
<!-- Фильтр по статусу -->
<div class="col-12 col-md-3">
<label for="is_active" class="form-label">
<i class="bi bi-toggle-on"></i> Статус
</label>
<select class="form-select" id="is_active" name="is_active">
<option value="">Все</option>
<option value="1" {% if filters.current.is_active == '1' %}selected{% endif %}>Активные</option>
<option value="0" {% if filters.current.is_active == '0' %}selected{% endif %}>Неактивные</option>
</select>
</div>
<!-- Сохраняем текущую категорию при поиске -->
{% if filters.current.category %}
<input type="hidden" name="category" value="{{ filters.current.category }}">
{% endif %}
<!-- Кнопки управления фильтрами -->
<div class="col-12 col-md-3">
<label class="form-label d-none d-md-block">&nbsp;</label>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-check-circle"></i> Применить
</button>
<a href="{{ request.path }}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle"></i> Сброс
</a>
</div>
</div>
</div>
</form>
</div>
</div>
{% if items %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th style="width: 60px;">Тип</th>
<th style="width: 80px;">Фото</th>
<th>Название</th>
<th>Артикул</th>
<th>Категория</th>
<th>Цена продажи</th>
<th>Статус</th>
<th style="width: 200px;">Действия</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>
{% if item.item_type == 'product' %}
<span class="badge bg-success" title="Товар поштучно">
<i class="bi bi-box"></i>
</span>
{% else %}
<span class="badge bg-info" title="Комплект">
<i class="bi bi-box-seam"></i>
</span>
{% endif %}
</td>
<td>
{% if item.photos.all %}
{% with photo=item.photos.first %}
<img src="{{ photo.image.url }}" alt="{{ item.name }}" style="max-width: 50px; max-height: 50px;" class="img-thumbnail">
{% endwith %}
{% else %}
<span class="text-muted small">Нет фото</span>
{% endif %}
</td>
<td>
{% if item.item_type == 'product' %}
<a href="{% url 'products:product-detail' item.pk %}">{{ item.name }}</a>
{% else %}
<a href="{% url 'products:productkit-detail' item.pk %}">{{ item.name }}</a>
{% endif %}
</td>
<td>{{ item.sku }}</td>
<td>
{% if item.categories.all %}
{% for category in item.categories.all %}
<span class="badge bg-secondary">{{ category.name }}</span>{% if not forloop.last %} {% endif %}
{% endfor %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if item.item_type == 'product' %}
{{ item.sale_price|floatformat:2 }} руб.
{% else %}
{{ item.get_sale_price|floatformat:2 }} руб.
{% endif %}
</td>
<td>
{% if item.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">
{% if item.item_type == 'product' %}
<a href="{% url 'products:product-detail' item.pk %}" class="btn btn-outline-info" title="Просмотр">
<i class="bi bi-eye"></i>
</a>
{% if perms.products.change_product %}
<a href="{% url 'products:product-update' item.pk %}" class="btn btn-outline-primary" title="Изменить">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
{% if perms.products.delete_product %}
<a href="{% url 'products:product-delete' item.pk %}" class="btn btn-outline-danger" title="Удалить">
<i class="bi bi-trash"></i>
</a>
{% endif %}
{% else %}
<a href="{% url 'products:productkit-detail' item.pk %}" class="btn btn-outline-info" title="Просмотр">
<i class="bi bi-eye"></i>
</a>
{% if perms.products.change_productkit %}
<a href="{% url 'products:productkit-update' item.pk %}" class="btn btn-outline-primary" title="Изменить">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
{% if perms.products.delete_productkit %}
<a href="{% url 'products:productkit-delete' item.pk %}" class="btn btn-outline-danger" title="Удалить">
<i class="bi bi-trash"></i>
</a>
{% endif %}
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if is_paginated %}
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">Предыдущая</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 }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="alert alert-info">
<h4><i class="bi bi-info-circle"></i> Товары не найдены</h4>
<p>В данный момент нет товаров или комплектов, соответствующих выбранным фильтрам.</p>
<div class="mt-3">
{% if perms.products.add_product %}
<a href="{% url 'products:product-create' %}" class="btn btn-primary me-2">
<i class="bi bi-plus-circle"></i> Создать товар
</a>
{% endif %}
{% if perms.products.add_productkit %}
<a href="{% url 'products:productkit-create' %}" class="btn btn-outline-primary">
<i class="bi bi-plus-circle"></i> Создать комплект
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -4,8 +4,11 @@ from . import views
app_name = 'products' app_name = 'products'
urlpatterns = [ urlpatterns = [
# Combined view for products and kits
path('', views.CombinedProductListView.as_view(), name='all-products'),
# CRUD URLs for Product # CRUD URLs for Product
path('', views.ProductListView.as_view(), name='product-list'), path('products/', views.ProductListView.as_view(), name='product-list'),
path('create/', views.ProductCreateView.as_view(), name='product-create'), path('create/', views.ProductCreateView.as_view(), name='product-create'),
path('<int:pk>/', views.ProductDetailView.as_view(), name='product-detail'), path('<int:pk>/', views.ProductDetailView.as_view(), name='product-detail'),
path('<int:pk>/update/', views.ProductUpdateView.as_view(), name='product-update'), path('<int:pk>/update/', views.ProductUpdateView.as_view(), name='product-update'),

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
# Структура модуля views
Файл `products/views.py` был разбит на несколько специализированных модулей для улучшения читаемости и поддерживаемости кода.
## Статистика оптимизации
### До рефакторинга:
- **Файлов:** 1 (`views.py`)
- **Строк кода:** 1202
- **Дублированный код:** ~400 строк (12 функций управления фото + 3 функции обработки фото)
### После рефакторинга:
- **Файлов:** 7 модулей
- **Строк кода:** 1284 (включая документацию)
- **Дублированный код:** УСТРАНЁН
- **Экономия:** ~400 строк дублированного кода заменены на универсальные функции
## Структура модулей
### 1. `__init__.py` (112 строк)
Экспортирует все представления для обратной совместимости с `urls.py`.
Позволяет использовать импорты вида: `from products.views import ProductListView`
### 2. `utils.py` (73 строки)
Утилиты для работы с фотографиями:
- `validate_photo()` - валидация загружаемого фото
- `handle_photos()` - **УНИВЕРСАЛЬНАЯ** функция обработки фото (заменяет 3 дублирующиеся функции)
### 3. `photo_management.py` (310 строк)
Универсальные функции управления фотографиями:
- **4 базовые функции:** `generic_photo_delete()`, `generic_photo_set_main()`, `generic_photo_move_up()`, `generic_photo_move_down()`
- **12 оберток** для Product, ProductKit и Category (заменяют 12 дублирующихся функций из оригинала)
**Устранённое дублирование:**
- Было: 12 отдельных функций (по 4 для каждой модели)
- Стало: 4 универсальные функции + 12 простых оберток
### 4. `product_views.py` (182 строки)
CRUD представления для товаров (Product):
- `ProductListView` - список товаров с фильтрацией и поиском
- `ProductCreateView` - создание товара
- `ProductDetailView` - просмотр товара
- `ProductUpdateView` - редактирование товара
- `ProductDeleteView` - удаление товара
### 5. `productkit_views.py` (249 строк)
CRUD представления для комплектов (ProductKit):
- `ProductKitListView` - список комплектов
- `ProductKitCreateView` - создание комплекта с компонентами
- `ProductKitDetailView` - просмотр комплекта
- `ProductKitUpdateView` - редактирование комплекта
- `ProductKitDeleteView` - удаление комплекта
### 6. `category_views.py` (280 строк)
CRUD представления для категорий (ProductCategory):
- `TreeItem` - класс для элемента дерева категорий
- `ProductCategoryListView` - иерархическое дерево категорий с товарами и комплектами
- `ProductCategoryCreateView` - создание категории
- `ProductCategoryDetailView` - просмотр категории
- `ProductCategoryUpdateView` - редактирование категории
- `ProductCategoryDeleteView` - удаление категории
### 7. `api_views.py` (78 строк)
API представления:
- `search_products_and_variants()` - поиск товаров и групп вариантов для автокомплита
## Преимущества новой структуры
### ✅ Устранено дублирование
- 12 функций управления фото → 4 универсальные + 12 простых оберток
- 3 функции обработки фото → 1 универсальная функция
### ✅ Улучшена организация
- Логическое разделение по функциональным областям
- Каждый модуль отвечает за свою сущность (Product, ProductKit, Category)
- Легко найти нужный код
### ✅ Упрощена поддержка
- Изменения в одном типе представлений не затрагивают другие
- Проще тестировать отдельные компоненты
- Легче добавлять новый функционал
### ✅ Обратная совместимость
- Все импорты в `urls.py` работают без изменений
- Благодаря `__init__.py` внешний API не изменился
### ✅ Следование принципам
- **SRP (Single Responsibility Principle)** - каждый модуль отвечает за одну область
- **DRY (Don't Repeat Yourself)** - устранено дублирование кода
- **Separation of Concerns** - разделение по ответственности
## Примеры использования
### Импорт представлений (работает как раньше):
```python
from products.views import ProductListView, ProductCreateView
from products.views import productkit_photo_delete
from products.views import search_products_and_variants
```
### Импорт из конкретного модуля (новая возможность):
```python
from products.views.product_views import ProductListView
from products.views.photo_management import generic_photo_delete
from products.views.utils import validate_photo
```
## Изменения в коде
### Удалено:
- Неиспользуемый импорт `import json`
- Комментарий-мусор "Временный файл для добавления в views.py"
### Добавлено:
- Docstrings для всех модулей
- Комментарии к универсальным функциям
- Документация параметров функций
## Тестирование
После рефакторинга рекомендуется:
1. Запустить Django сервер: `python manage.py runserver`
2. Проверить все CRUD операции для Product, ProductKit и Category
3. Проверить управление фотографиями (upload, delete, set main, move up/down)
4. Проверить API endpoint для поиска товаров
## Потенциальные улучшения
В будущем можно:
1. Добавить базовый класс `BasePhotoView` для дальнейшего упрощения
2. Вынести общую логику ListView в миксины
3. Добавить unit-тесты для каждого модуля
4. Создать отдельный модуль для миксинов и базовых классов

View File

@@ -0,0 +1,114 @@
"""
Экспорт всех представлений для обратной совместимости с urls.py.
Позволяет использовать импорты вида: from products.views import ProductListView
"""
# Утилиты
from .utils import validate_photo, handle_photos
# Управление фотографиями (Product)
from .photo_management import (
product_photo_delete,
product_photo_set_main,
product_photo_move_up,
product_photo_move_down,
)
# Управление фотографиями (ProductKit)
from .photo_management import (
productkit_photo_delete,
productkit_photo_set_main,
productkit_photo_move_up,
productkit_photo_move_down,
)
# Управление фотографиями (Category)
from .photo_management import (
category_photo_delete,
category_photo_set_main,
category_photo_move_up,
category_photo_move_down,
)
# CRUD представления для Product
from .product_views import (
ProductListView,
ProductCreateView,
ProductDetailView,
ProductUpdateView,
ProductDeleteView,
CombinedProductListView,
)
# CRUD представления для ProductKit
from .productkit_views import (
ProductKitListView,
ProductKitCreateView,
ProductKitDetailView,
ProductKitUpdateView,
ProductKitDeleteView,
)
# CRUD представления для Category
from .category_views import (
TreeItem,
ProductCategoryListView,
ProductCategoryCreateView,
ProductCategoryDetailView,
ProductCategoryUpdateView,
ProductCategoryDeleteView,
)
# API представления
from .api_views import search_products_and_variants
__all__ = [
# Утилиты
'validate_photo',
'handle_photos',
# Управление фотографиями Product
'product_photo_delete',
'product_photo_set_main',
'product_photo_move_up',
'product_photo_move_down',
# Управление фотографиями ProductKit
'productkit_photo_delete',
'productkit_photo_set_main',
'productkit_photo_move_up',
'productkit_photo_move_down',
# Управление фотографиями Category
'category_photo_delete',
'category_photo_set_main',
'category_photo_move_up',
'category_photo_move_down',
# Product CRUD
'ProductListView',
'ProductCreateView',
'ProductDetailView',
'ProductUpdateView',
'ProductDeleteView',
'CombinedProductListView',
# ProductKit CRUD
'ProductKitListView',
'ProductKitCreateView',
'ProductKitDetailView',
'ProductKitUpdateView',
'ProductKitDeleteView',
# Category CRUD
'TreeItem',
'ProductCategoryListView',
'ProductCategoryCreateView',
'ProductCategoryDetailView',
'ProductCategoryUpdateView',
'ProductCategoryDeleteView',
# API
'search_products_and_variants',
]

View File

@@ -0,0 +1,78 @@
"""
API представления для приложения products.
"""
from django.http import JsonResponse
from django.db import models
from ..models import Product, ProductVariantGroup
def search_products_and_variants(request):
"""
API endpoint для поиска товаров и групп вариантов.
Используется для автокомплита при добавлении компонентов в комплект.
Параметры GET:
- q: строка поиска
- type: 'product' или 'variant' (опционально, если не указано - поиск по обоим)
Возвращает JSON список:
[
{
"id": 1,
"name": "Роза красная Freedom 50см",
"sku": "PROD-000001",
"type": "product",
"price": "150.00"
},
{
"id": 1,
"name": "Роза красная Freedom",
"type": "variant",
"count": 3
}
]
"""
query = request.GET.get('q', '').strip()
search_type = request.GET.get('type', 'all')
if not query or len(query) < 2:
return JsonResponse({'results': []})
results = []
# Поиск товаров
if search_type in ['all', 'product']:
products = Product.objects.filter(
models.Q(name__icontains=query) |
models.Q(sku__icontains=query) |
models.Q(description__icontains=query),
is_active=True
).values('id', 'name', 'sku', 'sale_price')[:10]
for product in products:
results.append({
'id': product['id'],
'name': f"{product['name']} ({product['sku']})",
'sku': product['sku'],
'type': 'product',
'price': str(product['sale_price'])
})
# Поиск групп вариантов
if search_type in ['all', 'variant']:
variants = ProductVariantGroup.objects.filter(
models.Q(name__icontains=query) |
models.Q(description__icontains=query)
).prefetch_related('products')[:10]
for variant in variants:
count = variant.products.filter(is_active=True).count()
results.append({
'id': variant.id,
'name': f"{variant.name} ({count} вариантов)",
'type': 'variant',
'count': count
})
return JsonResponse({'results': results})

View File

@@ -0,0 +1,280 @@
"""
CRUD представления для категорий товаров (ProductCategory).
Включает иерархическое дерево категорий с товарами и комплектами.
"""
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, reverse
from django.shortcuts import redirect
from django.db.models import Q
from ..models import ProductCategory, ProductCategoryPhoto
from ..forms import ProductCategoryForm
from .utils import handle_photos
class TreeItem:
"""
Универсальный элемент дерева категорий.
Может представлять категорию, товар или набор.
"""
def __init__(self, obj, item_type, depth, parent_category_id=None):
self.obj = obj
self.item_type = item_type # 'category', 'product', 'kit'
self.depth = depth
self.pk = obj.pk
self.name = obj.name
self.sku = getattr(obj, 'sku', None)
if item_type == 'category':
self.has_children = obj.children.exists()
self.parent_id = obj.parent.pk if obj.parent else None
else:
# Товары и наборы не имеют детей
self.has_children = False
# Виртуальный parent_id = ID категории (для JavaScript)
self.parent_id = parent_category_id
# Получение цены
if item_type == 'product':
self.price = obj.sale_price
elif item_type == 'kit':
self.price = obj.get_sale_price()
else:
self.price = None
class ProductCategoryListView(LoginRequiredMixin, ListView):
model = ProductCategory
template_name = 'products/category_list.html'
context_object_name = 'categories'
paginate_by = None # Отключаем пагинацию для иерархического отображения
def get_queryset(self):
queryset = super().get_queryset()
# Добавляем prefetch_related для оптимизации запросов к фото
queryset = queryset.prefetch_related('photos', 'children')
# Поиск по названию и артикулу
search_query = self.request.GET.get('search')
if search_query:
queryset = queryset.filter(
Q(name__icontains=search_query) |
Q(sku__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
def build_category_tree(self, queryset):
"""
Строит иерархическое дерево категорий с товарами и наборами.
Возвращает плоский список TreeItem объектов.
"""
# Получаем все категории из queryset с prefetch для товаров и наборов
all_categories = list(queryset.select_related('parent')
.prefetch_related('photos', 'children',
'products', 'kits'))
# Создаем словарь для быстрого доступа по ID
categories_dict = {cat.pk: cat for cat in all_categories}
# Добавляем служебные поля
for cat in all_categories:
cat.depth = 0
cat.has_children = cat.children.exists()
# Находим корневые категории (parent=None или parent не в нашем queryset)
queryset_ids = set(categories_dict.keys())
root_categories = [
cat for cat in all_categories
if cat.parent is None or cat.parent.pk not in queryset_ids
]
# Сортируем корневые по имени
root_categories.sort(key=lambda x: x.name.lower())
# Рекурсивно строим дерево
result = []
def add_category_and_children(category, depth=0):
# 1. Добавляем категорию как TreeItem
tree_item = TreeItem(category, 'category', depth)
result.append(tree_item)
# 2. Добавляем активные товары этой категории (отсортированные по имени)
products = category.products.filter(is_active=True).order_by('name')
for product in products:
product_item = TreeItem(product, 'product', depth + 1, category.pk)
result.append(product_item)
# 3. Добавляем активные наборы этой категории (отсортированные по имени)
kits = category.kits.filter(is_active=True).order_by('name')
for kit in kits:
kit_item = TreeItem(kit, 'kit', depth + 1, category.pk)
result.append(kit_item)
# 4. Рекурсивно обрабатываем дочерние категории
children = [
cat for cat in all_categories
if cat.parent and cat.parent.pk == category.pk
]
# Сортируем детей по имени
children.sort(key=lambda x: x.name.lower())
# Рекурсивно добавляем детей
for child in children:
add_category_and_children(child, depth + 1)
# Обходим все корневые категории
for root in root_categories:
add_category_and_children(root, 0)
return result
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Строим иерархическое дерево
queryset = self.get_queryset()
category_tree = self.build_category_tree(queryset)
context['category_tree'] = category_tree
# Флаг для автоматического раскрытия при поиске
context['has_search'] = bool(self.request.GET.get('search'))
# Фильтры для панели фильтрации
context['filters'] = {
'search': self.request.GET.get('search', ''),
'is_active': self.request.GET.get('is_active', ''),
'current': {
'search': self.request.GET.get('search', ''),
'is_active': self.request.GET.get('is_active', ''),
}
}
# Кнопки действий
context['action_buttons'] = [
{'url': reverse('products:category-create'), 'text': 'Создать категорию', 'icon': 'plus-circle'}
]
return context
class ProductCategoryCreateView(LoginRequiredMixin, CreateView):
model = ProductCategory
form_class = ProductCategoryForm
template_name = 'products/category_form.html'
success_url = reverse_lazy('products:category-list')
def form_valid(self, form):
# Сохраняем категорию
self.object = form.save()
messages.success(self.request, f'Категория "{self.object.name}" создана успешно.')
# Обработка загрузки фотографий
errors = handle_photos(self.request, self.object, ProductCategoryPhoto, 'category')
if errors:
for error in errors:
messages.warning(self.request, error)
return redirect(self.get_success_url())
def form_invalid(self, form):
messages.error(self.request, 'Пожалуйста, исправьте ошибки в форме.')
return super().form_invalid(form)
class ProductCategoryDetailView(LoginRequiredMixin, DetailView):
model = ProductCategory
template_name = 'products/category_detail.html'
context_object_name = 'category'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Получаем фотографии категории
context['category_photos'] = self.object.photos.all().order_by('order')
context['photos_count'] = context['category_photos'].count()
# Получаем дочерние категории
context['children_categories'] = self.object.children.filter(is_active=True)
# Получаем товары в категории
context['products'] = self.object.products.filter(is_active=True)[:20]
context['products_count'] = self.object.products.filter(is_active=True).count()
return context
class ProductCategoryUpdateView(LoginRequiredMixin, UpdateView):
model = ProductCategory
form_class = ProductCategoryForm
template_name = 'products/category_form.html'
success_url = reverse_lazy('products:category-list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Добавляем фотографии для отображения в форме
context['category_photos'] = self.object.photos.all().order_by('order')
context['photos_count'] = context['category_photos'].count()
return context
def form_valid(self, form):
# Сохраняем категорию
self.object = form.save()
messages.success(self.request, f'Категория "{self.object.name}" обновлена успешно.')
# Обработка загрузки новых фотографий
errors = handle_photos(self.request, self.object, ProductCategoryPhoto, 'category')
if errors:
for error in errors:
messages.warning(self.request, error)
return redirect(self.get_success_url())
def form_invalid(self, form):
messages.error(self.request, 'Пожалуйста, исправьте ошибки в форме.')
return super().form_invalid(form)
class ProductCategoryDeleteView(LoginRequiredMixin, DeleteView):
model = ProductCategory
template_name = 'products/category_confirm_delete.html'
success_url = reverse_lazy('products:category-list')
context_object_name = 'category'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Проверяем, есть ли товары в категории
context['products_count'] = self.object.products.count()
context['children_count'] = self.object.children.count()
return context
def post(self, request, *args, **kwargs):
self.object = self.get_object()
# Проверяем, есть ли товары в категории
if self.object.products.exists():
messages.error(
request,
f'Невозможно удалить категорию "{self.object.name}", '
f'так как в ней есть товары ({self.object.products.count()} шт.). '
f'Сначала удалите или переместите товары.'
)
return redirect('products:category-detail', pk=self.object.pk)
# Проверяем, есть ли дочерние категории
if self.object.children.exists():
messages.error(
request,
f'Невозможно удалить категорию "{self.object.name}", '
f'так как у неё есть подкатегории ({self.object.children.count()} шт.). '
f'Сначала удалите или переместите подкатегории.'
)
return redirect('products:category-detail', pk=self.object.pk)
messages.success(request, f'Категория "{self.object.name}" удалена успешно.')
return super().post(request, *args, **kwargs)

View File

@@ -0,0 +1,310 @@
"""
Универсальные функции для управления фотографиями товаров, комплектов и категорий.
Устраняет дублирование кода для операций: delete, set_main, move_up, move_down.
"""
from django.shortcuts import get_object_or_404, redirect
from django.contrib import messages
from ..models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
# ====================================
# Универсальные функции
# ====================================
def generic_photo_delete(request, pk, photo_model, redirect_url_name, parent_attr, permission):
"""
Универсальное удаление фотографии.
Args:
request: HTTP request
pk: ID фотографии
photo_model: Модель фото (ProductPhoto, ProductKitPhoto, ProductCategoryPhoto)
redirect_url_name: Имя URL для редиректа ('products:product-update', etc.)
parent_attr: Имя атрибута родителя ('product', 'kit', 'category')
permission: Требуемое разрешение ('products.change_product', etc.)
"""
photo = get_object_or_404(photo_model, pk=pk)
parent = getattr(photo, parent_attr)
parent_id = parent.id
# Проверка прав доступа
if not request.user.has_perm(permission):
messages.error(request, 'У вас нет прав для удаления фотографий.')
return redirect(redirect_url_name, pk=parent_id)
photo.delete()
messages.success(request, 'Фото успешно удалено!')
return redirect(redirect_url_name, pk=parent_id)
def generic_photo_set_main(request, pk, photo_model, redirect_url_name, parent_attr, permission):
"""
Универсальная установка фото как главного (order = 0).
Args:
request: HTTP request
pk: ID фотографии
photo_model: Модель фото
redirect_url_name: Имя URL для редиректа
parent_attr: Имя атрибута родителя ('product', 'kit', 'category')
permission: Требуемое разрешение
"""
photo = get_object_or_404(photo_model, pk=pk)
parent = getattr(photo, parent_attr)
parent_id = parent.id
# Проверка прав доступа
if not request.user.has_perm(permission):
messages.error(request, 'У вас нет прав для изменения порядка фотографий.')
return redirect(redirect_url_name, pk=parent_id)
# Получаем все фото этого родительского объекта
filter_kwargs = {f"{parent_attr}_id": parent_id}
photos = photo_model.objects.filter(**filter_kwargs).order_by('order')
# Если это уже главное фото, ничего не делаем
if photo.order == 0:
messages.info(request, 'Это фото уже установлено как главное.')
return redirect(redirect_url_name, pk=parent_id)
# Меняем порядок: текущее главное фото становится вторым
old_order = photo.order
for p in photos:
if p.pk == photo.pk:
p.order = 0
p.save()
elif p.order == 0:
p.order = old_order
p.save()
messages.success(request, 'Фото установлено как главное!')
return redirect(redirect_url_name, pk=parent_id)
def generic_photo_move_up(request, pk, photo_model, redirect_url_name, parent_attr, permission):
"""
Универсальное перемещение фото вверх (уменьшить order).
Args:
request: HTTP request
pk: ID фотографии
photo_model: Модель фото
redirect_url_name: Имя URL для редиректа
parent_attr: Имя атрибута родителя ('product', 'kit', 'category')
permission: Требуемое разрешение
"""
photo = get_object_or_404(photo_model, pk=pk)
parent = getattr(photo, parent_attr)
parent_id = parent.id
# Проверка прав доступа
if not request.user.has_perm(permission):
messages.error(request, 'У вас нет прав для изменения порядка фотографий.')
return redirect(redirect_url_name, pk=parent_id)
# Если это уже первое фото
if photo.order == 0:
messages.info(request, 'Это фото уже первое в списке.')
return redirect(redirect_url_name, pk=parent_id)
# Находим предыдущее фото
filter_kwargs = {
f"{parent_attr}_id": parent_id,
'order__lt': photo.order
}
prev_photo = photo_model.objects.filter(**filter_kwargs).order_by('-order').first()
if prev_photo:
# Меняем местами
photo.order, prev_photo.order = prev_photo.order, photo.order
photo.save()
prev_photo.save()
messages.success(request, 'Фото перемещено вверх!')
return redirect(redirect_url_name, pk=parent_id)
def generic_photo_move_down(request, pk, photo_model, redirect_url_name, parent_attr, permission):
"""
Универсальное перемещение фото вниз (увеличить order).
Args:
request: HTTP request
pk: ID фотографии
photo_model: Модель фото
redirect_url_name: Имя URL для редиректа
parent_attr: Имя атрибута родителя ('product', 'kit', 'category')
permission: Требуемое разрешение
"""
photo = get_object_or_404(photo_model, pk=pk)
parent = getattr(photo, parent_attr)
parent_id = parent.id
# Проверка прав доступа
if not request.user.has_perm(permission):
messages.error(request, 'У вас нет прав для изменения порядка фотографий.')
return redirect(redirect_url_name, pk=parent_id)
# Находим следующее фото
filter_kwargs = {
f"{parent_attr}_id": parent_id,
'order__gt': photo.order
}
next_photo = photo_model.objects.filter(**filter_kwargs).order_by('order').first()
if next_photo:
# Меняем местами
photo.order, next_photo.order = next_photo.order, photo.order
photo.save()
next_photo.save()
messages.success(request, 'Фото перемещено вниз!')
else:
messages.info(request, 'Это фото уже последнее в списке.')
return redirect(redirect_url_name, pk=parent_id)
# ====================================
# Обертки для Product Photos
# ====================================
def product_photo_delete(request, pk):
"""Удаление фотографии товара"""
return generic_photo_delete(
request, pk,
photo_model=ProductPhoto,
redirect_url_name='products:product-update',
parent_attr='product',
permission='products.change_product'
)
def product_photo_set_main(request, pk):
"""Установка фото товара как главного (order = 0)"""
return generic_photo_set_main(
request, pk,
photo_model=ProductPhoto,
redirect_url_name='products:product-update',
parent_attr='product',
permission='products.change_product'
)
def product_photo_move_up(request, pk):
"""Переместить фото товара вверх (уменьшить order)"""
return generic_photo_move_up(
request, pk,
photo_model=ProductPhoto,
redirect_url_name='products:product-update',
parent_attr='product',
permission='products.change_product'
)
def product_photo_move_down(request, pk):
"""Переместить фото товара вниз (увеличить order)"""
return generic_photo_move_down(
request, pk,
photo_model=ProductPhoto,
redirect_url_name='products:product-update',
parent_attr='product',
permission='products.change_product'
)
# ====================================
# Обертки для ProductKit Photos
# ====================================
def productkit_photo_delete(request, pk):
"""Удаление фотографии комплекта"""
return generic_photo_delete(
request, pk,
photo_model=ProductKitPhoto,
redirect_url_name='products:productkit-update',
parent_attr='kit',
permission='products.change_productkit'
)
def productkit_photo_set_main(request, pk):
"""Установка фото комплекта как главного (order = 0)"""
return generic_photo_set_main(
request, pk,
photo_model=ProductKitPhoto,
redirect_url_name='products:productkit-update',
parent_attr='kit',
permission='products.change_productkit'
)
def productkit_photo_move_up(request, pk):
"""Переместить фото комплекта вверх (уменьшить order)"""
return generic_photo_move_up(
request, pk,
photo_model=ProductKitPhoto,
redirect_url_name='products:productkit-update',
parent_attr='kit',
permission='products.change_productkit'
)
def productkit_photo_move_down(request, pk):
"""Переместить фото комплекта вниз (увеличить order)"""
return generic_photo_move_down(
request, pk,
photo_model=ProductKitPhoto,
redirect_url_name='products:productkit-update',
parent_attr='kit',
permission='products.change_productkit'
)
# ====================================
# Обертки для Category Photos
# ====================================
def category_photo_delete(request, pk):
"""Удаление фотографии категории"""
return generic_photo_delete(
request, pk,
photo_model=ProductCategoryPhoto,
redirect_url_name='products:category-update',
parent_attr='category',
permission='products.change_productcategory'
)
def category_photo_set_main(request, pk):
"""Установка фото категории как главного (order = 0)"""
return generic_photo_set_main(
request, pk,
photo_model=ProductCategoryPhoto,
redirect_url_name='products:category-update',
parent_attr='category',
permission='products.change_productcategory'
)
def category_photo_move_up(request, pk):
"""Переместить фото категории вверх (уменьшить order)"""
return generic_photo_move_up(
request, pk,
photo_model=ProductCategoryPhoto,
redirect_url_name='products:category-update',
parent_attr='category',
permission='products.change_productcategory'
)
def category_photo_move_down(request, pk):
"""Переместить фото категории вниз (увеличить order)"""
return generic_photo_move_down(
request, pk,
photo_model=ProductCategoryPhoto,
redirect_url_name='products:category-update',
parent_attr='category',
permission='products.change_productcategory'
)

View File

@@ -0,0 +1,289 @@
"""
CRUD представления для товаров (Product).
"""
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
from django.urls import reverse_lazy
from django.db.models import Q
from itertools import chain
from ..models import Product, ProductCategory, ProductTag, ProductKit
from ..forms import ProductForm
from .utils import handle_photos
from ..models import ProductPhoto
class ProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
model = Product
template_name = 'products/product_list.html'
context_object_name = 'products'
permission_required = 'products.view_product'
paginate_by = 10
def get_queryset(self):
queryset = super().get_queryset()
# Добавляем prefetch_related для оптимизации запросов к категориям
queryset = queryset.prefetch_related('categories', 'photos', 'tags')
# Улучшенный поиск по нескольким полям
search_query = self.request.GET.get('search')
if search_query:
# Ищем по названию, артикулу, описанию, категориям и ключевым словам
queryset = queryset.filter(
Q(name__icontains=search_query) |
Q(sku__icontains=search_query) |
Q(description__icontains=search_query) |
Q(categories__name__icontains=search_query) |
Q(search_keywords__icontains=search_query)
).distinct()
# Фильтр по категории
category_id = self.request.GET.get('category')
if category_id:
queryset = queryset.filter(categories__id=category_id)
# Фильтр по статусу
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)
# Фильтр по тегам
tags = self.request.GET.getlist('tags')
if tags:
queryset = queryset.filter(tags__id__in=tags).distinct()
return queryset.order_by('-created_at')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Данные для фильтров
context['filters'] = {
'categories': ProductCategory.objects.filter(is_active=True),
'tags': ProductTag.objects.all(),
'current': {
'search': self.request.GET.get('search', ''),
'category': self.request.GET.get('category', ''),
'is_active': self.request.GET.get('is_active', ''),
'tags': self.request.GET.getlist('tags'),
}
}
# Кнопки действий
action_buttons = []
if self.request.user.has_perm('products.add_product'):
action_buttons.append({
'url': reverse_lazy('products:product-create'),
'text': 'Создать товар',
'class': 'btn-primary',
'icon': 'plus-circle'
})
if self.request.user.has_perm('products.add_productkit'):
action_buttons.append({
'url': reverse_lazy('products:productkit-create'),
'text': 'Создать комплект',
'class': 'btn-outline-primary',
'icon': 'box-seam'
})
action_buttons.append({
'url': reverse_lazy('products:productkit-list'),
'text': 'К списку комплектов',
'class': 'btn-outline-secondary',
'icon': 'list'
})
context['action_buttons'] = action_buttons
return context
class ProductCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
model = Product
form_class = ProductForm
template_name = 'products/product_form.html'
permission_required = 'products.add_product'
def get_success_url(self):
return reverse_lazy('products:product-list')
def form_valid(self, form):
response = super().form_valid(form)
# Handle photo uploads
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
if photo_errors:
for error in photo_errors:
messages.error(self.request, error)
messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!')
return response
class ProductDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
model = Product
template_name = 'products/product_detail.html'
context_object_name = 'product'
permission_required = 'products.view_product'
def get_queryset(self):
# Prefetch photos to avoid N+1 queries
return super().get_queryset().prefetch_related('photos')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Добавляем фотографии товара в контекст
context['product_photos'] = self.object.photos.all().order_by('order', 'created_at')
context['photos_count'] = self.object.photos.count()
return context
class ProductUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
model = Product
form_class = ProductForm
template_name = 'products/product_form.html'
permission_required = 'products.change_product'
def get_success_url(self):
return reverse_lazy('products:product-list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Добавляем фотографии товара в контекст
context['product_photos'] = self.object.photos.all().order_by('order', 'created_at')
context['photos_count'] = self.object.photos.count()
return context
def form_valid(self, form):
response = super().form_valid(form)
# Handle photo uploads
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
if photo_errors:
for error in photo_errors:
messages.error(self.request, error)
messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!')
return response
class ProductDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
model = Product
template_name = 'products/product_confirm_delete.html'
context_object_name = 'product'
permission_required = 'products.delete_product'
def get_success_url(self):
messages.success(self.request, f'Товар "{self.object.name}" успешно удален!')
return reverse_lazy('products:product-list')
class CombinedProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
"""
Объединенное представление для товаров и комплектов.
Показывает оба типа продуктов в одном списке.
"""
template_name = 'products/all_products_list.html'
context_object_name = 'items'
permission_required = 'products.view_product'
paginate_by = 20
def get_queryset(self):
# Получаем товары и комплекты
products = Product.objects.prefetch_related('categories', 'photos', 'tags')
kits = ProductKit.objects.prefetch_related('categories', 'photos')
# Применяем фильтры
search_query = self.request.GET.get('search')
category_id = self.request.GET.get('category')
is_active = self.request.GET.get('is_active')
# Фильтрация по поиску
if search_query:
products = products.filter(
Q(name__icontains=search_query) |
Q(sku__icontains=search_query) |
Q(description__icontains=search_query) |
Q(categories__name__icontains=search_query) |
Q(search_keywords__icontains=search_query)
).distinct()
kits = kits.filter(
Q(name__icontains=search_query) |
Q(sku__icontains=search_query) |
Q(description__icontains=search_query) |
Q(categories__name__icontains=search_query)
).distinct()
# Фильтрация по категории
if category_id:
products = products.filter(categories__id=category_id)
kits = kits.filter(categories__id=category_id)
# Фильтрация по статусу
if is_active == '1':
products = products.filter(is_active=True)
kits = kits.filter(is_active=True)
elif is_active == '0':
products = products.filter(is_active=False)
kits = kits.filter(is_active=False)
# Добавляем type для различения в шаблоне
products_list = list(products.order_by('-created_at'))
for p in products_list:
p.item_type = 'product'
kits_list = list(kits.order_by('-created_at'))
for k in kits_list:
k.item_type = 'kit'
# Объединяем и сортируем по дате создания
combined = sorted(
chain(products_list, kits_list),
key=lambda x: x.created_at,
reverse=True
)
return combined
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Данные для фильтров
context['filters'] = {
'categories': ProductCategory.objects.filter(is_active=True),
'tags': ProductTag.objects.all(),
'current': {
'search': self.request.GET.get('search', ''),
'category': self.request.GET.get('category', ''),
'is_active': self.request.GET.get('is_active', ''),
}
}
# Кнопки действий
action_buttons = []
if self.request.user.has_perm('products.add_product'):
action_buttons.append({
'url': reverse_lazy('products:product-create'),
'text': 'Создать товар',
'class': 'btn-primary',
'icon': 'plus-circle'
})
if self.request.user.has_perm('products.add_productkit'):
action_buttons.append({
'url': reverse_lazy('products:productkit-create'),
'text': 'Создать комплект',
'class': 'btn-outline-primary',
'icon': 'box-seam'
})
context['action_buttons'] = action_buttons
return context

View File

@@ -0,0 +1,249 @@
"""
CRUD представления для комплектов товаров (ProductKit).
"""
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
from django.urls import reverse_lazy
from django.shortcuts import redirect
from django.db import transaction
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto
from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
from .utils import handle_photos
class ProductKitListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
model = ProductKit
template_name = 'products/productkit_list.html'
context_object_name = 'kits'
permission_required = 'products.view_productkit'
paginate_by = 10
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset.prefetch_related('categories', 'photos', 'kit_items', 'tags')
# Поиск по названию
search_query = self.request.GET.get('search')
if search_query:
queryset = queryset.filter(name__icontains=search_query)
# Фильтр по категории
category_id = self.request.GET.get('category')
if category_id:
queryset = queryset.filter(categories__id=category_id)
# Фильтр по статусу
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('-created_at')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Данные для фильтров
context['filters'] = {
'categories': ProductCategory.objects.filter(is_active=True),
'tags': ProductTag.objects.all(),
'current': {
'search': self.request.GET.get('search', ''),
'category': self.request.GET.get('category', ''),
'is_active': self.request.GET.get('is_active', ''),
'tags': self.request.GET.getlist('tags'),
}
}
# Кнопки действий
action_buttons = []
if self.request.user.has_perm('products.add_productkit'):
action_buttons.append({
'url': reverse_lazy('products:productkit-create'),
'text': 'Создать комплект',
'class': 'btn-primary',
'icon': 'plus-circle'
})
action_buttons.append({
'url': reverse_lazy('products:product-list'),
'text': 'К товарам',
'class': 'btn-outline-primary',
'icon': 'box'
})
context['action_buttons'] = action_buttons
return context
class ProductKitCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
"""
View для создания нового комплекта с компонентами.
"""
model = ProductKit
form_class = ProductKitForm
template_name = 'products/productkit_form.html'
permission_required = 'products.add_productkit'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.POST:
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, instance=self.object)
else:
context['kititem_formset'] = KitItemFormSetCreate(instance=self.object)
return context
def form_valid(self, form):
# Получаем формсет из POST
kititem_formset = KitItemFormSetCreate(self.request.POST, instance=self.object)
# Проверяем валидность формсета
if kititem_formset.is_valid():
try:
with transaction.atomic():
# Сохраняем основную форму
self.object = form.save()
# Сохраняем компоненты
kititem_formset.instance = self.object
kititem_formset.save()
# Обработка фотографий
handle_photos(self.request, self.object, ProductKitPhoto, 'kit')
messages.success(self.request, f'Комплект "{self.object.name}" успешно создан!')
# Проверяем, какую кнопку нажали
if self.request.POST.get('action') == 'continue':
return redirect('products:productkit-update', pk=self.object.pk)
else:
return redirect('products:productkit-list')
except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
return self.form_invalid(form)
else:
# Если формсет невалиден, показываем форму с ошибками
messages.error(self.request, 'Пожалуйста, исправьте ошибки в компонентах комплекта.')
return self.form_invalid(form)
def form_invalid(self, form):
# Получаем формсет для отображения ошибок
context = self.get_context_data(form=form)
return self.render_to_response(context)
def get_success_url(self):
return reverse_lazy('products:productkit-list')
class ProductKitUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
"""
View для редактирования существующего комплекта.
"""
model = ProductKit
form_class = ProductKitForm
template_name = 'products/productkit_form.html'
permission_required = 'products.change_productkit'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.request.POST:
context['kititem_formset'] = KitItemFormSetUpdate(self.request.POST, instance=self.object)
else:
context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object)
context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at')
context['photos_count'] = self.object.photos.count()
return context
def form_valid(self, form):
# Получаем формсет из POST
kititem_formset = KitItemFormSetUpdate(self.request.POST, instance=self.object)
# Проверяем валидность формсета
if kititem_formset.is_valid():
try:
with transaction.atomic():
# Сохраняем основную форму
self.object = form.save()
# Сохраняем компоненты
kititem_formset.instance = self.object
kititem_formset.save()
# Обработка фотографий
handle_photos(self.request, self.object, ProductKitPhoto, 'kit')
messages.success(self.request, f'Комплект "{self.object.name}" успешно обновлен!')
# Проверяем, какую кнопку нажали
if self.request.POST.get('action') == 'continue':
return redirect('products:productkit-update', pk=self.object.pk)
else:
return redirect('products:productkit-list')
except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
return self.form_invalid(form)
else:
# Если формсет невалиден, показываем форму с ошибками
messages.error(self.request, 'Пожалуйста, исправьте ошибки в компонентах комплекта.')
return self.form_invalid(form)
def form_invalid(self, form):
# Получаем формсет для отображения ошибок
context = self.get_context_data(form=form)
return self.render_to_response(context)
def get_success_url(self):
return reverse_lazy('products:productkit-list')
class ProductKitDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
"""
View для просмотра деталей комплекта.
Показывает все компоненты, цены, фотографии.
"""
model = ProductKit
template_name = 'products/productkit_detail.html'
context_object_name = 'kit'
permission_required = 'products.view_productkit'
def get_queryset(self):
# Prefetch для оптимизации запросов
return super().get_queryset().prefetch_related(
'photos',
'kit_items__product',
'kit_items__variant_group',
'tags'
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Добавляем фотографии комплекта в контекст
context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at')
context['photos_count'] = self.object.photos.count()
# Добавляем компоненты
context['kit_items'] = self.object.kit_items.all().select_related('product', 'variant_group')
return context
class ProductKitDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
"""
View для удаления комплекта.
"""
model = ProductKit
template_name = 'products/productkit_confirm_delete.html'
context_object_name = 'kit'
permission_required = 'products.delete_productkit'
def get_success_url(self):
messages.success(self.request, f'Комплект "{self.object.name}" успешно удален!')
return reverse_lazy('products:productkit-list')

View File

@@ -0,0 +1,73 @@
"""
Утилиты для работы с фотографиями товаров, комплектов и категорий.
"""
import os
from django.db import models
def validate_photo(photo):
"""
Валидация загружаемого фото.
Возвращает (True, None) если валидно, или (False, error_message) если ошибка.
"""
max_size = 5 * 1024 * 1024 # 5MB
allowed_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
if photo.size > max_size:
return False, f'Размер файла {photo.name} превышает 5MB.'
ext = os.path.splitext(photo.name)[1].lower()
if ext not in allowed_extensions:
return False, f'Формат файла {ext} не поддерживается. Разрешены: {", ".join(allowed_extensions)}'
return True, None
def handle_photos(request, parent_obj, photo_model, parent_field_name):
"""
Универсальная обработка загружаемых фотографий.
Args:
request: HTTP request с FILES
parent_obj: Родительский объект (Product, ProductKit или ProductCategory)
photo_model: Модель фотографии (ProductPhoto, ProductKitPhoto, ProductCategoryPhoto)
parent_field_name: Имя поля связи в модели фото ('product', 'kit', 'category')
Returns:
Список сообщений об ошибках (пустой список если все ок).
"""
errors = []
photos = request.FILES.getlist('photos')
if not photos:
return errors
if len(photos) > 10:
errors.append('Можно загрузить не более 10 фотографий.')
return errors
# Получаем максимальный order для этого родительского объекта
filter_kwargs = {parent_field_name: parent_obj}
max_order = photo_model.objects.filter(**filter_kwargs).aggregate(
models.Max('order')
)['order__max']
# Если фото нет, начинаем с 0, иначе с max_order + 1
next_order = 0 if max_order is None else max_order + 1
# Валидация и сохранение фото
for photo in photos:
is_valid, error_msg = validate_photo(photo)
if not is_valid:
errors.append(error_msg)
else:
# Создаем фото с правильной связью
create_kwargs = {
parent_field_name: parent_obj,
'image': photo,
'order': next_order
}
photo_model.objects.create(**create_kwargs)
next_order += 1
return errors

View File

@@ -0,0 +1,65 @@
{% comment %}
Компонент кнопок быстрого фильтра по категориям и типам продуктов
Параметры:
- categories: queryset категорий для отображения
- current_category: ID текущей выбранной категории (опционально)
- show_type_filters: показывать ли фильтры типов (по умолчанию True)
{% endcomment %}
<div class="card shadow-sm mb-4">
<div class="card-body">
<h6 class="card-title mb-3">
<i class="bi bi-filter"></i> Быстрые фильтры
</h6>
<!-- Фильтры по типу товара -->
{% if show_type_filters|default:True %}
<div class="mb-3">
<div class="btn-group btn-group-sm" role="group" aria-label="Фильтр по типу">
<a href="{% url 'products:all-products' %}"
class="btn {% if request.resolver_match.url_name == 'all-products' %}btn-primary{% else %}btn-outline-primary{% endif %}">
<i class="bi bi-grid"></i> Все товары
</a>
<a href="{% url 'products:product-list' %}"
class="btn {% if request.resolver_match.url_name == 'product-list' %}btn-success{% else %}btn-outline-success{% endif %}">
<i class="bi bi-box"></i> Только поштучно
</a>
<a href="{% url 'products:productkit-list' %}"
class="btn {% if request.resolver_match.url_name == 'productkit-list' %}btn-info{% else %}btn-outline-info{% endif %}">
<i class="bi bi-box-seam"></i> Только комплекты
</a>
</div>
</div>
{% endif %}
<!-- Фильтры по категориям -->
{% if categories %}
<div>
<label class="form-label text-muted small mb-2">
<i class="bi bi-tags"></i> Категории:
</label>
<div class="d-flex flex-wrap gap-2">
<!-- Кнопка "Все категории" -->
<a href="?{% if request.GET.search %}search={{ request.GET.search }}&{% endif %}{% if request.GET.is_active %}is_active={{ request.GET.is_active }}{% endif %}"
class="btn btn-sm {% if not current_category %}btn-secondary{% else %}btn-outline-secondary{% endif %}">
Все
</a>
<!-- Кнопки категорий -->
{% for category in categories %}
<a href="?category={{ category.id }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.is_active %}&is_active={{ request.GET.is_active }}{% endif %}"
class="btn btn-sm {% if current_category == category.id|stringformat:'s' %}btn-secondary{% else %}btn-outline-secondary{% endif %}">
{{ category.name }}
{% if category.product_set.count or category.productkit_set.count %}
<span class="badge bg-light text-dark">
{{ category.product_set.count|add:category.productkit_set.count }}
</span>
{% endif %}
</a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>

View File

@@ -1,9 +1,6 @@
<!-- navbar.html - Reusable navigation bar component --> <!-- navbar.html - Reusable navigation bar component -->
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top"> <nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top">
<div class="container"> <div class="container">
<!-- Brand/Logo -->
<a class="navbar-brand" href="{% url 'products:product-list' %}">Склад</a>
<!-- Toggler for mobile view --> <!-- Toggler for mobile view -->
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
@@ -14,20 +11,18 @@
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
{% if user.is_authenticated %} {% if user.is_authenticated %}
<!-- Dropdown menu for Products --> <!-- Main navigation links -->
<li class="nav-item dropdown"> <li class="nav-item">
<a class="nav-link dropdown-toggle" href="#" id="productsDropdown" role="button" <a class="nav-link" href="{% url 'products:all-products' %}">Товары</a>
data-bs-toggle="dropdown" aria-expanded="false"> </li>
Товары <li class="nav-item">
</a> <a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Заказы</a>
<ul class="dropdown-menu" aria-labelledby="productsDropdown"> </li>
<li><a class="dropdown-item" href="{% url 'products:product-list' %}">Все товары</a></li> <li class="nav-item">
<li><a class="dropdown-item" href="{% url 'products:category-list' %}">Категории</a></li> <a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Клиенты</a>
<li><a class="dropdown-item" href="{% url 'products:productkit-list' %}">Комплекты</a></li> </li>
<li><hr class="dropdown-divider"></li> <li class="nav-item">
<li><a class="dropdown-item" href="{% url 'products:product-create' %}">Создать товар</a></li> <a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Касса</a>
<li><a class="dropdown-item" href="{% url 'products:category-create' %}">Создать категорию</a></li>
</ul>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>