Files
octopus/myproject/discounts/views.py
Andrey Smakotin c070e42cab feat(discounts, orders): рефакторинг системы скидок - единый источник правды
- Добавлен combine_mode в форму создания/редактирования скидок
- Добавлена колонка "Объединение" в список скидок с иконками
- Добавлен фильтр по режиму объединения скидок
- Добавлена валидация: только одна exclusive скидка на заказ
- Удалены дублирующие поля из Order и OrderItem:
  - applied_discount, applied_promo_code, discount_amount
- Скидки теперь хранятся только в DiscountApplication
- Добавлены свойства для обратной совместимости

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 13:46:02 +03:00

366 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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')