feat(discounts): добавлен CRUD интерфейс для скидок в настройках

- Добавлена вкладка "Скидки" в страницу настроек
- Созданы views для управления скидками и промокодами с проверкой прав:
  * owner/manager/superuser - полный CRUD
  * florist - только просмотр
  * courier - нет доступа
- Созданы шаблоны: список скидок, форма, подтверждение удаления
- Созданы шаблоны: список промокодов, форма, подтверждение удаления
- Добавлены фильтры по типу, области действия, активности
- Добавлена пагинация

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 01:18:26 +03:00
parent f50b47736d
commit b48e6c810d
10 changed files with 1169 additions and 2 deletions

View File

@@ -0,0 +1,331 @@
# -*- 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')
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 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', ''),
'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',
'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):
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(is_active=True).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',
'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):
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(is_active=True).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')