Обновили шапку и вывод всехтоваров. Добавили фильтры
This commit is contained in:
133
myproject/products/views/README.md
Normal file
133
myproject/products/views/README.md
Normal 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. Создать отдельный модуль для миксинов и базовых классов
|
||||
114
myproject/products/views/__init__.py
Normal file
114
myproject/products/views/__init__.py
Normal 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',
|
||||
]
|
||||
78
myproject/products/views/api_views.py
Normal file
78
myproject/products/views/api_views.py
Normal 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})
|
||||
280
myproject/products/views/category_views.py
Normal file
280
myproject/products/views/category_views.py
Normal 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)
|
||||
310
myproject/products/views/photo_management.py
Normal file
310
myproject/products/views/photo_management.py
Normal 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'
|
||||
)
|
||||
289
myproject/products/views/product_views.py
Normal file
289
myproject/products/views/product_views.py
Normal 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
|
||||
249
myproject/products/views/productkit_views.py
Normal file
249
myproject/products/views/productkit_views.py
Normal 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')
|
||||
73
myproject/products/views/utils.py
Normal file
73
myproject/products/views/utils.py
Normal 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
|
||||
Reference in New Issue
Block a user