- Добавлен combine_mode в форму создания/редактирования скидок - Добавлена колонка "Объединение" в список скидок с иконками - Добавлен фильтр по режиму объединения скидок - Добавлена валидация: только одна exclusive скидка на заказ - Удалены дублирующие поля из Order и OrderItem: - applied_discount, applied_promo_code, discount_amount - Скидки теперь хранятся только в DiscountApplication - Добавлены свойства для обратной совместимости Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
366 lines
15 KiB
Python
366 lines
15 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
Views для управления скидками и промокодами во фронтенде.
|
||
|
||
Права доступа:
|
||
- owner/manager/superuser - полный CRUD
|
||
- florist - только просмотр
|
||
- courier - нет доступа
|
||
"""
|
||
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, TemplateView
|
||
from django.shortcuts import redirect, get_object_or_404
|
||
from django.contrib import messages
|
||
from django.contrib.auth.decorators import login_required
|
||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||
from django.urls import reverse_lazy
|
||
from django.utils.decorators import method_decorator
|
||
from django.core.exceptions import PermissionDenied
|
||
from django.db.models import Q, Count
|
||
|
||
from user_roles.mixins import RoleRequiredMixin
|
||
from user_roles.services import RoleService
|
||
|
||
from .models import Discount, PromoCode
|
||
|
||
|
||
class DiscountAccessMixin(RoleRequiredMixin):
|
||
"""
|
||
Миксин для контроля доступа к скидкам.
|
||
|
||
- owner/manager/superuser - полный CRUD
|
||
- florist - только просмотр
|
||
- courier - нет доступа
|
||
"""
|
||
required_roles = ['owner', 'manager', 'florist']
|
||
|
||
def dispatch(self, request, *args, **kwargs):
|
||
# Superuser имеет полный доступ
|
||
if request.user.is_superuser:
|
||
return super().dispatch(request, *args, **kwargs)
|
||
|
||
# Проверяем базовый доступ к роли
|
||
if not RoleService.user_has_role(request.user, *self.required_roles):
|
||
raise PermissionDenied("У вас нет прав для доступа к этой странице")
|
||
|
||
# Florist - только просмотр списков
|
||
if RoleService.user_has_role(request.user, 'florist'):
|
||
# Разрешаем только ListView
|
||
if not isinstance(self, ListView):
|
||
raise PermissionDenied("Флористы могут только просматривать скидки")
|
||
|
||
return super().dispatch(request, *args, **kwargs)
|
||
|
||
|
||
class DiscountListView(DiscountAccessMixin, ListView):
|
||
"""Список скидок"""
|
||
model = Discount
|
||
template_name = 'discounts/discount_list.html'
|
||
context_object_name = 'discounts'
|
||
paginate_by = 20
|
||
|
||
def get_queryset(self):
|
||
queryset = Discount.objects.select_related(
|
||
'created_by'
|
||
).prefetch_related(
|
||
'products', 'categories'
|
||
).annotate(
|
||
promo_count=Count('promo_codes')
|
||
).order_by('-created_at')
|
||
|
||
# Фильтры
|
||
discount_type = self.request.GET.get('type')
|
||
scope = self.request.GET.get('scope')
|
||
is_active = self.request.GET.get('is_active')
|
||
combine_mode = self.request.GET.get('combine_mode')
|
||
search = self.request.GET.get('search')
|
||
|
||
if discount_type:
|
||
queryset = queryset.filter(discount_type=discount_type)
|
||
if scope:
|
||
queryset = queryset.filter(scope=scope)
|
||
if is_active:
|
||
queryset = queryset.filter(is_active=(is_active == 'active'))
|
||
if combine_mode:
|
||
queryset = queryset.filter(combine_mode=combine_mode)
|
||
if search:
|
||
queryset = queryset.filter(
|
||
Q(name__icontains=search) | Q(description__icontains=search)
|
||
)
|
||
|
||
return queryset
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
context['current_filters'] = {
|
||
'type': self.request.GET.get('type', ''),
|
||
'scope': self.request.GET.get('scope', ''),
|
||
'is_active': self.request.GET.get('is_active', ''),
|
||
'combine_mode': self.request.GET.get('combine_mode', ''),
|
||
'search': self.request.GET.get('search', ''),
|
||
}
|
||
context['can_edit'] = self._can_edit()
|
||
return context
|
||
|
||
def _can_edit(self):
|
||
"""Проверка прав на редактирование"""
|
||
if self.request.user.is_superuser:
|
||
return True
|
||
return RoleService.user_has_role(self.request.user, 'owner', 'manager')
|
||
|
||
|
||
class DiscountCreateView(LoginRequiredMixin, RoleRequiredMixin, CreateView):
|
||
"""Создание скидки (только owner/manager)"""
|
||
model = Discount
|
||
template_name = 'discounts/discount_form.html'
|
||
fields = [
|
||
'name', 'description', 'discount_type', 'value', 'scope',
|
||
'is_auto', 'is_active', 'priority', 'combine_mode',
|
||
'start_date', 'end_date',
|
||
'min_order_amount', 'max_usage_count',
|
||
'products', 'categories', 'excluded_products',
|
||
]
|
||
required_roles = ['owner', 'manager']
|
||
success_url = reverse_lazy('system_settings:discounts:list')
|
||
|
||
def form_valid(self, form):
|
||
# Валидация: нельзя создать больше одной активной exclusive скидки на заказ
|
||
if form.instance.combine_mode == 'exclusive' and form.instance.scope == 'order' and form.instance.is_active:
|
||
existing_exclusive = Discount.objects.filter(
|
||
combine_mode='exclusive',
|
||
scope='order',
|
||
is_active=True
|
||
).exists()
|
||
|
||
if existing_exclusive:
|
||
form.add_error(
|
||
'combine_mode',
|
||
'Уже существует активная исключающая скидка на заказ. Может быть только одна.'
|
||
)
|
||
return self.form_invalid(form)
|
||
|
||
form.instance.created_by = self.request.user
|
||
messages.success(self.request, f'Скидка "{form.instance.name}" создана')
|
||
return super().form_valid(form)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
context['is_edit'] = False
|
||
# Передаем товары и категории для формы
|
||
from products.models import Product, ProductCategory
|
||
context['all_products'] = Product.objects.filter(status='active').order_by('name')
|
||
context['all_categories'] = ProductCategory.objects.filter(is_active=True).order_by('name')
|
||
context['selected_products'] = []
|
||
context['selected_categories'] = []
|
||
context['excluded_products'] = []
|
||
return context
|
||
|
||
|
||
class DiscountUpdateView(LoginRequiredMixin, RoleRequiredMixin, UpdateView):
|
||
"""Редактирование скидки (только owner/manager)"""
|
||
model = Discount
|
||
template_name = 'discounts/discount_form.html'
|
||
fields = [
|
||
'name', 'description', 'discount_type', 'value', 'scope',
|
||
'is_auto', 'is_active', 'priority', 'combine_mode',
|
||
'start_date', 'end_date',
|
||
'min_order_amount', 'max_usage_count',
|
||
'products', 'categories', 'excluded_products',
|
||
]
|
||
required_roles = ['owner', 'manager']
|
||
success_url = reverse_lazy('system_settings:discounts:list')
|
||
|
||
def form_valid(self, form):
|
||
# Валидация: нельзя создать больше одной активной exclusive скидки на заказ
|
||
if form.instance.combine_mode == 'exclusive' and form.instance.scope == 'order' and form.instance.is_active:
|
||
existing_exclusive = Discount.objects.filter(
|
||
combine_mode='exclusive',
|
||
scope='order',
|
||
is_active=True
|
||
).exclude(pk=self.object.pk).exists()
|
||
|
||
if existing_exclusive:
|
||
form.add_error(
|
||
'combine_mode',
|
||
'Уже существует активная исключающая скидка на заказ. Может быть только одна.'
|
||
)
|
||
return self.form_invalid(form)
|
||
|
||
messages.success(self.request, f'Скидка "{form.instance.name}" обновлена')
|
||
return super().form_valid(form)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
context['is_edit'] = True
|
||
# Передаем товары и категории для формы
|
||
from products.models import Product, ProductCategory
|
||
context['all_products'] = Product.objects.filter(status='active').order_by('name')
|
||
context['all_categories'] = ProductCategory.objects.filter(is_active=True).order_by('name')
|
||
context['selected_products'] = list(self.object.products.values_list('id', flat=True))
|
||
context['selected_categories'] = list(self.object.categories.values_list('id', flat=True))
|
||
context['excluded_products'] = list(self.object.excluded_products.values_list('id', flat=True))
|
||
return context
|
||
|
||
|
||
class DiscountDeleteView(LoginRequiredMixin, RoleRequiredMixin, DeleteView):
|
||
"""Удаление скидки (только owner/manager)"""
|
||
model = Discount
|
||
template_name = 'discounts/discount_confirm_delete.html'
|
||
required_roles = ['owner', 'manager']
|
||
success_url = reverse_lazy('system_settings:discounts:list')
|
||
|
||
def delete(self, request, *args, **kwargs):
|
||
obj = self.get_object()
|
||
messages.success(self.request, f'Скидка "{obj.name}" удалена')
|
||
return super().delete(request, *args, **kwargs)
|
||
|
||
|
||
@login_required
|
||
def discount_toggle(request, pk):
|
||
"""Переключение активности скидки (только owner/manager)"""
|
||
if not request.user.is_superuser and not RoleService.user_has_role(request.user, 'owner', 'manager'):
|
||
raise PermissionDenied("У вас нет прав для этого действия")
|
||
|
||
discount = get_object_or_404(Discount, pk=pk)
|
||
discount.is_active = not discount.is_active
|
||
discount.save(update_fields=['is_active'])
|
||
|
||
status = "активирована" if discount.is_active else "деактивирована"
|
||
messages.success(request, f'Скидка "{discount.name}" {status}')
|
||
return redirect('system_settings:discounts:list')
|
||
|
||
|
||
# ============== Промокоды ==============
|
||
|
||
class PromoCodeAccessMixin(RoleRequiredMixin):
|
||
"""Миксин для контроля доступа к промокодам"""
|
||
required_roles = ['owner', 'manager', 'florist']
|
||
|
||
def dispatch(self, request, *args, **kwargs):
|
||
if request.user.is_superuser:
|
||
return super().dispatch(request, *args, **kwargs)
|
||
|
||
if not RoleService.user_has_role(request.user, *self.required_roles):
|
||
raise PermissionDenied("У вас нет прав для доступа к этой странице")
|
||
|
||
# Florist - только просмотр
|
||
if RoleService.user_has_role(request.user, 'florist'):
|
||
if not isinstance(self, ListView):
|
||
raise PermissionDenied("Флористы могут только просматривать промокоды")
|
||
|
||
return super().dispatch(request, *args, **kwargs)
|
||
|
||
|
||
class PromoCodeListView(PromoCodeAccessMixin, ListView):
|
||
"""Список промокодов"""
|
||
model = PromoCode
|
||
template_name = 'discounts/promocode_list.html'
|
||
context_object_name = 'promocodes'
|
||
paginate_by = 20
|
||
|
||
def get_queryset(self):
|
||
queryset = PromoCode.objects.select_related(
|
||
'discount', 'created_by'
|
||
).order_by('-created_at')
|
||
|
||
# Фильтры
|
||
is_active = self.request.GET.get('is_active')
|
||
search = self.request.GET.get('search')
|
||
|
||
if is_active:
|
||
queryset = queryset.filter(is_active=(is_active == 'active'))
|
||
if search:
|
||
queryset = queryset.filter(code__icontains=search)
|
||
|
||
return queryset
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
context['current_filters'] = {
|
||
'is_active': self.request.GET.get('is_active', ''),
|
||
'search': self.request.GET.get('search', ''),
|
||
}
|
||
context['can_edit'] = self._can_edit()
|
||
return context
|
||
|
||
def _can_edit(self):
|
||
if self.request.user.is_superuser:
|
||
return True
|
||
return RoleService.user_has_role(self.request.user, 'owner', 'manager')
|
||
|
||
|
||
class PromoCodeCreateView(LoginRequiredMixin, RoleRequiredMixin, CreateView):
|
||
"""Создание промокода (только owner/manager)"""
|
||
model = PromoCode
|
||
template_name = 'discounts/promocode_form.html'
|
||
fields = [
|
||
'code', 'discount', 'is_active',
|
||
'start_date', 'end_date',
|
||
'max_uses_per_user', 'max_total_uses',
|
||
]
|
||
required_roles = ['owner', 'manager']
|
||
success_url = reverse_lazy('system_settings:discounts:promo-list')
|
||
|
||
def form_valid(self, form):
|
||
form.instance.created_by = self.request.user
|
||
messages.success(self.request, f'Промокод "{form.instance.code}" создан')
|
||
return super().form_valid(form)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
context['is_edit'] = False
|
||
# Передаем активные скидки для выбора
|
||
context['all_discounts'] = Discount.objects.filter(is_active=True).order_by('name')
|
||
return context
|
||
|
||
|
||
class PromoCodeUpdateView(LoginRequiredMixin, RoleRequiredMixin, UpdateView):
|
||
"""Редактирование промокода (только owner/manager)"""
|
||
model = PromoCode
|
||
template_name = 'discounts/promocode_form.html'
|
||
fields = [
|
||
'code', 'discount', 'is_active',
|
||
'start_date', 'end_date',
|
||
'max_uses_per_user', 'max_total_uses',
|
||
]
|
||
required_roles = ['owner', 'manager']
|
||
success_url = reverse_lazy('system_settings:discounts:promo-list')
|
||
|
||
def form_valid(self, form):
|
||
messages.success(self.request, f'Промокод "{form.instance.code}" обновлён')
|
||
return super().form_valid(form)
|
||
|
||
def get_context_data(self, **kwargs):
|
||
context = super().get_context_data(**kwargs)
|
||
context['is_edit'] = True
|
||
# Передаем все скидки для выбора (включая неактивные, если они выбраны)
|
||
context['all_discounts'] = Discount.objects.all().order_by('name')
|
||
return context
|
||
|
||
|
||
class PromoCodeDeleteView(LoginRequiredMixin, RoleRequiredMixin, DeleteView):
|
||
"""Удаление промокода (только owner/manager)"""
|
||
model = PromoCode
|
||
template_name = 'discounts/promocode_confirm_delete.html'
|
||
required_roles = ['owner', 'manager']
|
||
success_url = reverse_lazy('system_settings:discounts:promo-list')
|
||
|
||
def delete(self, request, *args, **kwargs):
|
||
obj = self.get_object()
|
||
messages.success(self.request, f'Промокод "{obj.code}" удалён')
|
||
return super().delete(request, *args, **kwargs)
|
||
|
||
|
||
@login_required
|
||
def promocode_toggle(request, pk):
|
||
"""Переключение активности промокода (только owner/manager)"""
|
||
if not request.user.is_superuser and not RoleService.user_has_role(request.user, 'owner', 'manager'):
|
||
raise PermissionDenied("У вас нет прав для этого действия")
|
||
|
||
promocode = get_object_or_404(PromoCode, pk=pk)
|
||
promocode.is_active = not promocode.is_active
|
||
promocode.save(update_fields=['is_active'])
|
||
|
||
status = "активирован" if promocode.is_active else "деактивирован"
|
||
messages.success(request, f'Промокод "{promocode.code}" {status}')
|
||
return redirect('system_settings:discounts:promo-list')
|