Files
octopus/myproject/products/views/configurablekit_views.py
Andrey Smakotin ffc3b0c42d feat: implement role-based permissions for product views
- Add view mixins (RoleRequiredMixin, OwnerRequiredMixin, ManagerOwnerRequiredMixin) to user_roles/mixins.py
- Replace PermissionRequiredMixin with ManagerOwnerRequiredMixin in all product views
- Remove permission_required attributes from view classes
- Owner and Manager roles now grant access without Django model permissions

This allows owners to access all product functionality through their custom role,
without needing to be superusers or have explicit Django permissions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 22:44:36 +03:00

748 lines
33 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.
"""
CRUD представления для вариативных товаров (ConfigurableKitProduct).
"""
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, Prefetch
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
from django.db import transaction
from user_roles.mixins import ManagerOwnerRequiredMixin
from ..models import ConfigurableKitProduct, ConfigurableKitOption, ProductKit, ConfigurableKitProductAttribute
from ..forms import (
ConfigurableKitProductForm,
ConfigurableKitOptionFormSetCreate,
ConfigurableKitOptionFormSetUpdate,
ConfigurableKitProductAttributeFormSetCreate,
ConfigurableKitProductAttributeFormSetUpdate
)
class ConfigurableKitProductListView(LoginRequiredMixin, ManagerOwnerRequiredMixin, ListView):
model = ConfigurableKitProduct
template_name = 'products/configurablekit_list.html'
context_object_name = 'configurable_kits'
paginate_by = 20
def get_queryset(self):
queryset = super().get_queryset().prefetch_related(
Prefetch(
'options',
queryset=ConfigurableKitOption.objects.select_related('kit')
)
)
# Поиск
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)
)
# Фильтр по статусу
status_filter = self.request.GET.get('status')
if status_filter:
queryset = queryset.filter(status=status_filter)
return queryset.order_by('-created_at')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Данные для фильтров
context['filters'] = {
'current': {
'search': self.request.GET.get('search', ''),
'status': self.request.GET.get('status', ''),
}
}
# Кнопки действий
action_buttons = []
if self.request.user.has_perm('products.add_configurablekitproduct'):
action_buttons.append({
'url': reverse_lazy('products:configurablekit-create'),
'text': 'Создать вариативный товар',
'class': 'btn-primary',
'icon': 'plus-circle'
})
context['action_buttons'] = action_buttons
return context
class ConfigurableKitProductDetailView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DetailView):
model = ConfigurableKitProduct
template_name = 'products/configurablekit_detail.html'
context_object_name = 'configurable_kit'
def get_queryset(self):
return super().get_queryset().prefetch_related(
Prefetch(
'options',
queryset=ConfigurableKitOption.objects.select_related('kit').order_by('id')
),
'parent_attributes'
)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Добавляем доступные комплекты для выбора (активные, не временные)
context['available_kits'] = ProductKit.objects.filter(
status='active',
is_temporary=False
).order_by('name')
return context
class ConfigurableKitProductCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, CreateView):
model = ConfigurableKitProduct
form_class = ConfigurableKitProductForm
template_name = 'products/configurablekit_form.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Formset для вариантов
if 'option_formset' in kwargs:
context['option_formset'] = kwargs['option_formset']
elif self.request.POST:
context['option_formset'] = ConfigurableKitOptionFormSetCreate(
self.request.POST,
prefix='options'
)
else:
context['option_formset'] = ConfigurableKitOptionFormSetCreate(
prefix='options'
)
# Formset для атрибутов родителя
if 'attribute_formset' in kwargs:
context['attribute_formset'] = kwargs['attribute_formset']
elif self.request.POST:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate(
self.request.POST,
prefix='attributes'
)
else:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetCreate(
prefix='attributes'
)
# Доступные комплекты для JavaScript (для выбора при добавлении значений атрибутов)
context['available_kits'] = ProductKit.objects.filter(
status='active',
is_temporary=False
).order_by('name')
return context
def form_valid(self, form):
from products.models.kits import ConfigurableKitOptionAttribute
# Пересоздаём formsets с POST данными
option_formset = ConfigurableKitOptionFormSetCreate(
self.request.POST,
prefix='options'
)
attribute_formset = ConfigurableKitProductAttributeFormSetCreate(
self.request.POST,
prefix='attributes'
)
if not form.is_valid():
messages.error(self.request, 'Пожалуйста, исправьте ошибки в основной форме.')
return self.form_invalid(form)
if not option_formset.is_valid():
# Логирование ошибок formset
import logging
logger = logging.getLogger(__name__)
logger.error(f"Option formset errors: {option_formset.errors}")
logger.error(f"Option formset non-form errors: {option_formset.non_form_errors()}")
# Показываем детальные ошибки
error_msg = 'Ошибки в вариантах:\n'
for i, form_errors in enumerate(option_formset.errors):
if form_errors:
error_msg += f' Вариант {i+1}: {form_errors}\n'
if option_formset.non_form_errors():
error_msg += f' Общие ошибки: {option_formset.non_form_errors()}\n'
messages.error(self.request, error_msg)
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
# Валидация что каждый вариант имеет выбранный комплект
validation_errors = self._validate_variant_kits(option_formset)
if validation_errors:
for error in validation_errors:
messages.error(self.request, error)
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
if not attribute_formset.is_valid():
messages.error(self.request, 'Пожалуйста, исправьте ошибки в атрибутах.')
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
try:
with transaction.atomic():
# Сохраняем основную форму
self.object = form.save()
# Сохраняем варианты И их атрибуты
option_formset.instance = self.object
for option_form in option_formset:
if option_form.cleaned_data and not self._should_delete_form(option_form, option_formset):
# Сохраняем сам вариант
option = option_form.save(commit=False)
option.parent = self.object
option.save()
# Очищаем старые атрибуты варианта (если редактируем)
option.attributes_set.all().delete()
# Сохраняем выбранные атрибуты для этого варианта
for field_name, field_value in option_form.cleaned_data.items():
if field_name.startswith('attribute_') and field_value:
ConfigurableKitOptionAttribute.objects.create(
option=option,
attribute=field_value
)
elif self._should_delete_form(option_form, option_formset):
# Удаляем вариант если помечен для удаления
if option_form.instance.pk:
option_form.instance.delete()
# Сохраняем атрибуты родителя - новый интерфейс
# Карточный интерфейс: значения приходят как инлайн input'ы
self._save_attributes_from_cards()
messages.success(self.request, f'Вариативный товар "{self.object.name}" успешно создан!')
return super().form_valid(form)
except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
import traceback
traceback.print_exc()
return self.form_invalid(form)
def _save_attributes_from_cards(self):
"""
Сохранить атрибуты из карточного интерфейса.
Каждая карточка содержит:
- attributes-X-name: название параметра
- attributes-X-position: позиция
- attributes-X-visible: видимость
- attributes-X-DELETE: помечен ли для удаления
- attributes-X-values: JSON массив значений параметра
- attributes-X-kits: JSON массив ID комплектов для каждого значения
"""
import json
from products.models.kits import ProductKit
# Сначала удаляем все старые атрибуты
ConfigurableKitProductAttribute.objects.filter(parent=self.object).delete()
# Получаем количество карточек параметров
total_forms_str = self.request.POST.get('attributes-TOTAL_FORMS', '0')
try:
total_forms = int(total_forms_str)
except (ValueError, TypeError):
total_forms = 0
# Обрабатываем каждую карточку параметра
for idx in range(total_forms):
# Пропускаем если карточка помечена для удаления
delete_key = f'attributes-{idx}-DELETE'
if delete_key in self.request.POST and self.request.POST.get(delete_key):
continue
# Получаем название параметра
name = self.request.POST.get(f'attributes-{idx}-name', '').strip()
if not name:
continue
position = self.request.POST.get(f'attributes-{idx}-position', idx)
try:
position = int(position)
except (ValueError, TypeError):
position = idx
visible = self.request.POST.get(f'attributes-{idx}-visible') == 'on'
# Получаем значения и их привязанные комплекты
values_json = self.request.POST.get(f'attributes-{idx}-values', '[]')
kits_json = self.request.POST.get(f'attributes-{idx}-kits', '[]')
try:
values = json.loads(values_json)
except (json.JSONDecodeError, TypeError):
values = []
try:
kit_ids = json.loads(kits_json)
except (json.JSONDecodeError, TypeError):
kit_ids = []
# Создаём ConfigurableKitProductAttribute для каждого значения
for value_idx, value in enumerate(values):
if value and value.strip():
# Получаем соответствующий ID комплекта
kit_id = kit_ids[value_idx] if value_idx < len(kit_ids) else None
# Приготавливаем параметры создания
create_kwargs = {
'parent': self.object,
'name': name,
'option': value.strip(),
'position': position,
'visible': visible
}
# Добавляем комплект если указан
if kit_id:
try:
kit = ProductKit.objects.get(id=kit_id)
create_kwargs['kit'] = kit
except ProductKit.DoesNotExist:
# Комплект не найден - создаём без привязки
pass
ConfigurableKitProductAttribute.objects.create(**create_kwargs)
def _validate_variant_kits(self, option_formset):
"""
Валидация что каждый вариант имеет выбранный комплект.
Возвращает список ошибок (пустой список если нет ошибок).
"""
errors = []
for idx, option_form in enumerate(option_formset):
# Пропускаем удаленные или пустые формы
if not option_form.cleaned_data or self._should_delete_form(option_form, option_formset):
continue
# Получаем kit_id из POST данных (он там должен быть установлен JavaScript'ом)
kit_id = self.request.POST.get(f'options-{idx}-kit', '').strip()
if not kit_id:
# Пытаемся получить из cleaned_data
kit_id = option_form.cleaned_data.get('kit')
if not kit_id:
# Если у варианта есть выбранные атрибуты, но нет комплекта - это ошибка
has_attributes = any(
option_form.cleaned_data.get(k)
for k in option_form.cleaned_data.keys()
if k.startswith('attribute_')
)
if has_attributes:
# Собираем названия выбранных атрибутов для сообщения об ошибке
selected_attrs = [
str(option_form.cleaned_data.get(k))
for k in option_form.cleaned_data.keys()
if k.startswith('attribute_') and option_form.cleaned_data.get(k)
]
errors.append(
f'Вариант {idx + 1} ({", ".join(selected_attrs)}): '
f'не выбран комплект. Пожалуйста, выберите значения атрибутов которые '
f'привязаны к одному комплекту.'
)
return errors
@staticmethod
def _should_delete_form(form, formset):
"""Проверить должна ли форма быть удалена"""
if not formset.can_delete:
return False
# Проверяем поле DELETE (стандартное имя для formset deletion field)
deletion_field_name = 'DELETE'
if hasattr(formset, 'deletion_field') and hasattr(formset.deletion_field, 'name'):
deletion_field_name = formset.deletion_field.name
return form.cleaned_data.get(deletion_field_name, False)
def get_success_url(self):
return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk})
class ConfigurableKitProductUpdateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, UpdateView):
model = ConfigurableKitProduct
form_class = ConfigurableKitProductForm
template_name = 'products/configurablekit_form.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Formset для вариантов
if 'option_formset' in kwargs:
context['option_formset'] = kwargs['option_formset']
elif self.request.POST:
context['option_formset'] = ConfigurableKitOptionFormSetUpdate(
self.request.POST,
instance=self.object,
prefix='options'
)
else:
context['option_formset'] = ConfigurableKitOptionFormSetUpdate(
instance=self.object,
prefix='options'
)
# Formset для атрибутов родителя
if 'attribute_formset' in kwargs:
context['attribute_formset'] = kwargs['attribute_formset']
elif self.request.POST:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate(
self.request.POST,
instance=self.object,
prefix='attributes'
)
else:
context['attribute_formset'] = ConfigurableKitProductAttributeFormSetUpdate(
instance=self.object,
prefix='attributes'
)
# Доступные комплекты для JavaScript (для выбора при добавлении значений атрибутов)
context['available_kits'] = ProductKit.objects.filter(
status='active',
is_temporary=False
).order_by('name')
return context
def form_valid(self, form):
from products.models.kits import ConfigurableKitOptionAttribute
# Пересоздаём formsets с POST данными
option_formset = ConfigurableKitOptionFormSetUpdate(
self.request.POST,
instance=self.object,
prefix='options'
)
attribute_formset = ConfigurableKitProductAttributeFormSetUpdate(
self.request.POST,
instance=self.object,
prefix='attributes'
)
if not form.is_valid():
messages.error(self.request, 'Пожалуйста, исправьте ошибки в основной форме.')
return self.form_invalid(form)
if not option_formset.is_valid():
# Логирование ошибок formset
import logging
logger = logging.getLogger(__name__)
logger.error(f"Option formset errors: {option_formset.errors}")
logger.error(f"Option formset non-form errors: {option_formset.non_form_errors()}")
# Показываем детальные ошибки
error_msg = 'Ошибки в вариантах:\n'
for i, form_errors in enumerate(option_formset.errors):
if form_errors:
error_msg += f' Вариант {i+1}: {form_errors}\n'
if option_formset.non_form_errors():
error_msg += f' Общие ошибки: {option_formset.non_form_errors()}\n'
messages.error(self.request, error_msg)
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
# Валидация что каждый вариант имеет выбранный комплект
validation_errors = self._validate_variant_kits(option_formset)
if validation_errors:
for error in validation_errors:
messages.error(self.request, error)
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
if not attribute_formset.is_valid():
messages.error(self.request, 'Пожалуйста, исправьте ошибки в атрибутах.')
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
try:
with transaction.atomic():
# Сохраняем основную форму
self.object = form.save()
# Сохраняем варианты И их атрибуты
for option_form in option_formset:
if option_form.cleaned_data and not self._should_delete_form(option_form, option_formset):
# Сохраняем сам вариант
option = option_form.save(commit=False)
option.parent = self.object
option.save()
# Очищаем старые атрибуты варианта
option.attributes_set.all().delete()
# Сохраняем выбранные атрибуты для этого варианта
for field_name, field_value in option_form.cleaned_data.items():
if field_name.startswith('attribute_') and field_value:
ConfigurableKitOptionAttribute.objects.create(
option=option,
attribute=field_value
)
elif self._should_delete_form(option_form, option_formset):
# Удаляем вариант если помечен для удаления
if option_form.instance.pk:
option_form.instance.delete()
# Сохраняем атрибуты родителя - новый интерфейс
# Карточный интерфейс: значения приходят как инлайн input'ы
self._save_attributes_from_cards()
messages.success(self.request, f'Вариативный товар "{self.object.name}" успешно обновлён!')
return super().form_valid(form)
except Exception as e:
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
import traceback
traceback.print_exc()
return self.form_invalid(form)
def _save_attributes_from_cards(self):
"""
Сохранить атрибуты из карточного интерфейса.
Каждая карточка содержит:
- attributes-X-name: название параметра
- attributes-X-position: позиция
- attributes-X-visible: видимость
- attributes-X-DELETE: помечен ли для удаления
- attributes-X-values: JSON массив значений параметра
- attributes-X-kits: JSON массив ID комплектов для каждого значения
"""
import json
from products.models.kits import ProductKit
# Сначала удаляем все старые атрибуты
ConfigurableKitProductAttribute.objects.filter(parent=self.object).delete()
# Получаем количество карточек параметров
total_forms_str = self.request.POST.get('attributes-TOTAL_FORMS', '0')
try:
total_forms = int(total_forms_str)
except (ValueError, TypeError):
total_forms = 0
# Обрабатываем каждую карточку параметра
for idx in range(total_forms):
# Пропускаем если карточка помечена для удаления
delete_key = f'attributes-{idx}-DELETE'
if delete_key in self.request.POST and self.request.POST.get(delete_key):
continue
# Получаем название параметра
name = self.request.POST.get(f'attributes-{idx}-name', '').strip()
if not name:
continue
position = self.request.POST.get(f'attributes-{idx}-position', idx)
try:
position = int(position)
except (ValueError, TypeError):
position = idx
visible = self.request.POST.get(f'attributes-{idx}-visible') == 'on'
# Получаем значения и их привязанные комплекты
values_json = self.request.POST.get(f'attributes-{idx}-values', '[]')
kits_json = self.request.POST.get(f'attributes-{idx}-kits', '[]')
try:
values = json.loads(values_json)
except (json.JSONDecodeError, TypeError):
values = []
try:
kit_ids = json.loads(kits_json)
except (json.JSONDecodeError, TypeError):
kit_ids = []
# Создаём ConfigurableKitProductAttribute для каждого значения
for value_idx, value in enumerate(values):
if value and value.strip():
# Получаем соответствующий ID комплекта
kit_id = kit_ids[value_idx] if value_idx < len(kit_ids) else None
# Приготавливаем параметры создания
create_kwargs = {
'parent': self.object,
'name': name,
'option': value.strip(),
'position': position,
'visible': visible
}
# Добавляем комплект если указан
if kit_id:
try:
kit = ProductKit.objects.get(id=kit_id)
create_kwargs['kit'] = kit
except ProductKit.DoesNotExist:
# Комплект не найден - создаём без привязки
pass
ConfigurableKitProductAttribute.objects.create(**create_kwargs)
def _validate_variant_kits(self, option_formset):
"""
Валидация что каждый вариант имеет выбранный комплект.
Возвращает список ошибок (пустой список если нет ошибок).
"""
errors = []
for idx, option_form in enumerate(option_formset):
# Пропускаем удаленные или пустые формы
if not option_form.cleaned_data or self._should_delete_form(option_form, option_formset):
continue
# Получаем kit_id из POST данных (он там должен быть установлен JavaScript'ом)
kit_id = self.request.POST.get(f'options-{idx}-kit', '').strip()
if not kit_id:
# Пытаемся получить из cleaned_data
kit_id = option_form.cleaned_data.get('kit')
if not kit_id:
# Если у варианта есть выбранные атрибуты, но нет комплекта - это ошибка
has_attributes = any(
option_form.cleaned_data.get(k)
for k in option_form.cleaned_data.keys()
if k.startswith('attribute_')
)
if has_attributes:
# Собираем названия выбранных атрибутов для сообщения об ошибке
selected_attrs = [
str(option_form.cleaned_data.get(k))
for k in option_form.cleaned_data.keys()
if k.startswith('attribute_') and option_form.cleaned_data.get(k)
]
errors.append(
f'Вариант {idx + 1} ({", ".join(selected_attrs)}): '
f'не выбран комплект. Пожалуйста, выберите значения атрибутов которые '
f'привязаны к одному комплекту.'
)
return errors
@staticmethod
def _should_delete_form(form, formset):
"""Проверить должна ли форма быть удалена"""
if not formset.can_delete:
return False
# Проверяем поле DELETE (стандартное имя для formset deletion field)
deletion_field_name = 'DELETE'
if hasattr(formset, 'deletion_field') and hasattr(formset.deletion_field, 'name'):
deletion_field_name = formset.deletion_field.name
return form.cleaned_data.get(deletion_field_name, False)
def get_success_url(self):
return reverse_lazy('products:configurablekit-detail', kwargs={'pk': self.object.pk})
class ConfigurableKitProductDeleteView(LoginRequiredMixin, ManagerOwnerRequiredMixin, DeleteView):
model = ConfigurableKitProduct
template_name = 'products/configurablekit_confirm_delete.html'
success_url = reverse_lazy('products:configurablekit-list')
def form_valid(self, form):
messages.success(self.request, f'Вариативный товар "{self.object.name}" успешно удалён!')
return super().form_valid(form)
# API для управления вариантами
@login_required
@require_POST
def add_option_to_configurable(request, pk):
"""
Добавить вариант (комплект) к вариативному товару.
"""
configurable = get_object_or_404(ConfigurableKitProduct, pk=pk)
kit_id = request.POST.get('kit_id')
attributes = request.POST.get('attributes', '')
is_default = request.POST.get('is_default') == 'true'
if not kit_id:
return JsonResponse({'success': False, 'error': 'Не указан комплект'}, status=400)
try:
kit = ProductKit.objects.get(pk=kit_id)
except ProductKit.DoesNotExist:
return JsonResponse({'success': False, 'error': 'Комплект не найден'}, status=404)
# Проверяем, не добавлен ли уже этот комплект
if ConfigurableKitOption.objects.filter(parent=configurable, kit=kit).exists():
return JsonResponse({'success': False, 'error': 'Этот комплект уже добавлен как вариант'}, status=400)
# Если is_default=True, снимаем флаг с других
if is_default:
ConfigurableKitOption.objects.filter(parent=configurable, is_default=True).update(is_default=False)
# Создаём вариант
option = ConfigurableKitOption.objects.create(
parent=configurable,
kit=kit,
attributes=attributes,
is_default=is_default
)
return JsonResponse({
'success': True,
'option': {
'id': option.id,
'kit_id': kit.id,
'kit_name': kit.name,
'kit_sku': kit.sku or '',
'kit_price': str(kit.actual_price),
'attributes': option.attributes or '',
'is_default': option.is_default,
}
})
@login_required
@require_POST
def remove_option_from_configurable(request, pk, option_id):
"""
Удалить вариант из вариативного товара.
"""
configurable = get_object_or_404(ConfigurableKitProduct, pk=pk)
option = get_object_or_404(ConfigurableKitOption, pk=option_id, parent=configurable)
option.delete()
return JsonResponse({'success': True, 'message': 'Вариант удалён'})
@login_required
@require_POST
def set_option_as_default(request, pk, option_id):
"""
Установить вариант как по умолчанию.
"""
configurable = get_object_or_404(ConfigurableKitProduct, pk=pk)
option = get_object_or_404(ConfigurableKitOption, pk=option_id, parent=configurable)
# Снимаем флаг со всех других
ConfigurableKitOption.objects.filter(parent=configurable).update(is_default=False)
# Устанавливаем текущий
option.is_default = True
option.save(update_fields=['is_default'])
return JsonResponse({'success': True, 'message': 'Вариант установлен как по умолчанию'})