542 lines
24 KiB
Python
542 lines
24 KiB
Python
"""
|
||
CRUD представления для товаров (Product).
|
||
"""
|
||
from django.contrib import messages
|
||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
|
||
from django.urls import reverse_lazy
|
||
from django.db.models import Q, Sum, Value, DecimalField
|
||
from django.db.models.functions import Coalesce
|
||
from itertools import chain
|
||
|
||
from ..models import Product, ProductCategory, ProductTag, ProductKit
|
||
from ..forms import ProductForm, ProductSalesUnitFormSet
|
||
from .utils import handle_photos
|
||
from ..models import ProductPhoto
|
||
from user_roles.mixins import ManagerOwnerRequiredMixin
|
||
|
||
|
||
class ProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
|
||
model = Product
|
||
template_name = 'products/product_list.html'
|
||
context_object_name = 'products'
|
||
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)
|
||
|
||
# Фильтр по статусу (новая система)
|
||
status_filter = self.request.GET.get('status')
|
||
if status_filter:
|
||
queryset = queryset.filter(status=status_filter)
|
||
else:
|
||
# Фильтр по is_active для обратной совместимости (старая система)
|
||
is_active_filter = self.request.GET.get('is_active')
|
||
if is_active_filter == '1':
|
||
queryset = queryset.filter(status='active')
|
||
elif is_active_filter == '0':
|
||
queryset = queryset.filter(status__in=['archived', 'discontinued'])
|
||
|
||
# Фильтр по тегам
|
||
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.filter(is_active=True),
|
||
'current': {
|
||
'search': self.request.GET.get('search', ''),
|
||
'category': self.request.GET.get('category', ''),
|
||
'status': self.request.GET.get('status', ''),
|
||
'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'
|
||
})
|
||
|
||
context['action_buttons'] = action_buttons
|
||
|
||
return context
|
||
|
||
|
||
class ProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateView):
|
||
model = Product
|
||
form_class = ProductForm
|
||
template_name = 'products/product_form.html'
|
||
|
||
def get_success_url(self):
|
||
return reverse_lazy('products:products-list')
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
if self.request.POST:
|
||
context['sales_unit_formset'] = ProductSalesUnitFormSet(
|
||
self.request.POST,
|
||
prefix='sales_units'
|
||
)
|
||
else:
|
||
context['sales_unit_formset'] = ProductSalesUnitFormSet(prefix='sales_units')
|
||
return context
|
||
|
||
def form_valid(self, form):
|
||
from django.db import IntegrityError
|
||
|
||
context = self.get_context_data()
|
||
sales_unit_formset = context['sales_unit_formset']
|
||
|
||
try:
|
||
# Сначала сохраняем товар
|
||
self.object = form.save()
|
||
|
||
# Затем сохраняем единицы продажи
|
||
if sales_unit_formset.is_valid():
|
||
sales_unit_formset.instance = self.object
|
||
sales_unit_formset.save()
|
||
else:
|
||
# Если formset невалиден, показываем ошибки
|
||
for error in sales_unit_formset.errors:
|
||
if error:
|
||
messages.warning(self.request, f'Ошибка в единицах продажи: {error}')
|
||
|
||
# Обработка загрузки фотографий
|
||
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
||
if photo_errors:
|
||
for error in photo_errors:
|
||
# Если это предупреждение о лимите фото - warning, иначе - error
|
||
if 'Загружено' in error and 'обработано только' in error:
|
||
messages.warning(self.request, error)
|
||
else:
|
||
messages.error(self.request, error)
|
||
|
||
messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!')
|
||
return super().form_valid(form)
|
||
|
||
except IntegrityError as e:
|
||
# Обработка ошибки дублирования slug'а или других unique constraints
|
||
error_msg = str(e).lower()
|
||
if 'slug' in error_msg or 'duplicate key' in error_msg:
|
||
messages.error(
|
||
self.request,
|
||
f'Ошибка: товар с названием "{form.instance.name}" уже существует. '
|
||
'Пожалуйста, используйте другое название.'
|
||
)
|
||
else:
|
||
messages.error(
|
||
self.request,
|
||
'Ошибка при сохранении товара. Пожалуйста, проверьте введённые данные.'
|
||
)
|
||
|
||
# Перенаправляем обратно на форму с сохранённой информацией
|
||
return self.form_invalid(form)
|
||
|
||
|
||
class ProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailView):
|
||
model = Product
|
||
template_name = 'products/product_detail.html'
|
||
context_object_name = 'product'
|
||
|
||
def get_queryset(self):
|
||
# Предзагрузка фотографий, категорий, тегов и аннотация остатков
|
||
total_available = Coalesce(Sum('stocks__quantity_available'), Value(0), output_field=DecimalField())
|
||
total_reserved = Coalesce(Sum('stocks__quantity_reserved'), Value(0), output_field=DecimalField())
|
||
return super().get_queryset().prefetch_related(
|
||
'photos',
|
||
'categories',
|
||
'tags',
|
||
'sales_units'
|
||
).annotate(
|
||
total_available=total_available,
|
||
total_reserved=total_reserved,
|
||
total_free=total_available - total_reserved,
|
||
)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
# Добавляем фотографии товара в контекст (используем уже загруженные через prefetch_related)
|
||
product_photos = list(self.object.photos.all())
|
||
product_photos.sort(key=lambda x: (x.order, x.created_at))
|
||
context['product_photos'] = product_photos
|
||
context['photos_count'] = len(product_photos)
|
||
|
||
# Кешируем cost_price_details, чтобы не делать множественные запросы к БД
|
||
from ..services.cost_calculator import ProductCostCalculator
|
||
context['cost_price_details'] = ProductCostCalculator.get_cost_details(self.object)
|
||
|
||
# Единицы продажи (активные, отсортированные)
|
||
context['sales_units'] = self.object.sales_units.filter(is_active=True).order_by('position', 'name')
|
||
|
||
# Комплекты, в которых этот товар используется как единица продажи
|
||
context['kit_items_using_sales_units'] = self.object.kit_items_using_as_sales_unit.select_related('kit', 'sales_unit').prefetch_related('kit__photos')
|
||
|
||
# Комплекты, в которых этот товар используется напрямую
|
||
context['kit_items_using_products'] = self.object.kit_items_direct.select_related('kit').prefetch_related('kit__photos')
|
||
|
||
# Комплекты, в которых этот товар используется как часть группы вариантов
|
||
context['variant_group_kit_items'] = self.object.variant_group_items.select_related('variant_group').prefetch_related('variant_group__kit_items__kit__photos')
|
||
|
||
return context
|
||
|
||
|
||
class ProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateView):
|
||
model = Product
|
||
form_class = ProductForm
|
||
template_name = 'products/product_form.html'
|
||
|
||
def get_success_url(self):
|
||
return reverse_lazy('products:products-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()
|
||
|
||
# Добавляем formset единиц продажи
|
||
if self.request.POST:
|
||
context['sales_unit_formset'] = ProductSalesUnitFormSet(
|
||
self.request.POST,
|
||
instance=self.object,
|
||
prefix='sales_units'
|
||
)
|
||
else:
|
||
context['sales_unit_formset'] = ProductSalesUnitFormSet(
|
||
instance=self.object,
|
||
prefix='sales_units'
|
||
)
|
||
return context
|
||
|
||
def form_valid(self, form):
|
||
from django.db import IntegrityError
|
||
|
||
context = self.get_context_data()
|
||
sales_unit_formset = context['sales_unit_formset']
|
||
|
||
try:
|
||
# Сначала сохраняем товар
|
||
self.object = form.save()
|
||
|
||
# Затем сохраняем единицы продажи
|
||
if sales_unit_formset.is_valid():
|
||
sales_unit_formset.instance = self.object
|
||
sales_unit_formset.save()
|
||
else:
|
||
# Если formset невалиден, показываем ошибки
|
||
for error in sales_unit_formset.errors:
|
||
if error:
|
||
messages.warning(self.request, f'Ошибка в единицах продажи: {error}')
|
||
|
||
# Обработка загрузки фотографий
|
||
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
||
if photo_errors:
|
||
for error in photo_errors:
|
||
# Если это предупреждение о лимите фото - warning, иначе - error
|
||
if 'Загружено' in error and 'обработано только' in error:
|
||
messages.warning(self.request, error)
|
||
else:
|
||
messages.error(self.request, error)
|
||
|
||
messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!')
|
||
return super().form_valid(form)
|
||
|
||
except IntegrityError as e:
|
||
# Обработка ошибки дублирования slug'а или других unique constraints
|
||
error_msg = str(e).lower()
|
||
if 'slug' in error_msg or 'duplicate key' in error_msg:
|
||
messages.error(
|
||
self.request,
|
||
f'Ошибка: товар с названием "{form.instance.name}" уже существует. '
|
||
'Пожалуйста, используйте другое название.'
|
||
)
|
||
else:
|
||
messages.error(
|
||
self.request,
|
||
'Ошибка при сохранении товара. Пожалуйста, проверьте введённые данные.'
|
||
)
|
||
|
||
# Перенаправляем обратно на форму с сохранённой информацией
|
||
return self.form_invalid(form)
|
||
|
||
|
||
class ProductDeleteView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DeleteView):
|
||
model = Product
|
||
template_name = 'products/product_confirm_delete.html'
|
||
context_object_name = 'product'
|
||
|
||
def get_success_url(self):
|
||
messages.success(self.request, f'Товар "{self.object.name}" успешно удален!')
|
||
return reverse_lazy('products:products-list')
|
||
|
||
|
||
class CombinedProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
|
||
"""
|
||
Объединенное представление для товаров и комплектов.
|
||
Показывает оба типа продуктов в одном списке с возможностью фильтрации по типу.
|
||
"""
|
||
template_name = 'products/products_list.html'
|
||
context_object_name = 'items'
|
||
paginate_by = 20
|
||
|
||
def get_paginate_by(self, queryset):
|
||
"""
|
||
Динамическое определение количества элементов на странице.
|
||
Читает параметр per_page из GET-запроса.
|
||
"""
|
||
per_page = self.request.GET.get('per_page', '20')
|
||
try:
|
||
per_page_int = int(per_page)
|
||
# Разрешаем только значения 20, 50, 100
|
||
if per_page_int in [20, 50, 100]:
|
||
return per_page_int
|
||
except (ValueError, TypeError):
|
||
pass
|
||
# По умолчанию возвращаем 20
|
||
return 20
|
||
|
||
def get_queryset(self):
|
||
# Получаем фильтр по типу
|
||
type_filter = self.request.GET.get('type', 'all')
|
||
|
||
# Получаем товары и комплекты (только постоянные комплекты)
|
||
# Аннотируем товары данными об остатках из агрегированной таблицы Stock
|
||
total_available = Coalesce(Sum('stocks__quantity_available'), Value(0), output_field=DecimalField())
|
||
total_reserved = Coalesce(Sum('stocks__quantity_reserved'), Value(0), output_field=DecimalField())
|
||
products = Product.objects.prefetch_related('categories', 'photos', 'tags').annotate(
|
||
total_available=total_available,
|
||
total_reserved=total_reserved,
|
||
total_free=total_available - total_reserved,
|
||
)
|
||
kits = ProductKit.objects.filter(is_temporary=False).prefetch_related('categories', 'photos', 'tags')
|
||
|
||
# Применяем фильтры
|
||
search_query = self.request.GET.get('search')
|
||
category_id = self.request.GET.get('category')
|
||
status_filter = self.request.GET.get('status')
|
||
is_active_filter = self.request.GET.get('is_active')
|
||
in_stock_filter = self.request.GET.get('in_stock')
|
||
tags = self.request.GET.getlist('tags')
|
||
|
||
# Фильтрация по поиску
|
||
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 status_filter:
|
||
products = products.filter(status=status_filter)
|
||
kits = kits.filter(status=status_filter)
|
||
else:
|
||
# Фильтрация по is_active для обратной совместимости (старая система)
|
||
if is_active_filter == '1':
|
||
products = products.filter(status='active')
|
||
kits = kits.filter(status='active')
|
||
elif is_active_filter == '0':
|
||
products = products.filter(status__in=['archived', 'discontinued'])
|
||
kits = kits.filter(status__in=['archived', 'discontinued'])
|
||
|
||
# Фильтрация по наличию (только для товаров)
|
||
if in_stock_filter == '1':
|
||
products = products.filter(in_stock=True)
|
||
elif in_stock_filter == '0':
|
||
products = products.filter(in_stock=False)
|
||
|
||
# Фильтрация по тегам
|
||
if tags:
|
||
products = products.filter(tags__id__in=tags).distinct()
|
||
kits = kits.filter(tags__id__in=tags).distinct()
|
||
|
||
# Фильтр по цене
|
||
price_from = self.request.GET.get('price_from')
|
||
price_to = self.request.GET.get('price_to')
|
||
if price_from:
|
||
products = products.filter(
|
||
Q(sale_price__gte=price_from) | Q(price__gte=price_from)
|
||
).distinct()
|
||
kits = kits.filter(
|
||
Q(sale_price__gte=price_from) | Q(price__gte=price_from)
|
||
).distinct()
|
||
if price_to:
|
||
products = products.filter(
|
||
Q(sale_price__lte=price_to) | Q(price__lte=price_to)
|
||
).distinct()
|
||
kits = kits.filter(
|
||
Q(sale_price__lte=price_to) | Q(price__lte=price_to)
|
||
).distinct()
|
||
|
||
# Маркетинговые фильтры (OR логика — объединяются через Q)
|
||
marketing_filters = Q()
|
||
if self.request.GET.get('is_new') == '1':
|
||
marketing_filters |= Q(is_new=True)
|
||
if self.request.GET.get('is_popular') == '1':
|
||
marketing_filters |= Q(is_popular=True)
|
||
if self.request.GET.get('is_special') == '1':
|
||
marketing_filters |= Q(is_special=True)
|
||
if marketing_filters:
|
||
products = products.filter(marketing_filters)
|
||
kits = kits.filter(marketing_filters)
|
||
|
||
# Фильтр по скидке
|
||
has_discount = self.request.GET.get('has_discount')
|
||
if has_discount == '1':
|
||
products = products.filter(sale_price__gt=0)
|
||
kits = kits.filter(sale_price__gt=0)
|
||
|
||
# Применяем фильтр по типу
|
||
products_list = []
|
||
kits_list = []
|
||
|
||
if type_filter in ['all', 'products']:
|
||
products_list = list(products.order_by('-created_at'))
|
||
for p in products_list:
|
||
p.item_type = 'product'
|
||
|
||
if type_filter in ['all', 'kits']:
|
||
kits_list = list(kits.order_by('-created_at'))
|
||
for k in kits_list:
|
||
k.item_type = 'kit'
|
||
# Рассчитываем доступное количество комплектов (для отображения остатков)
|
||
k.total_free = k.calculate_available_quantity()
|
||
|
||
# Объединяем и сортируем по дате создания
|
||
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)
|
||
|
||
# Получаем список статусов из модели
|
||
from ..models.base import BaseProductEntity
|
||
item_statuses = BaseProductEntity.STATUS_CHOICES
|
||
|
||
# Получаем состояние интеграции Recommerce
|
||
from integrations.models import RecommerceIntegration
|
||
recommerce_integration = RecommerceIntegration.objects.first()
|
||
context['recommerce_integration_enabled'] = recommerce_integration.is_active if recommerce_integration else False
|
||
|
||
# Данные для фильтров
|
||
context['filters'] = {
|
||
'categories': ProductCategory.objects.filter(is_active=True),
|
||
'tags': ProductTag.objects.filter(is_active=True),
|
||
'current': {
|
||
'search': self.request.GET.get('search', ''),
|
||
'category': self.request.GET.get('category', ''),
|
||
'status': self.request.GET.get('status', ''),
|
||
'type': self.request.GET.get('type', 'all'),
|
||
'in_stock': self.request.GET.get('in_stock', ''),
|
||
'tags': [int(tag) for tag in self.request.GET.getlist('tags') if tag.isdigit()],
|
||
'per_page': self.request.GET.get('per_page', '20'),
|
||
'price_from': self.request.GET.get('price_from', ''),
|
||
'price_to': self.request.GET.get('price_to', ''),
|
||
'is_new': self.request.GET.get('is_new', ''),
|
||
'is_popular': self.request.GET.get('is_popular', ''),
|
||
'is_special': self.request.GET.get('is_special', ''),
|
||
'has_discount': self.request.GET.get('has_discount', ''),
|
||
}
|
||
}
|
||
|
||
context['item_statuses'] = item_statuses
|
||
|
||
# Кнопки действий
|
||
# Проверяем права через has_perm, который использует наш RoleBasedPermissionBackend
|
||
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'
|
||
})
|
||
|
||
if self.request.user.has_perm('products.add_configurableproduct'):
|
||
action_buttons.append({
|
||
'url': reverse_lazy('products:configurableproduct-create'),
|
||
'text': 'Создать вариативный товар',
|
||
'class': 'btn-outline-success',
|
||
'icon': 'grid-3x3-gap'
|
||
})
|
||
|
||
if self.request.user.has_perm('products.add_productvariantgroup'):
|
||
action_buttons.append({
|
||
'url': reverse_lazy('products:variantgroup-create'),
|
||
'text': 'Создать группу вариантов',
|
||
'class': 'btn-outline-info',
|
||
'icon': 'collection'
|
||
})
|
||
|
||
context['action_buttons'] = action_buttons
|
||
|
||
return context
|