From 9ff1f2d184061870a07228e70b164527b39c4fa3 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Wed, 29 Oct 2025 23:33:11 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=D0=BB=D0=B8=D1=87=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=82=D0=BE=D0=B2=D0=B0=D1=80=D0=BE=D0=B2=20=D0=B8=20=D1=86?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=B2=D0=B0=D1=80=D0=B8=D0=B0=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B2=D0=BE=20=D0=B2=D1=81=D0=B5=20=D1=88=D0=B0?= =?UTF-8?q?=D0=B1=D0=BB=D0=BE=D0=BD=D1=8B=20CRUD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Добавлена визуализация статуса наличия (in_stock) и цены вариантов во все CRUD шаблоны товаров и групп вариантов. Product (товары): - product_list.html: добавлена колонка "В наличии" с бейджами (зелёный/красный) - product_detail.html: добавлена строка "В наличии" в таблицу данных товара - product_form.html: добавлена информационная секция о наличии при редактировании - all_products_list.html: добавлена колонка "В наличии" для товаров - productkit_list.html: обновлены стили бейджей статуса ProductVariantGroup (группы вариантов): - variantgroup_list.html: добавлены колонки "В наличии" и "Цена" в таблицу групп - variantgroup_detail.html: добавлены отображение наличия и цены в информационный блок слева - variantgroup_detail.html: добавлена колонка "В наличии" в таблицу товаров группы - variantgroup_form.html: добавлены отображение артикула, цены и статуса наличия в formset таблице - variantgroup_form.html: добавлен JavaScript код для динамического обновления данных товара при выборе через Select2 - variantgroup_confirm_delete.html: добавлена информация о наличии и цене группы в окно подтверждения удаления Views optimization: - ProductVariantGroupListView: добавлен prefetch_related('items__product') для оптимизации N+1 запросов - Все представления используют оптимизированные запросы для вычисления in_stock и price свойств UI/UX улучшения: - Используются Bootstrap 5 бейджи с иконками (bg-success/bg-danger) - Визуальное выделение статуса наличия через цвет и значки - Информативное отображение цены варианта во всех местах - Динамическое обновление информации при выборе товаров в formset 🤖 Generated with Claude Code Co-Authored-By: Claude --- .../templates/products/all_products_list.html | 15 +- .../templates/products/product_detail.html | 12 +- .../templates/products/product_form.html | 20 ++ .../templates/products/product_list.html | 10 +- .../templates/products/productkit_create.html | 154 ++++++--- .../templates/products/productkit_list.html | 2 +- .../products/variantgroup_confirm_delete.html | 64 ++++ .../products/variantgroup_detail.html | 114 +++++++ .../templates/products/variantgroup_form.html | 323 ++++++++++++++++++ .../templates/products/variantgroup_list.html | 137 ++++++++ .../products/views/variant_group_views.py | 292 ++++++++++++++++ 11 files changed, 1084 insertions(+), 59 deletions(-) create mode 100644 myproject/products/templates/products/variantgroup_confirm_delete.html create mode 100644 myproject/products/templates/products/variantgroup_detail.html create mode 100644 myproject/products/templates/products/variantgroup_form.html create mode 100644 myproject/products/templates/products/variantgroup_list.html create mode 100644 myproject/products/views/variant_group_views.py diff --git a/myproject/products/templates/products/all_products_list.html b/myproject/products/templates/products/all_products_list.html index 64c9ead..6feb73a 100644 --- a/myproject/products/templates/products/all_products_list.html +++ b/myproject/products/templates/products/all_products_list.html @@ -95,6 +95,7 @@ Артикул Категория Цена продажи + В наличии Статус Действия @@ -146,9 +147,21 @@ {{ item.get_sale_price|floatformat:2 }} руб. {% endif %} + + {% if item.item_type == 'product' %} + {% if item.in_stock %} + Да + {% else %} + Нет + {% endif %} + {% else %} + + - + {% endif %} + {% if item.is_active %} - Активен + Активен {% else %} Неактивен {% endif %} diff --git a/myproject/products/templates/products/product_detail.html b/myproject/products/templates/products/product_detail.html index d4025d9..006ec5d 100644 --- a/myproject/products/templates/products/product_detail.html +++ b/myproject/products/templates/products/product_detail.html @@ -161,11 +161,21 @@ Цена продажи: {{ product.sale_price }} руб. + + В наличии: + + {% if product.in_stock %} + Да, в наличии + {% else %} + Нет, закончился + {% endif %} + + Статус: {% if product.is_active %} - Активен + Активен {% else %} Неактивен {% endif %} diff --git a/myproject/products/templates/products/product_form.html b/myproject/products/templates/products/product_form.html index 6c69bf1..7353130 100644 --- a/myproject/products/templates/products/product_form.html +++ b/myproject/products/templates/products/product_form.html @@ -138,6 +138,26 @@
+ + {% if object %} +
+
Информация о наличии
+

+ Статус: + {% if object.in_stock %} + В наличии + {% else %} + Нет в наличии + {% endif %} +

+

+ Статус обновляется автоматически на основе остатков на складе +

+
+ {% endif %} + +
+
Фотографии
diff --git a/myproject/products/templates/products/product_list.html b/myproject/products/templates/products/product_list.html index 3261c98..bf8237a 100644 --- a/myproject/products/templates/products/product_list.html +++ b/myproject/products/templates/products/product_list.html @@ -19,6 +19,7 @@ Артикул Категория Цена продажи + В наличии Статус Действия @@ -50,9 +51,16 @@ {% endif %} {{ product.sale_price }} руб. + + {% if product.in_stock %} + В наличии + {% else %} + Нет + {% endif %} + {% if product.is_active %} - Активен + Активен {% else %} Неактивен {% endif %} diff --git a/myproject/products/templates/products/productkit_create.html b/myproject/products/templates/products/productkit_create.html index bf23913..4e9ad26 100644 --- a/myproject/products/templates/products/productkit_create.html +++ b/myproject/products/templates/products/productkit_create.html @@ -326,75 +326,93 @@ animation: slideIn 0.3s ease-out; } +/* Разделитель ИЛИ между полями товара и вариантов */ +.kit-item-separator { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + min-height: 40px; +} + +.kit-item-separator .separator-text { + font-size: 0.75rem; + font-weight: 600; + color: #adb5bd; + text-transform: uppercase; + letter-spacing: 1px; + white-space: nowrap; +} + +.kit-item-separator .separator-help { + font-size: 0.85rem; + color: #6c757d; + cursor: help; + transition: color 0.2s; +} + +.kit-item-separator .separator-help:hover { + color: #0d6efd; +} + +/* Bootstrap Tooltip стили */ +.tooltip-inner { + background-color: #2c3e50; + border-radius: 0.375rem; + padding: 0.5rem 0.75rem; + font-size: 0.85rem; + max-width: 250px; + text-align: left; +} + +.tooltip-arrow::before { + border-top-color: #2c3e50 !important; +} + /* Адаптивность */ @media (max-width: 991px) { .col-lg-8, .col-lg-4 { max-width: 100%; } + + /* Для мобильных устройств делаем разделитель более компактным */ + .kit-item-separator { + min-height: 35px; + } + + .kit-item-separator .separator-text { + font-size: 0.7rem; + letter-spacing: 0.5px; + } } + +{% include 'products/includes/select2-product-init.html' %} + + + + + +{% include 'products/includes/select2-product-init.html' %} + + +{% endblock %} diff --git a/myproject/products/templates/products/variantgroup_list.html b/myproject/products/templates/products/variantgroup_list.html new file mode 100644 index 0000000..b56729a --- /dev/null +++ b/myproject/products/templates/products/variantgroup_list.html @@ -0,0 +1,137 @@ +{% extends 'base.html' %} +{% load static %} + +{% block title %}Группы вариантов{% endblock %} + +{% block content %} +
+ + + +
+

Группы вариантов

+ + Создать группу + +
+ + +
+
+
+
+ +
+
+ +
+
+
+
+ + +
+
+ + + + + + + + + + + + + + {% for group in variant_groups %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
НазваниеОписаниеТоваровВ наличииЦенаДата обновленияДействия
{{ group.name }} + + {{ group.description|truncatewords:10 }} + + + {{ group.items_count }} + + {% if group.in_stock %} + Да + {% else %} + Нет + {% endif %} + + {% if group.price %} + {{ group.price }} руб. + {% else %} + - + {% endif %} + {{ group.updated_at|date:"d.m.Y H:i" }} + + + + + + + + + +
+ Нет групп вариантов. Создать первую +
+
+
+ + + {% if is_paginated %} + + {% endif %} +
+{% endblock %} diff --git a/myproject/products/views/variant_group_views.py b/myproject/products/views/variant_group_views.py new file mode 100644 index 0000000..405f228 --- /dev/null +++ b/myproject/products/views/variant_group_views.py @@ -0,0 +1,292 @@ +""" +CRUD представления для групп вариантов товаров (ProductVariantGroup). +Включает управление товарами в группе с приоритизацией. +""" +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.decorators import login_required +from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView +from django.urls import reverse_lazy, reverse +from django.shortcuts import redirect, get_object_or_404 +from django.http import JsonResponse +from django.db.models import Q, Count +from django.db import transaction +from django.views.decorators.http import require_http_methods + +from ..models import ProductVariantGroup, ProductVariantGroupItem +from ..forms import ( + ProductVariantGroupForm, + ProductVariantGroupItemFormSetCreate, + ProductVariantGroupItemFormSetUpdate +) + + +class ProductVariantGroupListView(LoginRequiredMixin, ListView): + """Список всех групп вариантов с поиском и фильтрацией""" + model = ProductVariantGroup + template_name = 'products/variantgroup_list.html' + context_object_name = 'variant_groups' + paginate_by = 20 + + def get_queryset(self): + queryset = super().get_queryset() + + # Оптимизация: загружаем товары для вычисления in_stock и price + queryset = queryset.prefetch_related('items__product') + + # Добавляем количество товаров в каждую группу + queryset = queryset.annotate(items_count=Count('items')) + + # Поиск по названию и описанию + search_query = self.request.GET.get('search') + if search_query: + queryset = queryset.filter( + Q(name__icontains=search_query) | + Q(description__icontains=search_query) + ) + + return queryset.order_by('-updated_at') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['search_query'] = self.request.GET.get('search', '') + return context + + +class ProductVariantGroupCreateView(LoginRequiredMixin, CreateView): + """Создание новой группы вариантов с добавлением товаров""" + model = ProductVariantGroup + form_class = ProductVariantGroupForm + template_name = 'products/variantgroup_form.html' + success_url = reverse_lazy('products:variantgroup-list') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.request.POST: + context['items_formset'] = ProductVariantGroupItemFormSetCreate( + self.request.POST, + prefix='items' + ) + else: + context['items_formset'] = ProductVariantGroupItemFormSetCreate( + prefix='items' + ) + context['is_create'] = True + return context + + def form_valid(self, form): + """Сохраняем группу и товары в одной транзакции""" + items_formset = ProductVariantGroupItemFormSetCreate( + self.request.POST, + prefix='items' + ) + + # Проверяем валидность основной формы и формсета + if not form.is_valid() or not items_formset.is_valid(): + return self.form_invalid(form) + + try: + with transaction.atomic(): + # Сохраняем группу + self.object = form.save(commit=True) + + # Сохраняем товары + items_formset.instance = self.object + items_formset.save() + + # Пересчитываем приоритеты + self._recalculate_priorities(self.object) + + messages.success( + self.request, + f'Группа вариантов "{self.object.name}" успешно создана!' + ) + return redirect('products:variantgroup-list') + except Exception as e: + messages.error(self.request, f'Ошибка при сохранении: {str(e)}') + return self.form_invalid(form) + + @staticmethod + def _recalculate_priorities(variant_group): + """Пересчитывает приоритеты товаров в группе (по порядку в БД)""" + items = variant_group.items.all().order_by('id') + for idx, item in enumerate(items, start=1): + item.priority = idx + item.save(update_fields=['priority']) + + +class ProductVariantGroupDetailView(LoginRequiredMixin, DetailView): + """Просмотр группы вариантов с список товаров в ней""" + model = ProductVariantGroup + template_name = 'products/variantgroup_detail.html' + context_object_name = 'variant_group' + + def get_queryset(self): + queryset = super().get_queryset() + return queryset.prefetch_related('items__product') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Получаем товары с приоритетами + context['items'] = self.object.items.all().select_related('product').order_by('priority') + return context + + +class ProductVariantGroupUpdateView(LoginRequiredMixin, UpdateView): + """Редактирование группы вариантов и управление товарами в ней""" + model = ProductVariantGroup + form_class = ProductVariantGroupForm + template_name = 'products/variantgroup_form.html' + success_url = reverse_lazy('products:variantgroup-list') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + if self.request.POST: + context['items_formset'] = ProductVariantGroupItemFormSetUpdate( + self.request.POST, + instance=self.object, + prefix='items' + ) + else: + context['items_formset'] = ProductVariantGroupItemFormSetUpdate( + instance=self.object, + prefix='items' + ) + context['is_create'] = False + return context + + def form_valid(self, form): + """Сохраняем изменения группы и товаров""" + items_formset = ProductVariantGroupItemFormSetUpdate( + self.request.POST, + instance=self.object, + prefix='items' + ) + + if not form.is_valid() or not items_formset.is_valid(): + return self.form_invalid(form) + + try: + with transaction.atomic(): + self.object = form.save(commit=True) + items_formset.instance = self.object + items_formset.save() + + # Пересчитываем приоритеты после редактирования + self._recalculate_priorities(self.object) + + messages.success( + self.request, + f'Группа вариантов "{self.object.name}" успешно обновлена!' + ) + return redirect('products:variantgroup-list') + except Exception as e: + messages.error(self.request, f'Ошибка при сохранении: {str(e)}') + return self.form_invalid(form) + + @staticmethod + def _recalculate_priorities(variant_group): + """Пересчитывает приоритеты товаров в группе""" + items = variant_group.items.all().order_by('id') + for idx, item in enumerate(items, start=1): + item.priority = idx + item.save(update_fields=['priority']) + + +class ProductVariantGroupDeleteView(LoginRequiredMixin, DeleteView): + """Удаление группы вариантов с подтверждением""" + model = ProductVariantGroup + template_name = 'products/variantgroup_confirm_delete.html' + success_url = reverse_lazy('products:variantgroup-list') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Добавляем информацию о товарах в группе + context['items'] = self.object.items.all().select_related('product').order_by('priority') + context['items_count'] = context['items'].count() + return context + + def delete(self, request, *args, **kwargs): + """Удаляем группу и показываем сообщение об успехе""" + group_name = self.get_object().name + response = super().delete(request, *args, **kwargs) + messages.success(request, f'Группа вариантов "{group_name}" успешно удалена!') + return response + + +@require_http_methods(["POST"]) +@login_required +def product_variant_group_item_move(request, item_id, direction): + """ + AJAX view для перемещения товара вверх/вниз в группе (UP/DOWN кнопки). + Перемещает товар вверх (up) или вниз (down) в списке приоритетов. + Автоматически пересчитывает приоритеты. + """ + try: + # Получаем товар в группе + item = get_object_or_404(ProductVariantGroupItem, pk=item_id) + variant_group = item.variant_group + + # Получаем все товары в группе отсортированные по приоритету + all_items = list( + variant_group.items.all() + .order_by('priority') + .values_list('id', 'priority') + ) + + # Находим текущую позицию товара + current_index = next( + (idx for idx, (item_pk, _) in enumerate(all_items) if item_pk == item_id), + None + ) + + if current_index is None: + return JsonResponse({'error': 'Товар не найден'}, status=404) + + # Перемещаем товар + if direction == 'up' and current_index > 0: + # Меняем местами с товаром выше + all_items[current_index], all_items[current_index - 1] = \ + all_items[current_index - 1], all_items[current_index] + elif direction == 'down' and current_index < len(all_items) - 1: + # Меняем местами с товаром ниже + all_items[current_index], all_items[current_index + 1] = \ + all_items[current_index + 1], all_items[current_index] + else: + # Товар уже в крайней позиции + return JsonResponse({ + 'error': f'Товар уже в крайней позиции', + 'items': _get_items_data(variant_group) + }, status=400) + + # Пересчитываем приоритеты + with transaction.atomic(): + for idx, (item_pk, _) in enumerate(all_items, start=1): + ProductVariantGroupItem.objects.filter(pk=item_pk).update(priority=idx) + + # Возвращаем обновленный список товаров + return JsonResponse({ + 'success': True, + 'message': f'Товар перемещен', + 'items': _get_items_data(variant_group) + }) + + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + +def _get_items_data(variant_group): + """Возвращает данные о товарах для обновления таблицы""" + items = variant_group.items.all().select_related('product').order_by('priority') + items_data = [] + for item in items: + items_data.append({ + 'id': item.id, + 'product_name': item.product.name, + 'product_sku': item.product.sku, + 'product_price': str(item.product.sale_price), + 'priority': item.priority, + 'can_move_up': item.priority > 1, + 'can_move_down': item.priority < items.count() + }) + return items_data