Обновили шапку и вывод всехтоваров. Добавили фильтры
This commit is contained in:
242
myproject/products/templates/products/all_products_list.html
Normal file
242
myproject/products/templates/products/all_products_list.html
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
|
||||||
|
{% block title %}Все товары и комплекты{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mt-5">
|
||||||
|
<h2 class="mb-4">Товары</h2>
|
||||||
|
|
||||||
|
<!-- Панель быстрых фильтров по категориям -->
|
||||||
|
{% include 'components/category_filter_buttons.html' with categories=filters.categories current_category=filters.current.category show_type_filters=True %}
|
||||||
|
|
||||||
|
<!-- Панель фильтрации и действий -->
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Кнопки действий -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap">
|
||||||
|
<h5 class="card-title mb-0 me-3">
|
||||||
|
<i class="bi bi-funnel-fill"></i> Поиск и фильтры
|
||||||
|
</h5>
|
||||||
|
|
||||||
|
{% if action_buttons %}
|
||||||
|
<div class="btn-toolbar" role="toolbar">
|
||||||
|
{% for button in action_buttons %}
|
||||||
|
<a href="{{ button.url }}" class="btn {{ button.class|default:'btn-primary' }} btn-sm me-2 mb-2 mb-md-0">
|
||||||
|
{% if button.icon %}<i class="bi bi-{{ button.icon }}"></i>{% endif %}
|
||||||
|
{{ button.text }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-3">
|
||||||
|
|
||||||
|
<!-- Форма фильтров -->
|
||||||
|
<form method="get" id="filterForm">
|
||||||
|
<div class="row g-3">
|
||||||
|
<!-- Поле поиска -->
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label for="search" class="form-label">
|
||||||
|
<i class="bi bi-search"></i> Поиск
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="search"
|
||||||
|
name="search"
|
||||||
|
placeholder="Поиск по названию, артикулу..."
|
||||||
|
value="{{ filters.current.search|default:'' }}"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Фильтр по статусу -->
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label for="is_active" class="form-label">
|
||||||
|
<i class="bi bi-toggle-on"></i> Статус
|
||||||
|
</label>
|
||||||
|
<select class="form-select" id="is_active" name="is_active">
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option value="1" {% if filters.current.is_active == '1' %}selected{% endif %}>Активные</option>
|
||||||
|
<option value="0" {% if filters.current.is_active == '0' %}selected{% endif %}>Неактивные</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Сохраняем текущую категорию при поиске -->
|
||||||
|
{% if filters.current.category %}
|
||||||
|
<input type="hidden" name="category" value="{{ filters.current.category }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Кнопки управления фильтрами -->
|
||||||
|
<div class="col-12 col-md-3">
|
||||||
|
<label class="form-label d-none d-md-block"> </label>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary w-100">
|
||||||
|
<i class="bi bi-check-circle"></i> Применить
|
||||||
|
</button>
|
||||||
|
<a href="{{ request.path }}" class="btn btn-outline-secondary">
|
||||||
|
<i class="bi bi-x-circle"></i> Сброс
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if items %}
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped table-hover">
|
||||||
|
<thead class="table-dark">
|
||||||
|
<tr>
|
||||||
|
<th style="width: 60px;">Тип</th>
|
||||||
|
<th style="width: 80px;">Фото</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Артикул</th>
|
||||||
|
<th>Категория</th>
|
||||||
|
<th>Цена продажи</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th style="width: 200px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
{% if item.item_type == 'product' %}
|
||||||
|
<span class="badge bg-success" title="Товар поштучно">
|
||||||
|
<i class="bi bi-box"></i>
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-info" title="Комплект">
|
||||||
|
<i class="bi bi-box-seam"></i>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if item.photos.all %}
|
||||||
|
{% with photo=item.photos.first %}
|
||||||
|
<img src="{{ photo.image.url }}" alt="{{ item.name }}" style="max-width: 50px; max-height: 50px;" class="img-thumbnail">
|
||||||
|
{% endwith %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted small">Нет фото</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if item.item_type == 'product' %}
|
||||||
|
<a href="{% url 'products:product-detail' item.pk %}">{{ item.name }}</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'products:productkit-detail' item.pk %}">{{ item.name }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ item.sku }}</td>
|
||||||
|
<td>
|
||||||
|
{% if item.categories.all %}
|
||||||
|
{% for category in item.categories.all %}
|
||||||
|
<span class="badge bg-secondary">{{ category.name }}</span>{% if not forloop.last %} {% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<span class="text-muted">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if item.item_type == 'product' %}
|
||||||
|
{{ item.sale_price|floatformat:2 }} руб.
|
||||||
|
{% else %}
|
||||||
|
{{ item.get_sale_price|floatformat:2 }} руб.
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if item.is_active %}
|
||||||
|
<span class="badge bg-success">Активен</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-secondary">Неактивен</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
|
{% if item.item_type == 'product' %}
|
||||||
|
<a href="{% url 'products:product-detail' item.pk %}" class="btn btn-outline-info" title="Просмотр">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if perms.products.change_product %}
|
||||||
|
<a href="{% url 'products:product-update' item.pk %}" class="btn btn-outline-primary" title="Изменить">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if perms.products.delete_product %}
|
||||||
|
<a href="{% url 'products:product-delete' item.pk %}" class="btn btn-outline-danger" title="Удалить">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<a href="{% url 'products:productkit-detail' item.pk %}" class="btn btn-outline-info" title="Просмотр">
|
||||||
|
<i class="bi bi-eye"></i>
|
||||||
|
</a>
|
||||||
|
{% if perms.products.change_productkit %}
|
||||||
|
<a href="{% url 'products:productkit-update' item.pk %}" class="btn btn-outline-primary" title="Изменить">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if perms.products.delete_productkit %}
|
||||||
|
<a href="{% url 'products:productkit-delete' item.pk %}" class="btn btn-outline-danger" title="Удалить">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if is_paginated %}
|
||||||
|
<nav aria-label="Page navigation">
|
||||||
|
<ul class="pagination justify-content-center">
|
||||||
|
{% if page_obj.has_previous %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page=1{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">Первая</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">Предыдущая</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<li class="page-item active">
|
||||||
|
<span class="page-link">Страница {{ page_obj.number }} из {{ page_obj.paginator.num_pages }}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{% if page_obj.has_next %}
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">Следующая</a>
|
||||||
|
</li>
|
||||||
|
<li class="page-item">
|
||||||
|
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% for key, value in request.GET.items %}{% if key != 'page' %}&{{ key }}={{ value }}{% endif %}{% endfor %}">Последняя</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h4><i class="bi bi-info-circle"></i> Товары не найдены</h4>
|
||||||
|
<p>В данный момент нет товаров или комплектов, соответствующих выбранным фильтрам.</p>
|
||||||
|
<div class="mt-3">
|
||||||
|
{% if perms.products.add_product %}
|
||||||
|
<a href="{% url 'products:product-create' %}" class="btn btn-primary me-2">
|
||||||
|
<i class="bi bi-plus-circle"></i> Создать товар
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if perms.products.add_productkit %}
|
||||||
|
<a href="{% url 'products:productkit-create' %}" class="btn btn-outline-primary">
|
||||||
|
<i class="bi bi-plus-circle"></i> Создать комплект
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -4,8 +4,11 @@ from . import views
|
|||||||
app_name = 'products'
|
app_name = 'products'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
# Combined view for products and kits
|
||||||
|
path('', views.CombinedProductListView.as_view(), name='all-products'),
|
||||||
|
|
||||||
# CRUD URLs for Product
|
# CRUD URLs for Product
|
||||||
path('', views.ProductListView.as_view(), name='product-list'),
|
path('products/', views.ProductListView.as_view(), name='product-list'),
|
||||||
path('create/', views.ProductCreateView.as_view(), name='product-create'),
|
path('create/', views.ProductCreateView.as_view(), name='product-create'),
|
||||||
path('<int:pk>/', views.ProductDetailView.as_view(), name='product-detail'),
|
path('<int:pk>/', views.ProductDetailView.as_view(), name='product-detail'),
|
||||||
path('<int:pk>/update/', views.ProductUpdateView.as_view(), name='product-update'),
|
path('<int:pk>/update/', views.ProductUpdateView.as_view(), name='product-update'),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
133
myproject/products/views/README.md
Normal file
133
myproject/products/views/README.md
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# Структура модуля views
|
||||||
|
|
||||||
|
Файл `products/views.py` был разбит на несколько специализированных модулей для улучшения читаемости и поддерживаемости кода.
|
||||||
|
|
||||||
|
## Статистика оптимизации
|
||||||
|
|
||||||
|
### До рефакторинга:
|
||||||
|
- **Файлов:** 1 (`views.py`)
|
||||||
|
- **Строк кода:** 1202
|
||||||
|
- **Дублированный код:** ~400 строк (12 функций управления фото + 3 функции обработки фото)
|
||||||
|
|
||||||
|
### После рефакторинга:
|
||||||
|
- **Файлов:** 7 модулей
|
||||||
|
- **Строк кода:** 1284 (включая документацию)
|
||||||
|
- **Дублированный код:** УСТРАНЁН
|
||||||
|
- **Экономия:** ~400 строк дублированного кода заменены на универсальные функции
|
||||||
|
|
||||||
|
## Структура модулей
|
||||||
|
|
||||||
|
### 1. `__init__.py` (112 строк)
|
||||||
|
Экспортирует все представления для обратной совместимости с `urls.py`.
|
||||||
|
Позволяет использовать импорты вида: `from products.views import ProductListView`
|
||||||
|
|
||||||
|
### 2. `utils.py` (73 строки)
|
||||||
|
Утилиты для работы с фотографиями:
|
||||||
|
- `validate_photo()` - валидация загружаемого фото
|
||||||
|
- `handle_photos()` - **УНИВЕРСАЛЬНАЯ** функция обработки фото (заменяет 3 дублирующиеся функции)
|
||||||
|
|
||||||
|
### 3. `photo_management.py` (310 строк)
|
||||||
|
Универсальные функции управления фотографиями:
|
||||||
|
- **4 базовые функции:** `generic_photo_delete()`, `generic_photo_set_main()`, `generic_photo_move_up()`, `generic_photo_move_down()`
|
||||||
|
- **12 оберток** для Product, ProductKit и Category (заменяют 12 дублирующихся функций из оригинала)
|
||||||
|
|
||||||
|
**Устранённое дублирование:**
|
||||||
|
- Было: 12 отдельных функций (по 4 для каждой модели)
|
||||||
|
- Стало: 4 универсальные функции + 12 простых оберток
|
||||||
|
|
||||||
|
### 4. `product_views.py` (182 строки)
|
||||||
|
CRUD представления для товаров (Product):
|
||||||
|
- `ProductListView` - список товаров с фильтрацией и поиском
|
||||||
|
- `ProductCreateView` - создание товара
|
||||||
|
- `ProductDetailView` - просмотр товара
|
||||||
|
- `ProductUpdateView` - редактирование товара
|
||||||
|
- `ProductDeleteView` - удаление товара
|
||||||
|
|
||||||
|
### 5. `productkit_views.py` (249 строк)
|
||||||
|
CRUD представления для комплектов (ProductKit):
|
||||||
|
- `ProductKitListView` - список комплектов
|
||||||
|
- `ProductKitCreateView` - создание комплекта с компонентами
|
||||||
|
- `ProductKitDetailView` - просмотр комплекта
|
||||||
|
- `ProductKitUpdateView` - редактирование комплекта
|
||||||
|
- `ProductKitDeleteView` - удаление комплекта
|
||||||
|
|
||||||
|
### 6. `category_views.py` (280 строк)
|
||||||
|
CRUD представления для категорий (ProductCategory):
|
||||||
|
- `TreeItem` - класс для элемента дерева категорий
|
||||||
|
- `ProductCategoryListView` - иерархическое дерево категорий с товарами и комплектами
|
||||||
|
- `ProductCategoryCreateView` - создание категории
|
||||||
|
- `ProductCategoryDetailView` - просмотр категории
|
||||||
|
- `ProductCategoryUpdateView` - редактирование категории
|
||||||
|
- `ProductCategoryDeleteView` - удаление категории
|
||||||
|
|
||||||
|
### 7. `api_views.py` (78 строк)
|
||||||
|
API представления:
|
||||||
|
- `search_products_and_variants()` - поиск товаров и групп вариантов для автокомплита
|
||||||
|
|
||||||
|
## Преимущества новой структуры
|
||||||
|
|
||||||
|
### ✅ Устранено дублирование
|
||||||
|
- 12 функций управления фото → 4 универсальные + 12 простых оберток
|
||||||
|
- 3 функции обработки фото → 1 универсальная функция
|
||||||
|
|
||||||
|
### ✅ Улучшена организация
|
||||||
|
- Логическое разделение по функциональным областям
|
||||||
|
- Каждый модуль отвечает за свою сущность (Product, ProductKit, Category)
|
||||||
|
- Легко найти нужный код
|
||||||
|
|
||||||
|
### ✅ Упрощена поддержка
|
||||||
|
- Изменения в одном типе представлений не затрагивают другие
|
||||||
|
- Проще тестировать отдельные компоненты
|
||||||
|
- Легче добавлять новый функционал
|
||||||
|
|
||||||
|
### ✅ Обратная совместимость
|
||||||
|
- Все импорты в `urls.py` работают без изменений
|
||||||
|
- Благодаря `__init__.py` внешний API не изменился
|
||||||
|
|
||||||
|
### ✅ Следование принципам
|
||||||
|
- **SRP (Single Responsibility Principle)** - каждый модуль отвечает за одну область
|
||||||
|
- **DRY (Don't Repeat Yourself)** - устранено дублирование кода
|
||||||
|
- **Separation of Concerns** - разделение по ответственности
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Импорт представлений (работает как раньше):
|
||||||
|
```python
|
||||||
|
from products.views import ProductListView, ProductCreateView
|
||||||
|
from products.views import productkit_photo_delete
|
||||||
|
from products.views import search_products_and_variants
|
||||||
|
```
|
||||||
|
|
||||||
|
### Импорт из конкретного модуля (новая возможность):
|
||||||
|
```python
|
||||||
|
from products.views.product_views import ProductListView
|
||||||
|
from products.views.photo_management import generic_photo_delete
|
||||||
|
from products.views.utils import validate_photo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Изменения в коде
|
||||||
|
|
||||||
|
### Удалено:
|
||||||
|
- Неиспользуемый импорт `import json`
|
||||||
|
- Комментарий-мусор "Временный файл для добавления в views.py"
|
||||||
|
|
||||||
|
### Добавлено:
|
||||||
|
- Docstrings для всех модулей
|
||||||
|
- Комментарии к универсальным функциям
|
||||||
|
- Документация параметров функций
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
После рефакторинга рекомендуется:
|
||||||
|
1. Запустить Django сервер: `python manage.py runserver`
|
||||||
|
2. Проверить все CRUD операции для Product, ProductKit и Category
|
||||||
|
3. Проверить управление фотографиями (upload, delete, set main, move up/down)
|
||||||
|
4. Проверить API endpoint для поиска товаров
|
||||||
|
|
||||||
|
## Потенциальные улучшения
|
||||||
|
|
||||||
|
В будущем можно:
|
||||||
|
1. Добавить базовый класс `BasePhotoView` для дальнейшего упрощения
|
||||||
|
2. Вынести общую логику ListView в миксины
|
||||||
|
3. Добавить unit-тесты для каждого модуля
|
||||||
|
4. Создать отдельный модуль для миксинов и базовых классов
|
||||||
114
myproject/products/views/__init__.py
Normal file
114
myproject/products/views/__init__.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""
|
||||||
|
Экспорт всех представлений для обратной совместимости с urls.py.
|
||||||
|
Позволяет использовать импорты вида: from products.views import ProductListView
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Утилиты
|
||||||
|
from .utils import validate_photo, handle_photos
|
||||||
|
|
||||||
|
# Управление фотографиями (Product)
|
||||||
|
from .photo_management import (
|
||||||
|
product_photo_delete,
|
||||||
|
product_photo_set_main,
|
||||||
|
product_photo_move_up,
|
||||||
|
product_photo_move_down,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Управление фотографиями (ProductKit)
|
||||||
|
from .photo_management import (
|
||||||
|
productkit_photo_delete,
|
||||||
|
productkit_photo_set_main,
|
||||||
|
productkit_photo_move_up,
|
||||||
|
productkit_photo_move_down,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Управление фотографиями (Category)
|
||||||
|
from .photo_management import (
|
||||||
|
category_photo_delete,
|
||||||
|
category_photo_set_main,
|
||||||
|
category_photo_move_up,
|
||||||
|
category_photo_move_down,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CRUD представления для Product
|
||||||
|
from .product_views import (
|
||||||
|
ProductListView,
|
||||||
|
ProductCreateView,
|
||||||
|
ProductDetailView,
|
||||||
|
ProductUpdateView,
|
||||||
|
ProductDeleteView,
|
||||||
|
CombinedProductListView,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CRUD представления для ProductKit
|
||||||
|
from .productkit_views import (
|
||||||
|
ProductKitListView,
|
||||||
|
ProductKitCreateView,
|
||||||
|
ProductKitDetailView,
|
||||||
|
ProductKitUpdateView,
|
||||||
|
ProductKitDeleteView,
|
||||||
|
)
|
||||||
|
|
||||||
|
# CRUD представления для Category
|
||||||
|
from .category_views import (
|
||||||
|
TreeItem,
|
||||||
|
ProductCategoryListView,
|
||||||
|
ProductCategoryCreateView,
|
||||||
|
ProductCategoryDetailView,
|
||||||
|
ProductCategoryUpdateView,
|
||||||
|
ProductCategoryDeleteView,
|
||||||
|
)
|
||||||
|
|
||||||
|
# API представления
|
||||||
|
from .api_views import search_products_and_variants
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Утилиты
|
||||||
|
'validate_photo',
|
||||||
|
'handle_photos',
|
||||||
|
|
||||||
|
# Управление фотографиями Product
|
||||||
|
'product_photo_delete',
|
||||||
|
'product_photo_set_main',
|
||||||
|
'product_photo_move_up',
|
||||||
|
'product_photo_move_down',
|
||||||
|
|
||||||
|
# Управление фотографиями ProductKit
|
||||||
|
'productkit_photo_delete',
|
||||||
|
'productkit_photo_set_main',
|
||||||
|
'productkit_photo_move_up',
|
||||||
|
'productkit_photo_move_down',
|
||||||
|
|
||||||
|
# Управление фотографиями Category
|
||||||
|
'category_photo_delete',
|
||||||
|
'category_photo_set_main',
|
||||||
|
'category_photo_move_up',
|
||||||
|
'category_photo_move_down',
|
||||||
|
|
||||||
|
# Product CRUD
|
||||||
|
'ProductListView',
|
||||||
|
'ProductCreateView',
|
||||||
|
'ProductDetailView',
|
||||||
|
'ProductUpdateView',
|
||||||
|
'ProductDeleteView',
|
||||||
|
'CombinedProductListView',
|
||||||
|
|
||||||
|
# ProductKit CRUD
|
||||||
|
'ProductKitListView',
|
||||||
|
'ProductKitCreateView',
|
||||||
|
'ProductKitDetailView',
|
||||||
|
'ProductKitUpdateView',
|
||||||
|
'ProductKitDeleteView',
|
||||||
|
|
||||||
|
# Category CRUD
|
||||||
|
'TreeItem',
|
||||||
|
'ProductCategoryListView',
|
||||||
|
'ProductCategoryCreateView',
|
||||||
|
'ProductCategoryDetailView',
|
||||||
|
'ProductCategoryUpdateView',
|
||||||
|
'ProductCategoryDeleteView',
|
||||||
|
|
||||||
|
# API
|
||||||
|
'search_products_and_variants',
|
||||||
|
]
|
||||||
78
myproject/products/views/api_views.py
Normal file
78
myproject/products/views/api_views.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
"""
|
||||||
|
API представления для приложения products.
|
||||||
|
"""
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
from ..models import Product, ProductVariantGroup
|
||||||
|
|
||||||
|
|
||||||
|
def search_products_and_variants(request):
|
||||||
|
"""
|
||||||
|
API endpoint для поиска товаров и групп вариантов.
|
||||||
|
Используется для автокомплита при добавлении компонентов в комплект.
|
||||||
|
|
||||||
|
Параметры GET:
|
||||||
|
- q: строка поиска
|
||||||
|
- type: 'product' или 'variant' (опционально, если не указано - поиск по обоим)
|
||||||
|
|
||||||
|
Возвращает JSON список:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Роза красная Freedom 50см",
|
||||||
|
"sku": "PROD-000001",
|
||||||
|
"type": "product",
|
||||||
|
"price": "150.00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"name": "Роза красная Freedom",
|
||||||
|
"type": "variant",
|
||||||
|
"count": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
query = request.GET.get('q', '').strip()
|
||||||
|
search_type = request.GET.get('type', 'all')
|
||||||
|
|
||||||
|
if not query or len(query) < 2:
|
||||||
|
return JsonResponse({'results': []})
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Поиск товаров
|
||||||
|
if search_type in ['all', 'product']:
|
||||||
|
products = Product.objects.filter(
|
||||||
|
models.Q(name__icontains=query) |
|
||||||
|
models.Q(sku__icontains=query) |
|
||||||
|
models.Q(description__icontains=query),
|
||||||
|
is_active=True
|
||||||
|
).values('id', 'name', 'sku', 'sale_price')[:10]
|
||||||
|
|
||||||
|
for product in products:
|
||||||
|
results.append({
|
||||||
|
'id': product['id'],
|
||||||
|
'name': f"{product['name']} ({product['sku']})",
|
||||||
|
'sku': product['sku'],
|
||||||
|
'type': 'product',
|
||||||
|
'price': str(product['sale_price'])
|
||||||
|
})
|
||||||
|
|
||||||
|
# Поиск групп вариантов
|
||||||
|
if search_type in ['all', 'variant']:
|
||||||
|
variants = ProductVariantGroup.objects.filter(
|
||||||
|
models.Q(name__icontains=query) |
|
||||||
|
models.Q(description__icontains=query)
|
||||||
|
).prefetch_related('products')[:10]
|
||||||
|
|
||||||
|
for variant in variants:
|
||||||
|
count = variant.products.filter(is_active=True).count()
|
||||||
|
results.append({
|
||||||
|
'id': variant.id,
|
||||||
|
'name': f"{variant.name} ({count} вариантов)",
|
||||||
|
'type': 'variant',
|
||||||
|
'count': count
|
||||||
|
})
|
||||||
|
|
||||||
|
return JsonResponse({'results': results})
|
||||||
280
myproject/products/views/category_views.py
Normal file
280
myproject/products/views/category_views.py
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"""
|
||||||
|
CRUD представления для категорий товаров (ProductCategory).
|
||||||
|
Включает иерархическое дерево категорий с товарами и комплектами.
|
||||||
|
"""
|
||||||
|
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, reverse
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from ..models import ProductCategory, ProductCategoryPhoto
|
||||||
|
from ..forms import ProductCategoryForm
|
||||||
|
from .utils import handle_photos
|
||||||
|
|
||||||
|
|
||||||
|
class TreeItem:
|
||||||
|
"""
|
||||||
|
Универсальный элемент дерева категорий.
|
||||||
|
Может представлять категорию, товар или набор.
|
||||||
|
"""
|
||||||
|
def __init__(self, obj, item_type, depth, parent_category_id=None):
|
||||||
|
self.obj = obj
|
||||||
|
self.item_type = item_type # 'category', 'product', 'kit'
|
||||||
|
self.depth = depth
|
||||||
|
self.pk = obj.pk
|
||||||
|
self.name = obj.name
|
||||||
|
self.sku = getattr(obj, 'sku', None)
|
||||||
|
|
||||||
|
if item_type == 'category':
|
||||||
|
self.has_children = obj.children.exists()
|
||||||
|
self.parent_id = obj.parent.pk if obj.parent else None
|
||||||
|
else:
|
||||||
|
# Товары и наборы не имеют детей
|
||||||
|
self.has_children = False
|
||||||
|
# Виртуальный parent_id = ID категории (для JavaScript)
|
||||||
|
self.parent_id = parent_category_id
|
||||||
|
|
||||||
|
# Получение цены
|
||||||
|
if item_type == 'product':
|
||||||
|
self.price = obj.sale_price
|
||||||
|
elif item_type == 'kit':
|
||||||
|
self.price = obj.get_sale_price()
|
||||||
|
else:
|
||||||
|
self.price = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCategoryListView(LoginRequiredMixin, ListView):
|
||||||
|
model = ProductCategory
|
||||||
|
template_name = 'products/category_list.html'
|
||||||
|
context_object_name = 'categories'
|
||||||
|
paginate_by = None # Отключаем пагинацию для иерархического отображения
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
# Добавляем prefetch_related для оптимизации запросов к фото
|
||||||
|
queryset = queryset.prefetch_related('photos', 'children')
|
||||||
|
|
||||||
|
# Поиск по названию и артикулу
|
||||||
|
search_query = self.request.GET.get('search')
|
||||||
|
if search_query:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(name__icontains=search_query) |
|
||||||
|
Q(sku__icontains=search_query) |
|
||||||
|
Q(slug__icontains=search_query)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Фильтр по статусу
|
||||||
|
is_active = self.request.GET.get('is_active')
|
||||||
|
if is_active == '1':
|
||||||
|
queryset = queryset.filter(is_active=True)
|
||||||
|
elif is_active == '0':
|
||||||
|
queryset = queryset.filter(is_active=False)
|
||||||
|
|
||||||
|
return queryset
|
||||||
|
|
||||||
|
def build_category_tree(self, queryset):
|
||||||
|
"""
|
||||||
|
Строит иерархическое дерево категорий с товарами и наборами.
|
||||||
|
Возвращает плоский список TreeItem объектов.
|
||||||
|
"""
|
||||||
|
# Получаем все категории из queryset с prefetch для товаров и наборов
|
||||||
|
all_categories = list(queryset.select_related('parent')
|
||||||
|
.prefetch_related('photos', 'children',
|
||||||
|
'products', 'kits'))
|
||||||
|
|
||||||
|
# Создаем словарь для быстрого доступа по ID
|
||||||
|
categories_dict = {cat.pk: cat for cat in all_categories}
|
||||||
|
|
||||||
|
# Добавляем служебные поля
|
||||||
|
for cat in all_categories:
|
||||||
|
cat.depth = 0
|
||||||
|
cat.has_children = cat.children.exists()
|
||||||
|
|
||||||
|
# Находим корневые категории (parent=None или parent не в нашем queryset)
|
||||||
|
queryset_ids = set(categories_dict.keys())
|
||||||
|
root_categories = [
|
||||||
|
cat for cat in all_categories
|
||||||
|
if cat.parent is None or cat.parent.pk not in queryset_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
# Сортируем корневые по имени
|
||||||
|
root_categories.sort(key=lambda x: x.name.lower())
|
||||||
|
|
||||||
|
# Рекурсивно строим дерево
|
||||||
|
result = []
|
||||||
|
|
||||||
|
def add_category_and_children(category, depth=0):
|
||||||
|
# 1. Добавляем категорию как TreeItem
|
||||||
|
tree_item = TreeItem(category, 'category', depth)
|
||||||
|
result.append(tree_item)
|
||||||
|
|
||||||
|
# 2. Добавляем активные товары этой категории (отсортированные по имени)
|
||||||
|
products = category.products.filter(is_active=True).order_by('name')
|
||||||
|
for product in products:
|
||||||
|
product_item = TreeItem(product, 'product', depth + 1, category.pk)
|
||||||
|
result.append(product_item)
|
||||||
|
|
||||||
|
# 3. Добавляем активные наборы этой категории (отсортированные по имени)
|
||||||
|
kits = category.kits.filter(is_active=True).order_by('name')
|
||||||
|
for kit in kits:
|
||||||
|
kit_item = TreeItem(kit, 'kit', depth + 1, category.pk)
|
||||||
|
result.append(kit_item)
|
||||||
|
|
||||||
|
# 4. Рекурсивно обрабатываем дочерние категории
|
||||||
|
children = [
|
||||||
|
cat for cat in all_categories
|
||||||
|
if cat.parent and cat.parent.pk == category.pk
|
||||||
|
]
|
||||||
|
# Сортируем детей по имени
|
||||||
|
children.sort(key=lambda x: x.name.lower())
|
||||||
|
|
||||||
|
# Рекурсивно добавляем детей
|
||||||
|
for child in children:
|
||||||
|
add_category_and_children(child, depth + 1)
|
||||||
|
|
||||||
|
# Обходим все корневые категории
|
||||||
|
for root in root_categories:
|
||||||
|
add_category_and_children(root, 0)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
# Строим иерархическое дерево
|
||||||
|
queryset = self.get_queryset()
|
||||||
|
category_tree = self.build_category_tree(queryset)
|
||||||
|
context['category_tree'] = category_tree
|
||||||
|
|
||||||
|
# Флаг для автоматического раскрытия при поиске
|
||||||
|
context['has_search'] = bool(self.request.GET.get('search'))
|
||||||
|
|
||||||
|
# Фильтры для панели фильтрации
|
||||||
|
context['filters'] = {
|
||||||
|
'search': self.request.GET.get('search', ''),
|
||||||
|
'is_active': self.request.GET.get('is_active', ''),
|
||||||
|
'current': {
|
||||||
|
'search': self.request.GET.get('search', ''),
|
||||||
|
'is_active': self.request.GET.get('is_active', ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# Кнопки действий
|
||||||
|
context['action_buttons'] = [
|
||||||
|
{'url': reverse('products:category-create'), 'text': 'Создать категорию', 'icon': 'plus-circle'}
|
||||||
|
]
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCategoryCreateView(LoginRequiredMixin, CreateView):
|
||||||
|
model = ProductCategory
|
||||||
|
form_class = ProductCategoryForm
|
||||||
|
template_name = 'products/category_form.html'
|
||||||
|
success_url = reverse_lazy('products:category-list')
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
# Сохраняем категорию
|
||||||
|
self.object = form.save()
|
||||||
|
messages.success(self.request, f'Категория "{self.object.name}" создана успешно.')
|
||||||
|
|
||||||
|
# Обработка загрузки фотографий
|
||||||
|
errors = handle_photos(self.request, self.object, ProductCategoryPhoto, 'category')
|
||||||
|
if errors:
|
||||||
|
for error in errors:
|
||||||
|
messages.warning(self.request, error)
|
||||||
|
|
||||||
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
messages.error(self.request, 'Пожалуйста, исправьте ошибки в форме.')
|
||||||
|
return super().form_invalid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCategoryDetailView(LoginRequiredMixin, DetailView):
|
||||||
|
model = ProductCategory
|
||||||
|
template_name = 'products/category_detail.html'
|
||||||
|
context_object_name = 'category'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
# Получаем фотографии категории
|
||||||
|
context['category_photos'] = self.object.photos.all().order_by('order')
|
||||||
|
context['photos_count'] = context['category_photos'].count()
|
||||||
|
# Получаем дочерние категории
|
||||||
|
context['children_categories'] = self.object.children.filter(is_active=True)
|
||||||
|
# Получаем товары в категории
|
||||||
|
context['products'] = self.object.products.filter(is_active=True)[:20]
|
||||||
|
context['products_count'] = self.object.products.filter(is_active=True).count()
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCategoryUpdateView(LoginRequiredMixin, UpdateView):
|
||||||
|
model = ProductCategory
|
||||||
|
form_class = ProductCategoryForm
|
||||||
|
template_name = 'products/category_form.html'
|
||||||
|
success_url = reverse_lazy('products:category-list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
# Добавляем фотографии для отображения в форме
|
||||||
|
context['category_photos'] = self.object.photos.all().order_by('order')
|
||||||
|
context['photos_count'] = context['category_photos'].count()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
# Сохраняем категорию
|
||||||
|
self.object = form.save()
|
||||||
|
messages.success(self.request, f'Категория "{self.object.name}" обновлена успешно.')
|
||||||
|
|
||||||
|
# Обработка загрузки новых фотографий
|
||||||
|
errors = handle_photos(self.request, self.object, ProductCategoryPhoto, 'category')
|
||||||
|
if errors:
|
||||||
|
for error in errors:
|
||||||
|
messages.warning(self.request, error)
|
||||||
|
|
||||||
|
return redirect(self.get_success_url())
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
messages.error(self.request, 'Пожалуйста, исправьте ошибки в форме.')
|
||||||
|
return super().form_invalid(form)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCategoryDeleteView(LoginRequiredMixin, DeleteView):
|
||||||
|
model = ProductCategory
|
||||||
|
template_name = 'products/category_confirm_delete.html'
|
||||||
|
success_url = reverse_lazy('products:category-list')
|
||||||
|
context_object_name = 'category'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
# Проверяем, есть ли товары в категории
|
||||||
|
context['products_count'] = self.object.products.count()
|
||||||
|
context['children_count'] = self.object.children.count()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def post(self, request, *args, **kwargs):
|
||||||
|
self.object = self.get_object()
|
||||||
|
|
||||||
|
# Проверяем, есть ли товары в категории
|
||||||
|
if self.object.products.exists():
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f'Невозможно удалить категорию "{self.object.name}", '
|
||||||
|
f'так как в ней есть товары ({self.object.products.count()} шт.). '
|
||||||
|
f'Сначала удалите или переместите товары.'
|
||||||
|
)
|
||||||
|
return redirect('products:category-detail', pk=self.object.pk)
|
||||||
|
|
||||||
|
# Проверяем, есть ли дочерние категории
|
||||||
|
if self.object.children.exists():
|
||||||
|
messages.error(
|
||||||
|
request,
|
||||||
|
f'Невозможно удалить категорию "{self.object.name}", '
|
||||||
|
f'так как у неё есть подкатегории ({self.object.children.count()} шт.). '
|
||||||
|
f'Сначала удалите или переместите подкатегории.'
|
||||||
|
)
|
||||||
|
return redirect('products:category-detail', pk=self.object.pk)
|
||||||
|
|
||||||
|
messages.success(request, f'Категория "{self.object.name}" удалена успешно.')
|
||||||
|
return super().post(request, *args, **kwargs)
|
||||||
310
myproject/products/views/photo_management.py
Normal file
310
myproject/products/views/photo_management.py
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
"""
|
||||||
|
Универсальные функции для управления фотографиями товаров, комплектов и категорий.
|
||||||
|
Устраняет дублирование кода для операций: delete, set_main, move_up, move_down.
|
||||||
|
"""
|
||||||
|
from django.shortcuts import get_object_or_404, redirect
|
||||||
|
from django.contrib import messages
|
||||||
|
|
||||||
|
from ..models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
|
||||||
|
|
||||||
|
|
||||||
|
# ====================================
|
||||||
|
# Универсальные функции
|
||||||
|
# ====================================
|
||||||
|
|
||||||
|
def generic_photo_delete(request, pk, photo_model, redirect_url_name, parent_attr, permission):
|
||||||
|
"""
|
||||||
|
Универсальное удаление фотографии.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: HTTP request
|
||||||
|
pk: ID фотографии
|
||||||
|
photo_model: Модель фото (ProductPhoto, ProductKitPhoto, ProductCategoryPhoto)
|
||||||
|
redirect_url_name: Имя URL для редиректа ('products:product-update', etc.)
|
||||||
|
parent_attr: Имя атрибута родителя ('product', 'kit', 'category')
|
||||||
|
permission: Требуемое разрешение ('products.change_product', etc.)
|
||||||
|
"""
|
||||||
|
photo = get_object_or_404(photo_model, pk=pk)
|
||||||
|
parent = getattr(photo, parent_attr)
|
||||||
|
parent_id = parent.id
|
||||||
|
|
||||||
|
# Проверка прав доступа
|
||||||
|
if not request.user.has_perm(permission):
|
||||||
|
messages.error(request, 'У вас нет прав для удаления фотографий.')
|
||||||
|
return redirect(redirect_url_name, pk=parent_id)
|
||||||
|
|
||||||
|
photo.delete()
|
||||||
|
messages.success(request, 'Фото успешно удалено!')
|
||||||
|
|
||||||
|
return redirect(redirect_url_name, pk=parent_id)
|
||||||
|
|
||||||
|
|
||||||
|
def generic_photo_set_main(request, pk, photo_model, redirect_url_name, parent_attr, permission):
|
||||||
|
"""
|
||||||
|
Универсальная установка фото как главного (order = 0).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: HTTP request
|
||||||
|
pk: ID фотографии
|
||||||
|
photo_model: Модель фото
|
||||||
|
redirect_url_name: Имя URL для редиректа
|
||||||
|
parent_attr: Имя атрибута родителя ('product', 'kit', 'category')
|
||||||
|
permission: Требуемое разрешение
|
||||||
|
"""
|
||||||
|
photo = get_object_or_404(photo_model, pk=pk)
|
||||||
|
parent = getattr(photo, parent_attr)
|
||||||
|
parent_id = parent.id
|
||||||
|
|
||||||
|
# Проверка прав доступа
|
||||||
|
if not request.user.has_perm(permission):
|
||||||
|
messages.error(request, 'У вас нет прав для изменения порядка фотографий.')
|
||||||
|
return redirect(redirect_url_name, pk=parent_id)
|
||||||
|
|
||||||
|
# Получаем все фото этого родительского объекта
|
||||||
|
filter_kwargs = {f"{parent_attr}_id": parent_id}
|
||||||
|
photos = photo_model.objects.filter(**filter_kwargs).order_by('order')
|
||||||
|
|
||||||
|
# Если это уже главное фото, ничего не делаем
|
||||||
|
if photo.order == 0:
|
||||||
|
messages.info(request, 'Это фото уже установлено как главное.')
|
||||||
|
return redirect(redirect_url_name, pk=parent_id)
|
||||||
|
|
||||||
|
# Меняем порядок: текущее главное фото становится вторым
|
||||||
|
old_order = photo.order
|
||||||
|
for p in photos:
|
||||||
|
if p.pk == photo.pk:
|
||||||
|
p.order = 0
|
||||||
|
p.save()
|
||||||
|
elif p.order == 0:
|
||||||
|
p.order = old_order
|
||||||
|
p.save()
|
||||||
|
|
||||||
|
messages.success(request, 'Фото установлено как главное!')
|
||||||
|
return redirect(redirect_url_name, pk=parent_id)
|
||||||
|
|
||||||
|
|
||||||
|
def generic_photo_move_up(request, pk, photo_model, redirect_url_name, parent_attr, permission):
|
||||||
|
"""
|
||||||
|
Универсальное перемещение фото вверх (уменьшить order).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: HTTP request
|
||||||
|
pk: ID фотографии
|
||||||
|
photo_model: Модель фото
|
||||||
|
redirect_url_name: Имя URL для редиректа
|
||||||
|
parent_attr: Имя атрибута родителя ('product', 'kit', 'category')
|
||||||
|
permission: Требуемое разрешение
|
||||||
|
"""
|
||||||
|
photo = get_object_or_404(photo_model, pk=pk)
|
||||||
|
parent = getattr(photo, parent_attr)
|
||||||
|
parent_id = parent.id
|
||||||
|
|
||||||
|
# Проверка прав доступа
|
||||||
|
if not request.user.has_perm(permission):
|
||||||
|
messages.error(request, 'У вас нет прав для изменения порядка фотографий.')
|
||||||
|
return redirect(redirect_url_name, pk=parent_id)
|
||||||
|
|
||||||
|
# Если это уже первое фото
|
||||||
|
if photo.order == 0:
|
||||||
|
messages.info(request, 'Это фото уже первое в списке.')
|
||||||
|
return redirect(redirect_url_name, pk=parent_id)
|
||||||
|
|
||||||
|
# Находим предыдущее фото
|
||||||
|
filter_kwargs = {
|
||||||
|
f"{parent_attr}_id": parent_id,
|
||||||
|
'order__lt': photo.order
|
||||||
|
}
|
||||||
|
prev_photo = photo_model.objects.filter(**filter_kwargs).order_by('-order').first()
|
||||||
|
|
||||||
|
if prev_photo:
|
||||||
|
# Меняем местами
|
||||||
|
photo.order, prev_photo.order = prev_photo.order, photo.order
|
||||||
|
photo.save()
|
||||||
|
prev_photo.save()
|
||||||
|
messages.success(request, 'Фото перемещено вверх!')
|
||||||
|
|
||||||
|
return redirect(redirect_url_name, pk=parent_id)
|
||||||
|
|
||||||
|
|
||||||
|
def generic_photo_move_down(request, pk, photo_model, redirect_url_name, parent_attr, permission):
|
||||||
|
"""
|
||||||
|
Универсальное перемещение фото вниз (увеличить order).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: HTTP request
|
||||||
|
pk: ID фотографии
|
||||||
|
photo_model: Модель фото
|
||||||
|
redirect_url_name: Имя URL для редиректа
|
||||||
|
parent_attr: Имя атрибута родителя ('product', 'kit', 'category')
|
||||||
|
permission: Требуемое разрешение
|
||||||
|
"""
|
||||||
|
photo = get_object_or_404(photo_model, pk=pk)
|
||||||
|
parent = getattr(photo, parent_attr)
|
||||||
|
parent_id = parent.id
|
||||||
|
|
||||||
|
# Проверка прав доступа
|
||||||
|
if not request.user.has_perm(permission):
|
||||||
|
messages.error(request, 'У вас нет прав для изменения порядка фотографий.')
|
||||||
|
return redirect(redirect_url_name, pk=parent_id)
|
||||||
|
|
||||||
|
# Находим следующее фото
|
||||||
|
filter_kwargs = {
|
||||||
|
f"{parent_attr}_id": parent_id,
|
||||||
|
'order__gt': photo.order
|
||||||
|
}
|
||||||
|
next_photo = photo_model.objects.filter(**filter_kwargs).order_by('order').first()
|
||||||
|
|
||||||
|
if next_photo:
|
||||||
|
# Меняем местами
|
||||||
|
photo.order, next_photo.order = next_photo.order, photo.order
|
||||||
|
photo.save()
|
||||||
|
next_photo.save()
|
||||||
|
messages.success(request, 'Фото перемещено вниз!')
|
||||||
|
else:
|
||||||
|
messages.info(request, 'Это фото уже последнее в списке.')
|
||||||
|
|
||||||
|
return redirect(redirect_url_name, pk=parent_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ====================================
|
||||||
|
# Обертки для Product Photos
|
||||||
|
# ====================================
|
||||||
|
|
||||||
|
def product_photo_delete(request, pk):
|
||||||
|
"""Удаление фотографии товара"""
|
||||||
|
return generic_photo_delete(
|
||||||
|
request, pk,
|
||||||
|
photo_model=ProductPhoto,
|
||||||
|
redirect_url_name='products:product-update',
|
||||||
|
parent_attr='product',
|
||||||
|
permission='products.change_product'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def product_photo_set_main(request, pk):
|
||||||
|
"""Установка фото товара как главного (order = 0)"""
|
||||||
|
return generic_photo_set_main(
|
||||||
|
request, pk,
|
||||||
|
photo_model=ProductPhoto,
|
||||||
|
redirect_url_name='products:product-update',
|
||||||
|
parent_attr='product',
|
||||||
|
permission='products.change_product'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def product_photo_move_up(request, pk):
|
||||||
|
"""Переместить фото товара вверх (уменьшить order)"""
|
||||||
|
return generic_photo_move_up(
|
||||||
|
request, pk,
|
||||||
|
photo_model=ProductPhoto,
|
||||||
|
redirect_url_name='products:product-update',
|
||||||
|
parent_attr='product',
|
||||||
|
permission='products.change_product'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def product_photo_move_down(request, pk):
|
||||||
|
"""Переместить фото товара вниз (увеличить order)"""
|
||||||
|
return generic_photo_move_down(
|
||||||
|
request, pk,
|
||||||
|
photo_model=ProductPhoto,
|
||||||
|
redirect_url_name='products:product-update',
|
||||||
|
parent_attr='product',
|
||||||
|
permission='products.change_product'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ====================================
|
||||||
|
# Обертки для ProductKit Photos
|
||||||
|
# ====================================
|
||||||
|
|
||||||
|
def productkit_photo_delete(request, pk):
|
||||||
|
"""Удаление фотографии комплекта"""
|
||||||
|
return generic_photo_delete(
|
||||||
|
request, pk,
|
||||||
|
photo_model=ProductKitPhoto,
|
||||||
|
redirect_url_name='products:productkit-update',
|
||||||
|
parent_attr='kit',
|
||||||
|
permission='products.change_productkit'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def productkit_photo_set_main(request, pk):
|
||||||
|
"""Установка фото комплекта как главного (order = 0)"""
|
||||||
|
return generic_photo_set_main(
|
||||||
|
request, pk,
|
||||||
|
photo_model=ProductKitPhoto,
|
||||||
|
redirect_url_name='products:productkit-update',
|
||||||
|
parent_attr='kit',
|
||||||
|
permission='products.change_productkit'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def productkit_photo_move_up(request, pk):
|
||||||
|
"""Переместить фото комплекта вверх (уменьшить order)"""
|
||||||
|
return generic_photo_move_up(
|
||||||
|
request, pk,
|
||||||
|
photo_model=ProductKitPhoto,
|
||||||
|
redirect_url_name='products:productkit-update',
|
||||||
|
parent_attr='kit',
|
||||||
|
permission='products.change_productkit'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def productkit_photo_move_down(request, pk):
|
||||||
|
"""Переместить фото комплекта вниз (увеличить order)"""
|
||||||
|
return generic_photo_move_down(
|
||||||
|
request, pk,
|
||||||
|
photo_model=ProductKitPhoto,
|
||||||
|
redirect_url_name='products:productkit-update',
|
||||||
|
parent_attr='kit',
|
||||||
|
permission='products.change_productkit'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ====================================
|
||||||
|
# Обертки для Category Photos
|
||||||
|
# ====================================
|
||||||
|
|
||||||
|
def category_photo_delete(request, pk):
|
||||||
|
"""Удаление фотографии категории"""
|
||||||
|
return generic_photo_delete(
|
||||||
|
request, pk,
|
||||||
|
photo_model=ProductCategoryPhoto,
|
||||||
|
redirect_url_name='products:category-update',
|
||||||
|
parent_attr='category',
|
||||||
|
permission='products.change_productcategory'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def category_photo_set_main(request, pk):
|
||||||
|
"""Установка фото категории как главного (order = 0)"""
|
||||||
|
return generic_photo_set_main(
|
||||||
|
request, pk,
|
||||||
|
photo_model=ProductCategoryPhoto,
|
||||||
|
redirect_url_name='products:category-update',
|
||||||
|
parent_attr='category',
|
||||||
|
permission='products.change_productcategory'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def category_photo_move_up(request, pk):
|
||||||
|
"""Переместить фото категории вверх (уменьшить order)"""
|
||||||
|
return generic_photo_move_up(
|
||||||
|
request, pk,
|
||||||
|
photo_model=ProductCategoryPhoto,
|
||||||
|
redirect_url_name='products:category-update',
|
||||||
|
parent_attr='category',
|
||||||
|
permission='products.change_productcategory'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def category_photo_move_down(request, pk):
|
||||||
|
"""Переместить фото категории вниз (увеличить order)"""
|
||||||
|
return generic_photo_move_down(
|
||||||
|
request, pk,
|
||||||
|
photo_model=ProductCategoryPhoto,
|
||||||
|
redirect_url_name='products:category-update',
|
||||||
|
parent_attr='category',
|
||||||
|
permission='products.change_productcategory'
|
||||||
|
)
|
||||||
289
myproject/products/views/product_views.py
Normal file
289
myproject/products/views/product_views.py
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
"""
|
||||||
|
CRUD представления для товаров (Product).
|
||||||
|
"""
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||||||
|
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from django.db.models import Q
|
||||||
|
from itertools import chain
|
||||||
|
|
||||||
|
from ..models import Product, ProductCategory, ProductTag, ProductKit
|
||||||
|
from ..forms import ProductForm
|
||||||
|
from .utils import handle_photos
|
||||||
|
from ..models import ProductPhoto
|
||||||
|
|
||||||
|
|
||||||
|
class ProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
||||||
|
model = Product
|
||||||
|
template_name = 'products/product_list.html'
|
||||||
|
context_object_name = 'products'
|
||||||
|
permission_required = 'products.view_product'
|
||||||
|
paginate_by = 10
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
# Добавляем prefetch_related для оптимизации запросов к категориям
|
||||||
|
queryset = queryset.prefetch_related('categories', 'photos', 'tags')
|
||||||
|
|
||||||
|
# Улучшенный поиск по нескольким полям
|
||||||
|
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) |
|
||||||
|
Q(categories__name__icontains=search_query) |
|
||||||
|
Q(search_keywords__icontains=search_query)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
# Фильтр по категории
|
||||||
|
category_id = self.request.GET.get('category')
|
||||||
|
if category_id:
|
||||||
|
queryset = queryset.filter(categories__id=category_id)
|
||||||
|
|
||||||
|
# Фильтр по статусу
|
||||||
|
is_active = self.request.GET.get('is_active')
|
||||||
|
if is_active == '1':
|
||||||
|
queryset = queryset.filter(is_active=True)
|
||||||
|
elif is_active == '0':
|
||||||
|
queryset = queryset.filter(is_active=False)
|
||||||
|
|
||||||
|
# Фильтр по тегам
|
||||||
|
tags = self.request.GET.getlist('tags')
|
||||||
|
if tags:
|
||||||
|
queryset = queryset.filter(tags__id__in=tags).distinct()
|
||||||
|
|
||||||
|
return queryset.order_by('-created_at')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
# Данные для фильтров
|
||||||
|
context['filters'] = {
|
||||||
|
'categories': ProductCategory.objects.filter(is_active=True),
|
||||||
|
'tags': ProductTag.objects.all(),
|
||||||
|
'current': {
|
||||||
|
'search': self.request.GET.get('search', ''),
|
||||||
|
'category': self.request.GET.get('category', ''),
|
||||||
|
'is_active': self.request.GET.get('is_active', ''),
|
||||||
|
'tags': self.request.GET.getlist('tags'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Кнопки действий
|
||||||
|
action_buttons = []
|
||||||
|
|
||||||
|
if self.request.user.has_perm('products.add_product'):
|
||||||
|
action_buttons.append({
|
||||||
|
'url': reverse_lazy('products:product-create'),
|
||||||
|
'text': 'Создать товар',
|
||||||
|
'class': 'btn-primary',
|
||||||
|
'icon': 'plus-circle'
|
||||||
|
})
|
||||||
|
|
||||||
|
if self.request.user.has_perm('products.add_productkit'):
|
||||||
|
action_buttons.append({
|
||||||
|
'url': reverse_lazy('products:productkit-create'),
|
||||||
|
'text': 'Создать комплект',
|
||||||
|
'class': 'btn-outline-primary',
|
||||||
|
'icon': 'box-seam'
|
||||||
|
})
|
||||||
|
|
||||||
|
action_buttons.append({
|
||||||
|
'url': reverse_lazy('products:productkit-list'),
|
||||||
|
'text': 'К списку комплектов',
|
||||||
|
'class': 'btn-outline-secondary',
|
||||||
|
'icon': 'list'
|
||||||
|
})
|
||||||
|
|
||||||
|
context['action_buttons'] = action_buttons
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
|
||||||
|
model = Product
|
||||||
|
form_class = ProductForm
|
||||||
|
template_name = 'products/product_form.html'
|
||||||
|
permission_required = 'products.add_product'
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse_lazy('products:product-list')
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
|
||||||
|
# Handle photo uploads
|
||||||
|
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
||||||
|
if photo_errors:
|
||||||
|
for error in photo_errors:
|
||||||
|
messages.error(self.request, error)
|
||||||
|
|
||||||
|
messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!')
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class ProductDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
|
||||||
|
model = Product
|
||||||
|
template_name = 'products/product_detail.html'
|
||||||
|
context_object_name = 'product'
|
||||||
|
permission_required = 'products.view_product'
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# Prefetch photos to avoid N+1 queries
|
||||||
|
return super().get_queryset().prefetch_related('photos')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
# Добавляем фотографии товара в контекст
|
||||||
|
context['product_photos'] = self.object.photos.all().order_by('order', 'created_at')
|
||||||
|
context['photos_count'] = self.object.photos.count()
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ProductUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
|
||||||
|
model = Product
|
||||||
|
form_class = ProductForm
|
||||||
|
template_name = 'products/product_form.html'
|
||||||
|
permission_required = 'products.change_product'
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse_lazy('products:product-list')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
# Добавляем фотографии товара в контекст
|
||||||
|
context['product_photos'] = self.object.photos.all().order_by('order', 'created_at')
|
||||||
|
context['photos_count'] = self.object.photos.count()
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
response = super().form_valid(form)
|
||||||
|
|
||||||
|
# Handle photo uploads
|
||||||
|
photo_errors = handle_photos(self.request, self.object, ProductPhoto, 'product')
|
||||||
|
if photo_errors:
|
||||||
|
for error in photo_errors:
|
||||||
|
messages.error(self.request, error)
|
||||||
|
|
||||||
|
messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!')
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
class ProductDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
|
||||||
|
model = Product
|
||||||
|
template_name = 'products/product_confirm_delete.html'
|
||||||
|
context_object_name = 'product'
|
||||||
|
permission_required = 'products.delete_product'
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
messages.success(self.request, f'Товар "{self.object.name}" успешно удален!')
|
||||||
|
return reverse_lazy('products:product-list')
|
||||||
|
|
||||||
|
|
||||||
|
class CombinedProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
||||||
|
"""
|
||||||
|
Объединенное представление для товаров и комплектов.
|
||||||
|
Показывает оба типа продуктов в одном списке.
|
||||||
|
"""
|
||||||
|
template_name = 'products/all_products_list.html'
|
||||||
|
context_object_name = 'items'
|
||||||
|
permission_required = 'products.view_product'
|
||||||
|
paginate_by = 20
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# Получаем товары и комплекты
|
||||||
|
products = Product.objects.prefetch_related('categories', 'photos', 'tags')
|
||||||
|
kits = ProductKit.objects.prefetch_related('categories', 'photos')
|
||||||
|
|
||||||
|
# Применяем фильтры
|
||||||
|
search_query = self.request.GET.get('search')
|
||||||
|
category_id = self.request.GET.get('category')
|
||||||
|
is_active = self.request.GET.get('is_active')
|
||||||
|
|
||||||
|
# Фильтрация по поиску
|
||||||
|
if search_query:
|
||||||
|
products = products.filter(
|
||||||
|
Q(name__icontains=search_query) |
|
||||||
|
Q(sku__icontains=search_query) |
|
||||||
|
Q(description__icontains=search_query) |
|
||||||
|
Q(categories__name__icontains=search_query) |
|
||||||
|
Q(search_keywords__icontains=search_query)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
kits = kits.filter(
|
||||||
|
Q(name__icontains=search_query) |
|
||||||
|
Q(sku__icontains=search_query) |
|
||||||
|
Q(description__icontains=search_query) |
|
||||||
|
Q(categories__name__icontains=search_query)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
# Фильтрация по категории
|
||||||
|
if category_id:
|
||||||
|
products = products.filter(categories__id=category_id)
|
||||||
|
kits = kits.filter(categories__id=category_id)
|
||||||
|
|
||||||
|
# Фильтрация по статусу
|
||||||
|
if is_active == '1':
|
||||||
|
products = products.filter(is_active=True)
|
||||||
|
kits = kits.filter(is_active=True)
|
||||||
|
elif is_active == '0':
|
||||||
|
products = products.filter(is_active=False)
|
||||||
|
kits = kits.filter(is_active=False)
|
||||||
|
|
||||||
|
# Добавляем type для различения в шаблоне
|
||||||
|
products_list = list(products.order_by('-created_at'))
|
||||||
|
for p in products_list:
|
||||||
|
p.item_type = 'product'
|
||||||
|
|
||||||
|
kits_list = list(kits.order_by('-created_at'))
|
||||||
|
for k in kits_list:
|
||||||
|
k.item_type = 'kit'
|
||||||
|
|
||||||
|
# Объединяем и сортируем по дате создания
|
||||||
|
combined = sorted(
|
||||||
|
chain(products_list, kits_list),
|
||||||
|
key=lambda x: x.created_at,
|
||||||
|
reverse=True
|
||||||
|
)
|
||||||
|
|
||||||
|
return combined
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
# Данные для фильтров
|
||||||
|
context['filters'] = {
|
||||||
|
'categories': ProductCategory.objects.filter(is_active=True),
|
||||||
|
'tags': ProductTag.objects.all(),
|
||||||
|
'current': {
|
||||||
|
'search': self.request.GET.get('search', ''),
|
||||||
|
'category': self.request.GET.get('category', ''),
|
||||||
|
'is_active': self.request.GET.get('is_active', ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Кнопки действий
|
||||||
|
action_buttons = []
|
||||||
|
|
||||||
|
if self.request.user.has_perm('products.add_product'):
|
||||||
|
action_buttons.append({
|
||||||
|
'url': reverse_lazy('products:product-create'),
|
||||||
|
'text': 'Создать товар',
|
||||||
|
'class': 'btn-primary',
|
||||||
|
'icon': 'plus-circle'
|
||||||
|
})
|
||||||
|
|
||||||
|
if self.request.user.has_perm('products.add_productkit'):
|
||||||
|
action_buttons.append({
|
||||||
|
'url': reverse_lazy('products:productkit-create'),
|
||||||
|
'text': 'Создать комплект',
|
||||||
|
'class': 'btn-outline-primary',
|
||||||
|
'icon': 'box-seam'
|
||||||
|
})
|
||||||
|
|
||||||
|
context['action_buttons'] = action_buttons
|
||||||
|
|
||||||
|
return context
|
||||||
249
myproject/products/views/productkit_views.py
Normal file
249
myproject/products/views/productkit_views.py
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"""
|
||||||
|
CRUD представления для комплектов товаров (ProductKit).
|
||||||
|
"""
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
|
||||||
|
from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto
|
||||||
|
from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
|
||||||
|
from .utils import handle_photos
|
||||||
|
|
||||||
|
|
||||||
|
class ProductKitListView(LoginRequiredMixin, PermissionRequiredMixin, ListView):
|
||||||
|
model = ProductKit
|
||||||
|
template_name = 'products/productkit_list.html'
|
||||||
|
context_object_name = 'kits'
|
||||||
|
permission_required = 'products.view_productkit'
|
||||||
|
paginate_by = 10
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
queryset = super().get_queryset()
|
||||||
|
queryset = queryset.prefetch_related('categories', 'photos', 'kit_items', 'tags')
|
||||||
|
|
||||||
|
# Поиск по названию
|
||||||
|
search_query = self.request.GET.get('search')
|
||||||
|
if search_query:
|
||||||
|
queryset = queryset.filter(name__icontains=search_query)
|
||||||
|
|
||||||
|
# Фильтр по категории
|
||||||
|
category_id = self.request.GET.get('category')
|
||||||
|
if category_id:
|
||||||
|
queryset = queryset.filter(categories__id=category_id)
|
||||||
|
|
||||||
|
# Фильтр по статусу
|
||||||
|
is_active = self.request.GET.get('is_active')
|
||||||
|
if is_active == '1':
|
||||||
|
queryset = queryset.filter(is_active=True)
|
||||||
|
elif is_active == '0':
|
||||||
|
queryset = queryset.filter(is_active=False)
|
||||||
|
|
||||||
|
return queryset.order_by('-created_at')
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
# Данные для фильтров
|
||||||
|
context['filters'] = {
|
||||||
|
'categories': ProductCategory.objects.filter(is_active=True),
|
||||||
|
'tags': ProductTag.objects.all(),
|
||||||
|
'current': {
|
||||||
|
'search': self.request.GET.get('search', ''),
|
||||||
|
'category': self.request.GET.get('category', ''),
|
||||||
|
'is_active': self.request.GET.get('is_active', ''),
|
||||||
|
'tags': self.request.GET.getlist('tags'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Кнопки действий
|
||||||
|
action_buttons = []
|
||||||
|
|
||||||
|
if self.request.user.has_perm('products.add_productkit'):
|
||||||
|
action_buttons.append({
|
||||||
|
'url': reverse_lazy('products:productkit-create'),
|
||||||
|
'text': 'Создать комплект',
|
||||||
|
'class': 'btn-primary',
|
||||||
|
'icon': 'plus-circle'
|
||||||
|
})
|
||||||
|
|
||||||
|
action_buttons.append({
|
||||||
|
'url': reverse_lazy('products:product-list'),
|
||||||
|
'text': 'К товарам',
|
||||||
|
'class': 'btn-outline-primary',
|
||||||
|
'icon': 'box'
|
||||||
|
})
|
||||||
|
|
||||||
|
context['action_buttons'] = action_buttons
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ProductKitCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView):
|
||||||
|
"""
|
||||||
|
View для создания нового комплекта с компонентами.
|
||||||
|
"""
|
||||||
|
model = ProductKit
|
||||||
|
form_class = ProductKitForm
|
||||||
|
template_name = 'products/productkit_form.html'
|
||||||
|
permission_required = 'products.add_productkit'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
if self.request.POST:
|
||||||
|
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, instance=self.object)
|
||||||
|
else:
|
||||||
|
context['kititem_formset'] = KitItemFormSetCreate(instance=self.object)
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
# Получаем формсет из POST
|
||||||
|
kititem_formset = KitItemFormSetCreate(self.request.POST, instance=self.object)
|
||||||
|
|
||||||
|
# Проверяем валидность формсета
|
||||||
|
if kititem_formset.is_valid():
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
# Сохраняем основную форму
|
||||||
|
self.object = form.save()
|
||||||
|
|
||||||
|
# Сохраняем компоненты
|
||||||
|
kititem_formset.instance = self.object
|
||||||
|
kititem_formset.save()
|
||||||
|
|
||||||
|
# Обработка фотографий
|
||||||
|
handle_photos(self.request, self.object, ProductKitPhoto, 'kit')
|
||||||
|
|
||||||
|
messages.success(self.request, f'Комплект "{self.object.name}" успешно создан!')
|
||||||
|
|
||||||
|
# Проверяем, какую кнопку нажали
|
||||||
|
if self.request.POST.get('action') == 'continue':
|
||||||
|
return redirect('products:productkit-update', pk=self.object.pk)
|
||||||
|
else:
|
||||||
|
return redirect('products:productkit-list')
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
|
||||||
|
return self.form_invalid(form)
|
||||||
|
else:
|
||||||
|
# Если формсет невалиден, показываем форму с ошибками
|
||||||
|
messages.error(self.request, 'Пожалуйста, исправьте ошибки в компонентах комплекта.')
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
# Получаем формсет для отображения ошибок
|
||||||
|
context = self.get_context_data(form=form)
|
||||||
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse_lazy('products:productkit-list')
|
||||||
|
|
||||||
|
|
||||||
|
class ProductKitUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView):
|
||||||
|
"""
|
||||||
|
View для редактирования существующего комплекта.
|
||||||
|
"""
|
||||||
|
model = ProductKit
|
||||||
|
form_class = ProductKitForm
|
||||||
|
template_name = 'products/productkit_form.html'
|
||||||
|
permission_required = 'products.change_productkit'
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
|
||||||
|
if self.request.POST:
|
||||||
|
context['kititem_formset'] = KitItemFormSetUpdate(self.request.POST, instance=self.object)
|
||||||
|
else:
|
||||||
|
context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object)
|
||||||
|
|
||||||
|
context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at')
|
||||||
|
context['photos_count'] = self.object.photos.count()
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
def form_valid(self, form):
|
||||||
|
# Получаем формсет из POST
|
||||||
|
kititem_formset = KitItemFormSetUpdate(self.request.POST, instance=self.object)
|
||||||
|
|
||||||
|
# Проверяем валидность формсета
|
||||||
|
if kititem_formset.is_valid():
|
||||||
|
try:
|
||||||
|
with transaction.atomic():
|
||||||
|
# Сохраняем основную форму
|
||||||
|
self.object = form.save()
|
||||||
|
|
||||||
|
# Сохраняем компоненты
|
||||||
|
kititem_formset.instance = self.object
|
||||||
|
kititem_formset.save()
|
||||||
|
|
||||||
|
# Обработка фотографий
|
||||||
|
handle_photos(self.request, self.object, ProductKitPhoto, 'kit')
|
||||||
|
|
||||||
|
messages.success(self.request, f'Комплект "{self.object.name}" успешно обновлен!')
|
||||||
|
|
||||||
|
# Проверяем, какую кнопку нажали
|
||||||
|
if self.request.POST.get('action') == 'continue':
|
||||||
|
return redirect('products:productkit-update', pk=self.object.pk)
|
||||||
|
else:
|
||||||
|
return redirect('products:productkit-list')
|
||||||
|
except Exception as e:
|
||||||
|
messages.error(self.request, f'Ошибка при сохранении: {str(e)}')
|
||||||
|
return self.form_invalid(form)
|
||||||
|
else:
|
||||||
|
# Если формсет невалиден, показываем форму с ошибками
|
||||||
|
messages.error(self.request, 'Пожалуйста, исправьте ошибки в компонентах комплекта.')
|
||||||
|
return self.form_invalid(form)
|
||||||
|
|
||||||
|
def form_invalid(self, form):
|
||||||
|
# Получаем формсет для отображения ошибок
|
||||||
|
context = self.get_context_data(form=form)
|
||||||
|
return self.render_to_response(context)
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
return reverse_lazy('products:productkit-list')
|
||||||
|
|
||||||
|
|
||||||
|
class ProductKitDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView):
|
||||||
|
"""
|
||||||
|
View для просмотра деталей комплекта.
|
||||||
|
Показывает все компоненты, цены, фотографии.
|
||||||
|
"""
|
||||||
|
model = ProductKit
|
||||||
|
template_name = 'products/productkit_detail.html'
|
||||||
|
context_object_name = 'kit'
|
||||||
|
permission_required = 'products.view_productkit'
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
# Prefetch для оптимизации запросов
|
||||||
|
return super().get_queryset().prefetch_related(
|
||||||
|
'photos',
|
||||||
|
'kit_items__product',
|
||||||
|
'kit_items__variant_group',
|
||||||
|
'tags'
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_context_data(self, **kwargs):
|
||||||
|
context = super().get_context_data(**kwargs)
|
||||||
|
# Добавляем фотографии комплекта в контекст
|
||||||
|
context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at')
|
||||||
|
context['photos_count'] = self.object.photos.count()
|
||||||
|
# Добавляем компоненты
|
||||||
|
context['kit_items'] = self.object.kit_items.all().select_related('product', 'variant_group')
|
||||||
|
return context
|
||||||
|
|
||||||
|
|
||||||
|
class ProductKitDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView):
|
||||||
|
"""
|
||||||
|
View для удаления комплекта.
|
||||||
|
"""
|
||||||
|
model = ProductKit
|
||||||
|
template_name = 'products/productkit_confirm_delete.html'
|
||||||
|
context_object_name = 'kit'
|
||||||
|
permission_required = 'products.delete_productkit'
|
||||||
|
|
||||||
|
def get_success_url(self):
|
||||||
|
messages.success(self.request, f'Комплект "{self.object.name}" успешно удален!')
|
||||||
|
return reverse_lazy('products:productkit-list')
|
||||||
73
myproject/products/views/utils.py
Normal file
73
myproject/products/views/utils.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
Утилиты для работы с фотографиями товаров, комплектов и категорий.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
def validate_photo(photo):
|
||||||
|
"""
|
||||||
|
Валидация загружаемого фото.
|
||||||
|
Возвращает (True, None) если валидно, или (False, error_message) если ошибка.
|
||||||
|
"""
|
||||||
|
max_size = 5 * 1024 * 1024 # 5MB
|
||||||
|
allowed_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp']
|
||||||
|
|
||||||
|
if photo.size > max_size:
|
||||||
|
return False, f'Размер файла {photo.name} превышает 5MB.'
|
||||||
|
|
||||||
|
ext = os.path.splitext(photo.name)[1].lower()
|
||||||
|
if ext not in allowed_extensions:
|
||||||
|
return False, f'Формат файла {ext} не поддерживается. Разрешены: {", ".join(allowed_extensions)}'
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
|
||||||
|
def handle_photos(request, parent_obj, photo_model, parent_field_name):
|
||||||
|
"""
|
||||||
|
Универсальная обработка загружаемых фотографий.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: HTTP request с FILES
|
||||||
|
parent_obj: Родительский объект (Product, ProductKit или ProductCategory)
|
||||||
|
photo_model: Модель фотографии (ProductPhoto, ProductKitPhoto, ProductCategoryPhoto)
|
||||||
|
parent_field_name: Имя поля связи в модели фото ('product', 'kit', 'category')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Список сообщений об ошибках (пустой список если все ок).
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
photos = request.FILES.getlist('photos')
|
||||||
|
|
||||||
|
if not photos:
|
||||||
|
return errors
|
||||||
|
|
||||||
|
if len(photos) > 10:
|
||||||
|
errors.append('Можно загрузить не более 10 фотографий.')
|
||||||
|
return errors
|
||||||
|
|
||||||
|
# Получаем максимальный order для этого родительского объекта
|
||||||
|
filter_kwargs = {parent_field_name: parent_obj}
|
||||||
|
max_order = photo_model.objects.filter(**filter_kwargs).aggregate(
|
||||||
|
models.Max('order')
|
||||||
|
)['order__max']
|
||||||
|
|
||||||
|
# Если фото нет, начинаем с 0, иначе с max_order + 1
|
||||||
|
next_order = 0 if max_order is None else max_order + 1
|
||||||
|
|
||||||
|
# Валидация и сохранение фото
|
||||||
|
for photo in photos:
|
||||||
|
is_valid, error_msg = validate_photo(photo)
|
||||||
|
if not is_valid:
|
||||||
|
errors.append(error_msg)
|
||||||
|
else:
|
||||||
|
# Создаем фото с правильной связью
|
||||||
|
create_kwargs = {
|
||||||
|
parent_field_name: parent_obj,
|
||||||
|
'image': photo,
|
||||||
|
'order': next_order
|
||||||
|
}
|
||||||
|
photo_model.objects.create(**create_kwargs)
|
||||||
|
next_order += 1
|
||||||
|
|
||||||
|
return errors
|
||||||
65
myproject/templates/components/category_filter_buttons.html
Normal file
65
myproject/templates/components/category_filter_buttons.html
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
{% comment %}
|
||||||
|
Компонент кнопок быстрого фильтра по категориям и типам продуктов
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
- categories: queryset категорий для отображения
|
||||||
|
- current_category: ID текущей выбранной категории (опционально)
|
||||||
|
- show_type_filters: показывать ли фильтры типов (по умолчанию True)
|
||||||
|
{% endcomment %}
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="card-title mb-3">
|
||||||
|
<i class="bi bi-filter"></i> Быстрые фильтры
|
||||||
|
</h6>
|
||||||
|
|
||||||
|
<!-- Фильтры по типу товара -->
|
||||||
|
{% if show_type_filters|default:True %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="btn-group btn-group-sm" role="group" aria-label="Фильтр по типу">
|
||||||
|
<a href="{% url 'products:all-products' %}"
|
||||||
|
class="btn {% if request.resolver_match.url_name == 'all-products' %}btn-primary{% else %}btn-outline-primary{% endif %}">
|
||||||
|
<i class="bi bi-grid"></i> Все товары
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:product-list' %}"
|
||||||
|
class="btn {% if request.resolver_match.url_name == 'product-list' %}btn-success{% else %}btn-outline-success{% endif %}">
|
||||||
|
<i class="bi bi-box"></i> Только поштучно
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'products:productkit-list' %}"
|
||||||
|
class="btn {% if request.resolver_match.url_name == 'productkit-list' %}btn-info{% else %}btn-outline-info{% endif %}">
|
||||||
|
<i class="bi bi-box-seam"></i> Только комплекты
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Фильтры по категориям -->
|
||||||
|
{% if categories %}
|
||||||
|
<div>
|
||||||
|
<label class="form-label text-muted small mb-2">
|
||||||
|
<i class="bi bi-tags"></i> Категории:
|
||||||
|
</label>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<!-- Кнопка "Все категории" -->
|
||||||
|
<a href="?{% if request.GET.search %}search={{ request.GET.search }}&{% endif %}{% if request.GET.is_active %}is_active={{ request.GET.is_active }}{% endif %}"
|
||||||
|
class="btn btn-sm {% if not current_category %}btn-secondary{% else %}btn-outline-secondary{% endif %}">
|
||||||
|
Все
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Кнопки категорий -->
|
||||||
|
{% for category in categories %}
|
||||||
|
<a href="?category={{ category.id }}{% if request.GET.search %}&search={{ request.GET.search }}{% endif %}{% if request.GET.is_active %}&is_active={{ request.GET.is_active }}{% endif %}"
|
||||||
|
class="btn btn-sm {% if current_category == category.id|stringformat:'s' %}btn-secondary{% else %}btn-outline-secondary{% endif %}">
|
||||||
|
{{ category.name }}
|
||||||
|
{% if category.product_set.count or category.productkit_set.count %}
|
||||||
|
<span class="badge bg-light text-dark">
|
||||||
|
{{ category.product_set.count|add:category.productkit_set.count }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
<!-- navbar.html - Reusable navigation bar component -->
|
<!-- navbar.html - Reusable navigation bar component -->
|
||||||
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top">
|
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<!-- Brand/Logo -->
|
|
||||||
<a class="navbar-brand" href="{% url 'products:product-list' %}">Склад</a>
|
|
||||||
|
|
||||||
<!-- Toggler for mobile view -->
|
<!-- Toggler for mobile view -->
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
|
||||||
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
@@ -14,20 +11,18 @@
|
|||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
<ul class="navbar-nav me-auto">
|
<ul class="navbar-nav me-auto">
|
||||||
{% if user.is_authenticated %}
|
{% if user.is_authenticated %}
|
||||||
<!-- Dropdown menu for Products -->
|
<!-- Main navigation links -->
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item">
|
||||||
<a class="nav-link dropdown-toggle" href="#" id="productsDropdown" role="button"
|
<a class="nav-link" href="{% url 'products:all-products' %}">Товары</a>
|
||||||
data-bs-toggle="dropdown" aria-expanded="false">
|
</li>
|
||||||
Товары
|
<li class="nav-item">
|
||||||
</a>
|
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Заказы</a>
|
||||||
<ul class="dropdown-menu" aria-labelledby="productsDropdown">
|
</li>
|
||||||
<li><a class="dropdown-item" href="{% url 'products:product-list' %}">Все товары</a></li>
|
<li class="nav-item">
|
||||||
<li><a class="dropdown-item" href="{% url 'products:category-list' %}">Категории</a></li>
|
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Клиенты</a>
|
||||||
<li><a class="dropdown-item" href="{% url 'products:productkit-list' %}">Комплекты</a></li>
|
</li>
|
||||||
<li><hr class="dropdown-divider"></li>
|
<li class="nav-item">
|
||||||
<li><a class="dropdown-item" href="{% url 'products:product-create' %}">Создать товар</a></li>
|
<a class="nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Касса</a>
|
||||||
<li><a class="dropdown-item" href="{% url 'products:category-create' %}">Создать категорию</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
Reference in New Issue
Block a user