Initial commit: Django inventory system
This commit is contained in:
205
myproject/SKU_SYSTEM_README.md
Normal file
205
myproject/SKU_SYSTEM_README.md
Normal file
@@ -0,0 +1,205 @@
|
||||
# Система генерации артикулов (SKU)
|
||||
|
||||
## Обзор
|
||||
|
||||
Новая система генерации артикулов создана для того, чтобы артикулы были **легко озвучиваемыми** по телефону и имели **логическую структуру**.
|
||||
|
||||
## Структура артикулов
|
||||
|
||||
### Товары (Product)
|
||||
|
||||
**Формат:** `PROD-XXXXXX` или `PROD-XXXXXX-VARIANT`
|
||||
|
||||
- `PROD` - префикс для всех товаров
|
||||
- `XXXXXX` - 6-значный номер (000001-999999)
|
||||
- `VARIANT` - опциональный суффикс варианта (размер, цвет и т.д.)
|
||||
|
||||
**Примеры:**
|
||||
- `PROD-000001` - простой товар без варианта
|
||||
- `PROD-000002-50` - товар с вариантом "50" (например, 50см)
|
||||
- `PROD-000003-M` - товар с вариантом "M" (размер M)
|
||||
- `PROD-000004-RED` - товар с вариантом "RED" (красный цвет)
|
||||
|
||||
### Комплекты (ProductKit)
|
||||
|
||||
**Формат:** `KIT-XXXXXX`
|
||||
|
||||
- `KIT` - префикс для всех комплектов/букетов
|
||||
- `XXXXXX` - 6-значный номер (000001-999999)
|
||||
|
||||
**Примеры:**
|
||||
- `KIT-000001` - Букет "Романтика"
|
||||
- `KIT-000002` - Букет "Весна"
|
||||
|
||||
## Автоматическое извлечение суффиксов
|
||||
|
||||
Система автоматически извлекает суффиксы из названия товара:
|
||||
|
||||
| Название товара | Извлеченный суффикс | Артикул |
|
||||
|----------------|-------------------|---------|
|
||||
| "Роза Freedom 50см" | `50` | `PROD-000001-50` |
|
||||
| "Роза Freedom 60 см" | `60` | `PROD-000002-60` |
|
||||
| "Лента 2.5м" | `25` | `PROD-000003-25` |
|
||||
| "Коробка S" | `S` | `PROD-000004-S` |
|
||||
| "Коробка размер M" | `M` | `PROD-000005-M` |
|
||||
|
||||
## Ручное указание суффикса
|
||||
|
||||
Вы можете вручную указать суффикс в поле `variant_suffix` при создании товара. Это переопределит автоматическое извлечение.
|
||||
|
||||
**Пример:**
|
||||
```python
|
||||
product = Product(
|
||||
name="Лента атласная красная",
|
||||
variant_suffix="RED" # Ручной суффикс
|
||||
)
|
||||
product.save()
|
||||
# Артикул: PROD-000006-RED
|
||||
```
|
||||
|
||||
## Глобальные счетчики (SKUCounter)
|
||||
|
||||
Система использует два глобальных счетчика:
|
||||
|
||||
1. **Product Counter** - для товаров (Product)
|
||||
2. **Kit Counter** - для комплектов (ProductKit)
|
||||
|
||||
Счетчики автоматически увеличиваются при создании нового товара/комплекта.
|
||||
|
||||
### Просмотр счетчиков в админке
|
||||
|
||||
Зайдите в Django Admin → SKU Counters, чтобы увидеть текущие значения счетчиков и предпросмотр следующего артикула.
|
||||
|
||||
## Обеспечение уникальности
|
||||
|
||||
Если артикул уже существует (например, при ручном создании), система автоматически добавит буквенный суффикс:
|
||||
|
||||
```
|
||||
PROD-000001 уже существует
|
||||
→ PROD-000001A
|
||||
→ PROD-000001B (если A тоже занято)
|
||||
→ ... до PROD-000001Z
|
||||
```
|
||||
|
||||
## Преимущества новой системы
|
||||
|
||||
✅ **Легко озвучить:** "PROD тире ноль ноль ноль один тире пятьдесят"
|
||||
✅ **Короткие артикулы:** 11-14 символов вместо 12 (MD5)
|
||||
✅ **Логичная структура:** Понятно, что PROD = товар, KIT = комплект
|
||||
✅ **Масштабируемость:** До 999,999 товаров и 999,999 комплектов
|
||||
✅ **Стабильность:** Артикул не зависит от категории - можно менять категорию
|
||||
✅ **Автоматизация:** Суффиксы извлекаются автоматически из названия
|
||||
|
||||
## Обратная совместимость
|
||||
|
||||
Старые товары с MD5-хешами (например, `PRODA41C9EC1`) **остаются нетронутыми**.
|
||||
|
||||
Новая система применяется только к **новым товарам**, созданным после миграции.
|
||||
|
||||
## Использование
|
||||
|
||||
### Создание товара без варианта
|
||||
|
||||
```python
|
||||
product = Product(
|
||||
name="Роза красная",
|
||||
category=category,
|
||||
cost_price=100,
|
||||
sale_price=200
|
||||
)
|
||||
product.save()
|
||||
# Артикул: PROD-000001
|
||||
```
|
||||
|
||||
### Создание товара с автопарсингом суффикса
|
||||
|
||||
```python
|
||||
product = Product(
|
||||
name="Роза Freedom 50см", # "50см" будет автоматически извлечено
|
||||
category=category,
|
||||
cost_price=150,
|
||||
sale_price=300
|
||||
)
|
||||
product.save()
|
||||
# Артикул: PROD-000002-50
|
||||
# variant_suffix: "50"
|
||||
```
|
||||
|
||||
### Создание товара с ручным суффиксом
|
||||
|
||||
```python
|
||||
product = Product(
|
||||
name="Лента атласная красная",
|
||||
category=category,
|
||||
cost_price=20,
|
||||
sale_price=40,
|
||||
variant_suffix="RED" # Ручной суффикс
|
||||
)
|
||||
product.save()
|
||||
# Артикул: PROD-000003-RED
|
||||
```
|
||||
|
||||
### Создание комплекта
|
||||
|
||||
```python
|
||||
kit = ProductKit(
|
||||
name="Букет Романтика",
|
||||
slug="buket-romantika",
|
||||
pricing_method='fixed',
|
||||
fixed_price=1500
|
||||
)
|
||||
kit.save()
|
||||
# Артикул: KIT-000001
|
||||
```
|
||||
|
||||
## Технические детали
|
||||
|
||||
### Модели
|
||||
|
||||
- **SKUCounter** - хранит глобальные счетчики для товаров и комплектов
|
||||
- **Product.variant_suffix** - новое поле для хранения суффикса варианта
|
||||
|
||||
### Утилиты
|
||||
|
||||
Файл: `products/utils/sku_generator.py`
|
||||
|
||||
**Функции:**
|
||||
- `parse_variant_suffix(name)` - извлекает суффикс из названия
|
||||
- `ensure_sku_unique(base_sku, exclude_id)` - обеспечивает уникальность артикула
|
||||
- `generate_product_sku(product)` - генерирует артикул для товара
|
||||
- `generate_kit_sku()` - генерирует артикул для комплекта
|
||||
|
||||
### Миграции
|
||||
|
||||
- `0007_skucounter_product_variant_suffix.py` - создание модели SKUCounter и добавление поля variant_suffix
|
||||
|
||||
## Тестирование
|
||||
|
||||
Запустите тестовый скрипт для проверки генерации артикулов:
|
||||
|
||||
```bash
|
||||
python test_sku_generation.py
|
||||
```
|
||||
|
||||
Скрипт создаст несколько тестовых товаров и комплектов с разными типами артикулов.
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Что если я изменю название товара после создания?**
|
||||
A: Артикул НЕ изменится. Артикул генерируется только один раз при создании товара.
|
||||
|
||||
**Q: Можно ли изменить категорию товара?**
|
||||
A: Да! Артикул не зависит от категории, поэтому вы можете свободно менять категорию.
|
||||
|
||||
**Q: Что делать, если суффикс извлекся неправильно?**
|
||||
A: Вы можете вручную изменить поле `variant_suffix` в админке и пересохранить товар (но артикул уже не изменится).
|
||||
|
||||
**Q: Можно ли вручную задать артикул?**
|
||||
A: Да, вы можете вручную задать артикул в поле `sku`. Система проверит уникальность.
|
||||
|
||||
**Q: Как сбросить счетчики?**
|
||||
A: Не рекомендуется! Но если необходимо, измените значение в Django Admin → SKU Counters.
|
||||
|
||||
## Поддержка
|
||||
|
||||
При возникновении проблем или вопросов обратитесь к разработчикам проекта.
|
||||
0
myproject/accounts/__init__.py
Normal file
0
myproject/accounts/__init__.py
Normal file
38
myproject/accounts/admin.py
Normal file
38
myproject/accounts/admin.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from .models import CustomUser
|
||||
|
||||
|
||||
class CustomUserAdmin(UserAdmin):
|
||||
# Поля, которые отображаются в админке
|
||||
list_display = ('email', 'name', 'is_email_confirmed', 'is_staff', 'is_active')
|
||||
list_filter = ('is_staff', 'is_active', 'is_email_confirmed')
|
||||
|
||||
# Поля в форме редактирования
|
||||
fieldsets = (
|
||||
(None, {'fields': ('email', 'password')}),
|
||||
('Personal info', {'fields': ('name',)}),
|
||||
('Email Confirmation', {'fields': ('is_email_confirmed', 'email_confirmed_at')}),
|
||||
('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
|
||||
('Important dates', {'fields': ('last_login', 'date_joined')}),
|
||||
)
|
||||
|
||||
# Поля только для чтения
|
||||
readonly_fields = ('email_confirmed_at',)
|
||||
|
||||
# Поля в форме создания нового пользователя
|
||||
add_fieldsets = (
|
||||
(None, {
|
||||
'classes': ('wide',),
|
||||
'fields': ('email', 'name', 'password1', 'password2', 'is_email_confirmed'),
|
||||
}),
|
||||
)
|
||||
|
||||
# Поля, по которым можно производить поиск
|
||||
search_fields = ('email', 'name')
|
||||
ordering = ('email',)
|
||||
filter_horizontal = ()
|
||||
|
||||
|
||||
admin.site.register(CustomUser, CustomUserAdmin)
|
||||
|
||||
6
myproject/accounts/apps.py
Normal file
6
myproject/accounts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'accounts'
|
||||
49
myproject/accounts/forms.py
Normal file
49
myproject/accounts/forms.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import UserCreationForm, SetPasswordForm
|
||||
from .models import CustomUser
|
||||
|
||||
|
||||
class CustomUserCreationForm(UserCreationForm):
|
||||
name = forms.CharField(
|
||||
max_length=100,
|
||||
required=True,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Введите имя'
|
||||
})
|
||||
)
|
||||
email = forms.EmailField(
|
||||
required=True,
|
||||
widget=forms.EmailInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Введите email'
|
||||
})
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
fields = ("name", "email", "password1", "password2")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Настройка стилей для полей пароля
|
||||
self.fields['password1'].widget.attrs.update({'class': 'form-control', 'placeholder': 'Введите пароль'})
|
||||
self.fields['password2'].widget.attrs.update({'class': 'form-control', 'placeholder': 'Подтвердите пароль'})
|
||||
|
||||
def save(self, commit=True):
|
||||
user = super().save(commit=False)
|
||||
user.email = self.cleaned_data["email"]
|
||||
user.name = self.cleaned_data["name"]
|
||||
if commit:
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
class PasswordResetForm(forms.Form):
|
||||
email = forms.EmailField(
|
||||
max_length=254,
|
||||
widget=forms.EmailInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Введите ваш email'
|
||||
})
|
||||
)
|
||||
0
myproject/accounts/management/__init__.py
Normal file
0
myproject/accounts/management/__init__.py
Normal file
0
myproject/accounts/management/commands/__init__.py
Normal file
0
myproject/accounts/management/commands/__init__.py
Normal file
30
myproject/accounts/management/commands/confirm_email.py
Normal file
30
myproject/accounts/management/commands/confirm_email.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from accounts.models import CustomUser
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Подтверждает email для указанного пользователя'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('email', type=str, help='Email пользователя')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
email = options['email']
|
||||
|
||||
try:
|
||||
user = CustomUser.objects.get(email=email)
|
||||
|
||||
if user.is_email_confirmed:
|
||||
self.stdout.write(
|
||||
self.style.WARNING(f'Email для пользователя {email} уже подтвержден.')
|
||||
)
|
||||
else:
|
||||
user.confirm_email()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Email для пользователя {email} успешно подтвержден!')
|
||||
)
|
||||
|
||||
except CustomUser.DoesNotExist:
|
||||
self.stdout.write(
|
||||
self.style.ERROR(f'Пользователь с email {email} не найден.')
|
||||
)
|
||||
46
myproject/accounts/migrations/0001_initial.py
Normal file
46
myproject/accounts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-21 14:41
|
||||
|
||||
import django.contrib.auth.validators
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CustomUser',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
|
||||
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
|
||||
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
|
||||
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
|
||||
('email', models.EmailField(max_length=254, unique=True)),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('is_email_confirmed', models.BooleanField(default=False)),
|
||||
('email_confirmation_token', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('email_confirmed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('password_reset_token', models.UUIDField(blank=True, editable=False, null=True, unique=True)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to.', related_name='custom_user_set', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='custom_user_set', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
0
myproject/accounts/migrations/__init__.py
Normal file
0
myproject/accounts/migrations/__init__.py
Normal file
81
myproject/accounts/models.py
Normal file
81
myproject/accounts/models.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractUser, BaseUserManager
|
||||
from django.utils import timezone
|
||||
import uuid
|
||||
|
||||
|
||||
class CustomUserManager(BaseUserManager):
|
||||
def create_user(self, email, name, password=None, **extra_fields):
|
||||
if not email:
|
||||
raise ValueError('Email обязателен')
|
||||
email = self.normalize_email(email)
|
||||
# Generate a unique username based on email to satisfy the AbstractUser constraint
|
||||
username = email
|
||||
user = self.model(email=email, name=name, username=username, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_superuser(self, email, name, password=None, **extra_fields):
|
||||
extra_fields.setdefault('is_staff', True)
|
||||
extra_fields.setdefault('is_superuser', True)
|
||||
extra_fields.setdefault('is_active', True)
|
||||
# Суперпользователь автоматически имеет подтвержденный email
|
||||
extra_fields.setdefault('is_email_confirmed', True)
|
||||
|
||||
if extra_fields.get('is_staff') is not True:
|
||||
raise ValueError('Суперпользователь должен иметь is_staff=True.')
|
||||
if extra_fields.get('is_superuser') is not True:
|
||||
raise ValueError('Суперпользователь должен иметь is_superuser=True.')
|
||||
|
||||
user = self.create_user(email, name, password, **extra_fields)
|
||||
# Устанавливаем дату подтверждения email
|
||||
if user.is_email_confirmed and not user.email_confirmed_at:
|
||||
user.email_confirmed_at = timezone.now()
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
class CustomUser(AbstractUser):
|
||||
email = models.EmailField(unique=True)
|
||||
name = models.CharField(max_length=100)
|
||||
is_email_confirmed = models.BooleanField(default=False)
|
||||
email_confirmation_token = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
email_confirmed_at = models.DateTimeField(null=True, blank=True)
|
||||
password_reset_token = models.UUIDField(null=True, blank=True, editable=False, unique=True)
|
||||
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = ['name']
|
||||
|
||||
objects = CustomUserManager() # Добавляем кастомный менеджер
|
||||
|
||||
# Изменяем related_name для избежания конфликта с встроенной моделью User
|
||||
groups = models.ManyToManyField(
|
||||
'auth.Group',
|
||||
related_name='custom_user_set',
|
||||
blank=True,
|
||||
verbose_name='groups',
|
||||
help_text='The groups this user belongs to.',
|
||||
)
|
||||
user_permissions = models.ManyToManyField(
|
||||
'auth.Permission',
|
||||
related_name='custom_user_set',
|
||||
blank=True,
|
||||
verbose_name='user permissions',
|
||||
help_text='Specific permissions for this user.',
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
def generate_confirmation_token(self):
|
||||
"""Генерирует новый токен для подтверждения email"""
|
||||
self.email_confirmation_token = uuid.uuid4()
|
||||
self.save()
|
||||
return self.email_confirmation_token
|
||||
|
||||
def confirm_email(self):
|
||||
"""Подтверждает email пользователя"""
|
||||
self.is_email_confirmed = True
|
||||
self.email_confirmed_at = timezone.now()
|
||||
self.save()
|
||||
29
myproject/accounts/templates/accounts/password_input.html
Normal file
29
myproject/accounts/templates/accounts/password_input.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!-- Компонент для поля ввода пароля с возможностью показать/скрыть -->
|
||||
{% comment %}
|
||||
Использование:
|
||||
{% include 'accounts/password_input.html' with field_name='password1' field_label='Пароль' required=True %}
|
||||
{% endcomment %}
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="{{ field_name }}" class="form-label">{{ field_label|default:"Пароль" }}</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="{{ field_name }}"
|
||||
name="{{ field_name }}"
|
||||
{% if required %}required{% endif %}
|
||||
{% if placeholder %}placeholder="{{ placeholder }}"{% endif %}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-secondary show-password-btn"
|
||||
data-target="{{ field_name }}"
|
||||
>
|
||||
<i class="bi bi-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% if field_errors %}
|
||||
<div class="text-danger">{{ field_errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Сброс пароля{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="form-container">
|
||||
<h2 class="text-center mb-4">Сброс пароля</h2>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="reset-password">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% include 'accounts/password_input.html' with field_name='password1' field_label='Новый пароль' required=True %}
|
||||
{% include 'accounts/password_input.html' with field_name='password2' field_label='Подтверждение пароля' required=True %}
|
||||
<button type="submit" class="btn btn-primary w-100">Сбросить пароль</button>
|
||||
</form>
|
||||
|
||||
<!-- Ссылка на вход -->
|
||||
<div class="text-center mt-3">
|
||||
<a href="{% url 'accounts:login' %}" class="text-decoration-none">Вспомнили пароль? Войти</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Добавляем обработчик для показа/скрытия пароля
|
||||
document.querySelectorAll('.show-password-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const targetInput = document.getElementById(targetId);
|
||||
const icon = this.querySelector('i');
|
||||
|
||||
if (targetInput.type === 'password') {
|
||||
targetInput.type = 'text';
|
||||
icon.classList.remove('bi-eye');
|
||||
icon.classList.add('bi-eye-slash');
|
||||
} else {
|
||||
targetInput.type = 'password';
|
||||
icon.classList.remove('bi-eye-slash');
|
||||
icon.classList.add('bi-eye');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
9
myproject/accounts/templates/accounts/register.html
Normal file
9
myproject/accounts/templates/accounts/register.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Регистрация{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Регистрация</h2>
|
||||
<p>Форма регистрации доступна на главной странице.</p>
|
||||
<a href="{% url 'index' %}">Перейти на главную</a>
|
||||
{% endblock %}
|
||||
3
myproject/accounts/tests.py
Normal file
3
myproject/accounts/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
15
myproject/accounts/urls.py
Normal file
15
myproject/accounts/urls.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'accounts'
|
||||
|
||||
urlpatterns = [
|
||||
path('register/', views.register_view, name='register'),
|
||||
path('login/', views.login_view, name='login'),
|
||||
path('logout/', views.logout_view, name='logout'),
|
||||
path('profile/', views.profile_view, name='profile'),
|
||||
path('profile/change-password/', views.change_password_view, name='change_password'),
|
||||
path('confirm/<uuid:token>/', views.confirm_email, name='confirm_email'),
|
||||
path('password-reset/', views.password_reset_request, name='password_reset'),
|
||||
path('password-reset/<uuid:token>/', views.password_reset_confirm, name='password_reset_confirm'),
|
||||
]
|
||||
201
myproject/accounts/views.py
Normal file
201
myproject/accounts/views.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from django.shortcuts import render, redirect, get_object_or_404
|
||||
from django.contrib.auth import login, authenticate, logout
|
||||
from django.contrib import messages
|
||||
from django.core.mail import send_mail
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
|
||||
from django.utils.encoding import force_bytes, force_str
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.auth.forms import PasswordChangeForm
|
||||
from .forms import CustomUserCreationForm, PasswordResetForm
|
||||
from .models import CustomUser
|
||||
import uuid
|
||||
|
||||
|
||||
def register(request):
|
||||
if request.method == 'POST':
|
||||
form = CustomUserCreationForm(request.POST)
|
||||
if form.is_valid():
|
||||
user = form.save(commit=False)
|
||||
user.is_active = False # Пользователь не активен до подтверждения email
|
||||
user.save()
|
||||
|
||||
# Отправляем письмо с подтверждением
|
||||
confirmation_url = request.build_absolute_uri(
|
||||
reverse('accounts:confirm_email', kwargs={'token': user.email_confirmation_token})
|
||||
)
|
||||
|
||||
subject = 'Подтверждение Email'
|
||||
message = f'Привет {user.name}!\n\nДля подтверждения вашего email перейдите по следующей ссылке: {confirmation_url}\n\nСпасибо за регистрацию!'
|
||||
from_email = settings.DEFAULT_FROM_EMAIL
|
||||
recipient_list = [user.email]
|
||||
|
||||
# Выводим письмо в консоль, как вы просили
|
||||
print(f"Письмо для подтверждения:\nТема: {subject}\nСообщение:\n{message}\nПолучатель: {recipient_list}")
|
||||
|
||||
# В реальной системе отправили бы письмо:
|
||||
# send_mail(subject, message, from_email, recipient_list, fail_silently=False)
|
||||
|
||||
messages.success(request, 'Пожалуйста, проверьте вашу почту для подтверждения email.')
|
||||
return redirect('accounts:login')
|
||||
else:
|
||||
form = CustomUserCreationForm()
|
||||
|
||||
return render(request, 'register.html', {'form': form})
|
||||
|
||||
|
||||
def register_view(request):
|
||||
if request.method == 'POST':
|
||||
form = CustomUserCreationForm(request.POST)
|
||||
if form.is_valid():
|
||||
user = form.save(commit=False)
|
||||
user.is_active = False # Пользователь не активен до подтверждения email
|
||||
user.save()
|
||||
|
||||
# Отправляем письмо с подтверждением (выводим в консоль)
|
||||
confirmation_url = request.build_absolute_uri(
|
||||
f'/accounts/confirm/{user.email_confirmation_token}/'
|
||||
)
|
||||
|
||||
subject = 'Подтверждение Email'
|
||||
message = f'Привет {user.name}!\n\nДля подтверждения вашего email перейдите по следующей ссылке: {confirmation_url}\n\nСпасибо за регистрацию!'
|
||||
from_email = 'noreply@example.com' # Используем значение из настроек
|
||||
recipient_list = [user.email]
|
||||
|
||||
# Выводим письмо в консоль, как вы просили
|
||||
print(f"Письмо для подтверждения:\nТема: {subject}\nСообщение:\n{message}\nПолучатель: {recipient_list}")
|
||||
|
||||
messages.success(request, 'Пожалуйста, проверьте вашу почту для подтверждения email.')
|
||||
return redirect('accounts:login') # Перенаправляем на страницу входа после регистрации
|
||||
else:
|
||||
form = CustomUserCreationForm()
|
||||
|
||||
return render(request, 'register.html', {'form': form})
|
||||
|
||||
|
||||
def login_view(request):
|
||||
if request.method == 'POST':
|
||||
email = request.POST.get('email')
|
||||
password = request.POST.get('password')
|
||||
|
||||
# Используем email как логин
|
||||
user = authenticate(request, username=email, password=password)
|
||||
|
||||
if user is not None:
|
||||
if user.is_email_confirmed: # Проверяем, подтвержден ли email
|
||||
login(request, user)
|
||||
# Перенаправляем на главную страницу после успешного входа
|
||||
next_page = request.GET.get('next', 'index') # Если есть параметр next, переходим туда
|
||||
return redirect(next_page)
|
||||
else:
|
||||
messages.error(request, 'Пожалуйста, подтвердите ваш email для входа.')
|
||||
else:
|
||||
messages.error(request, 'Неверный email или пароль.')
|
||||
|
||||
return render(request, 'login.html')
|
||||
|
||||
|
||||
def logout_view(request):
|
||||
logout(request)
|
||||
return redirect('index')
|
||||
|
||||
|
||||
@login_required
|
||||
def profile_view(request):
|
||||
return render(request, 'profile.html', {'user': request.user})
|
||||
|
||||
|
||||
@login_required
|
||||
def change_password_view(request):
|
||||
if request.method == 'POST':
|
||||
form = PasswordChangeForm(request.user, request.POST)
|
||||
if form.is_valid():
|
||||
user = form.save()
|
||||
update_session_auth_hash(request, user) # Important for keeping the user logged in
|
||||
messages.success(request, 'Ваш пароль был успешно изменен!')
|
||||
return redirect('profile')
|
||||
else:
|
||||
messages.error(request, 'Пожалуйста, исправьте ошибки в форме.')
|
||||
else:
|
||||
form = PasswordChangeForm(request.user)
|
||||
|
||||
return render(request, 'change_password.html', {'form': form})
|
||||
|
||||
|
||||
def confirm_email(request, token):
|
||||
user = get_object_or_404(CustomUser, email_confirmation_token=token)
|
||||
|
||||
if user.is_email_confirmed:
|
||||
messages.info(request, 'Email уже был подтвержден.')
|
||||
else:
|
||||
user.confirm_email()
|
||||
user.is_active = True # Активируем пользователя
|
||||
user.save()
|
||||
messages.success(request, 'Email успешно подтвержден! Теперь вы можете войти.')
|
||||
|
||||
return redirect('accounts:login')
|
||||
|
||||
|
||||
def password_reset_request(request):
|
||||
if request.method == 'POST':
|
||||
form = PasswordResetForm(request.POST)
|
||||
if form.is_valid():
|
||||
email = form.cleaned_data['email']
|
||||
try:
|
||||
user = CustomUser.objects.get(email=email)
|
||||
|
||||
# Генерируем токен восстановления
|
||||
user.password_reset_token = uuid.uuid4()
|
||||
user.save()
|
||||
|
||||
# Отправляем письмо с инструкциями по восстановлению
|
||||
reset_url = request.build_absolute_uri(
|
||||
reverse('accounts:password_reset_confirm', kwargs={'token': user.password_reset_token})
|
||||
)
|
||||
|
||||
subject = 'Восстановление пароля'
|
||||
message = f'Привет {user.name}!\n\nДля восстановления пароля перейдите по следующей ссылке: {reset_url}\n\nЕсли вы не запрашивали восстановление пароля, проигнорируйте это письмо.'
|
||||
from_email = settings.DEFAULT_FROM_EMAIL
|
||||
recipient_list = [user.email]
|
||||
|
||||
# Выводим письмо в консоль
|
||||
print(f"Письмо для восстановления пароля:\nТема: {subject}\nСообщение:\n{message}\nПолучатель: {recipient_list}")
|
||||
|
||||
messages.success(request, f'Инструкции по восстановлению пароля отправлены на {email}')
|
||||
except CustomUser.DoesNotExist:
|
||||
# Для безопасности не сообщаем, что пользователя не существует
|
||||
messages.success(request, 'Если аккаунт с таким email существует, инструкции по восстановлению пароля были отправлены на него.')
|
||||
|
||||
return redirect('accounts:login')
|
||||
else:
|
||||
form = PasswordResetForm()
|
||||
|
||||
return render(request, 'login.html', {'form': form})
|
||||
|
||||
|
||||
def password_reset_confirm(request, token):
|
||||
try:
|
||||
user = CustomUser.objects.get(password_reset_token=token)
|
||||
except CustomUser.DoesNotExist:
|
||||
messages.error(request, 'Ссылка для восстановления пароля недействительна.')
|
||||
return redirect('index')
|
||||
|
||||
if request.method == 'POST':
|
||||
password1 = request.POST.get('password1')
|
||||
password2 = request.POST.get('password2')
|
||||
|
||||
if password1 and password2 and password1 == password2:
|
||||
user.set_password(password1)
|
||||
user.password_reset_token = None # Обнуляем токен
|
||||
user.save()
|
||||
messages.success(request, 'Пароль успешно изменен. Теперь вы можете войти.')
|
||||
return redirect('accounts:login')
|
||||
else:
|
||||
messages.error(request, 'Пароли не совпадают.')
|
||||
|
||||
# Отображаем форму смены пароля
|
||||
return render(request, 'accounts/password_reset_confirm.html', {'user': user})
|
||||
160
myproject/docs/README_VARIANTS.md
Normal file
160
myproject/docs/README_VARIANTS.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# Система вариантов товаров - Краткое руководство
|
||||
|
||||
## Что реализовано
|
||||
|
||||
Система позволяет создавать букеты с гибкими заменами компонентов. Каждый букет может иметь свои индивидуальные приоритеты для одной и той же группы товаров.
|
||||
|
||||
## Новые модели
|
||||
|
||||
1. **ProductVariantGroup** - группа взаимозаменяемых товаров
|
||||
2. **KitItemPriority** - приоритеты товаров для конкретной позиции букета
|
||||
|
||||
## Изменения в существующих моделях
|
||||
|
||||
1. **Product** - добавлено поле `variant_groups` (M2M)
|
||||
2. **KitItem** - добавлены поля `variant_group`, `notes`
|
||||
3. **ProductKit** - добавлены методы проверки доступности и расчета цен
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### 1. Запуск демо
|
||||
|
||||
```bash
|
||||
python manage.py demo_variants
|
||||
```
|
||||
|
||||
Это создаст демонстрационные данные и покажет работу системы.
|
||||
|
||||
### 2. Создание группы вариантов через админку
|
||||
|
||||
1. Откройте `/admin/`
|
||||
2. Перейдите в "Группы вариантов"
|
||||
3. Создайте новую группу (например, "Роза красная Freedom")
|
||||
4. Откройте товары и добавьте их в группу через поле "Группы вариантов"
|
||||
|
||||
### 3. Создание букета с вариантами
|
||||
|
||||
1. Создайте новый комплект
|
||||
2. Добавьте позицию:
|
||||
- Либо укажите конкретный товар (без замен)
|
||||
- Либо укажите группу вариантов (с заменами)
|
||||
3. Откройте позицию и настройте приоритеты в разделе "Приоритеты вариантов"
|
||||
|
||||
## API для разработчиков
|
||||
|
||||
### Проверка доступности букета
|
||||
|
||||
```python
|
||||
from products.models import ProductKit
|
||||
from products.utils.stock_manager import StockManager
|
||||
|
||||
kit = ProductKit.objects.get(name="Мой букет")
|
||||
stock_manager = StockManager()
|
||||
|
||||
if kit.check_availability(stock_manager):
|
||||
print("Букет доступен!")
|
||||
price = kit.calculate_price_with_substitutions(stock_manager)
|
||||
print(f"Цена: {price}")
|
||||
```
|
||||
|
||||
### Получение лучшего товара для позиции
|
||||
|
||||
```python
|
||||
from products.models import KitItem
|
||||
from products.utils.stock_manager import StockManager
|
||||
|
||||
kit_item = KitItem.objects.get(id=1)
|
||||
stock_manager = StockManager()
|
||||
|
||||
best_product = kit_item.get_best_available_product(stock_manager)
|
||||
if best_product:
|
||||
print(f"Используем: {best_product.name}")
|
||||
```
|
||||
|
||||
## Файлы документации
|
||||
|
||||
- [product_variants_guide.md](product_variants_guide.md) - подробное руководство
|
||||
- [example_usage.py](example_usage.py) - примеры кода
|
||||
|
||||
## Интеграция со складом
|
||||
|
||||
Текущая версия использует заглушку `StockManager`. Для интеграции с реальной системой складского учета:
|
||||
|
||||
1. Откройте `products/utils/stock_manager.py`
|
||||
2. Реализуйте методы:
|
||||
- `check_stock(product, quantity)` - проверка остатков
|
||||
- `get_available_quantity(product)` - получение доступного количества
|
||||
- `reserve_stock(product, quantity, order_id)` - резервирование
|
||||
- `release_stock(product, quantity, order_id)` - освобождение
|
||||
|
||||
## Основные возможности
|
||||
|
||||
- ✅ Создание групп взаимозаменяемых товаров
|
||||
- ✅ Один товар может быть в нескольких группах
|
||||
- ✅ Индивидуальные приоритеты для каждого букета
|
||||
- ✅ Проверка доступности с учетом замен
|
||||
- ✅ Расчет цены с учетом фактически доступных товаров
|
||||
- ✅ Валидация данных
|
||||
- ✅ Django Admin интерфейс
|
||||
- ✅ Документация и примеры
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Премиум букет
|
||||
|
||||
```
|
||||
Позиция: Роза Freedom (группа вариантов) - 15 шт
|
||||
Приоритеты:
|
||||
0. Роза 70см (200 руб/шт) - первый выбор
|
||||
1. Роза 60см (150 руб/шт)
|
||||
2. Роза 50см (100 руб/шт)
|
||||
|
||||
Цена: 15 × 200 = 3000 руб
|
||||
```
|
||||
|
||||
### Эконом букет
|
||||
|
||||
```
|
||||
Позиция: Роза Freedom (группа вариантов) - 15 шт
|
||||
Приоритеты:
|
||||
0. Роза 50см (100 руб/шт) - первый выбор
|
||||
1. Роза 60см (150 руб/шт)
|
||||
2. Роза 70см (200 руб/шт)
|
||||
|
||||
Цена: 15 × 100 = 1500 руб
|
||||
```
|
||||
|
||||
Та же группа товаров, но разные приоритеты и разная цена!
|
||||
|
||||
## Структура файлов
|
||||
|
||||
```
|
||||
products/
|
||||
├── models.py # Модели (обновлено)
|
||||
├── admin.py # Админка (обновлено)
|
||||
├── utils/
|
||||
│ └── stock_manager.py # Менеджер остатков (новый)
|
||||
├── management/
|
||||
│ └── commands/
|
||||
│ └── demo_variants.py # Демонстрация (новая)
|
||||
└── migrations/
|
||||
└── 0004_productvariantgroup_... # Миграция (новая)
|
||||
|
||||
docs/
|
||||
├── README_VARIANTS.md # Это руководство
|
||||
├── product_variants_guide.md # Подробная документация
|
||||
└── example_usage.py # Примеры кода
|
||||
```
|
||||
|
||||
## Поддержка
|
||||
|
||||
Для получения помощи:
|
||||
1. Прочитайте [product_variants_guide.md](product_variants_guide.md)
|
||||
2. Изучите примеры в [example_usage.py](example_usage.py)
|
||||
3. Запустите `python manage.py demo_variants`
|
||||
4. Обратитесь к разработчикам
|
||||
|
||||
---
|
||||
|
||||
**Версия**: 1.0
|
||||
**Дата**: 2025-10-21
|
||||
259
myproject/docs/example_usage.py
Normal file
259
myproject/docs/example_usage.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
Примеры использования системы вариантов товаров.
|
||||
|
||||
Этот файл содержит примеры кода для демонстрации работы с системой.
|
||||
Запускать через Django shell: python manage.py shell < docs/example_usage.py
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from products.models import (
|
||||
Product, ProductKit, KitItem, ProductCategory,
|
||||
ProductVariantGroup, KitItemPriority
|
||||
)
|
||||
from products.utils.stock_manager import StockManager
|
||||
|
||||
|
||||
def example_1_create_variant_group():
|
||||
"""Пример 1: Создание группы вариантов"""
|
||||
print("\n" + "="*60)
|
||||
print("ПРИМЕР 1: Создание группы вариантов")
|
||||
print("="*60)
|
||||
|
||||
# Создаём категорию
|
||||
category, _ = ProductCategory.objects.get_or_create(
|
||||
name="Цветы",
|
||||
defaults={'slug': 'cvety'}
|
||||
)
|
||||
|
||||
# Создаём товары - розы разной длины
|
||||
rose_50, _ = Product.objects.get_or_create(
|
||||
name="Роза Freedom 50см красная",
|
||||
defaults={
|
||||
'cost_price': Decimal('80.00'),
|
||||
'sale_price': Decimal('100.00'),
|
||||
'category': category
|
||||
}
|
||||
)
|
||||
|
||||
rose_60, _ = Product.objects.get_or_create(
|
||||
name="Роза Freedom 60см красная",
|
||||
defaults={
|
||||
'cost_price': Decimal('120.00'),
|
||||
'sale_price': Decimal('150.00'),
|
||||
'category': category
|
||||
}
|
||||
)
|
||||
|
||||
rose_70, _ = Product.objects.get_or_create(
|
||||
name="Роза Freedom 70см красная",
|
||||
defaults={
|
||||
'cost_price': Decimal('160.00'),
|
||||
'sale_price': Decimal('200.00'),
|
||||
'category': category
|
||||
}
|
||||
)
|
||||
|
||||
# Создаём группу вариантов
|
||||
group, created = ProductVariantGroup.objects.get_or_create(
|
||||
name="Роза красная Freedom",
|
||||
defaults={
|
||||
'description': 'Красная роза Freedom различной длины (50-70см)'
|
||||
}
|
||||
)
|
||||
|
||||
# Добавляем товары в группу
|
||||
rose_50.variant_groups.add(group)
|
||||
rose_60.variant_groups.add(group)
|
||||
rose_70.variant_groups.add(group)
|
||||
|
||||
print(f"✓ Создана группа: {group.name}")
|
||||
print(f" Товаров в группе: {group.get_products_count()}")
|
||||
print(f" Товары:")
|
||||
for product in group.products.all():
|
||||
print(f" - {product.name} ({product.sale_price} руб.)")
|
||||
|
||||
return group, rose_50, rose_60, rose_70
|
||||
|
||||
|
||||
def example_2_create_premium_bouquet(group, rose_50, rose_60, rose_70):
|
||||
"""Пример 2: Создание премиум букета с приоритетами"""
|
||||
print("\n" + "="*60)
|
||||
print("ПРИМЕР 2: Создание премиум букета")
|
||||
print("="*60)
|
||||
|
||||
# Создаём букет
|
||||
kit, _ = ProductKit.objects.get_or_create(
|
||||
name="Ранчо Виталия Премиум",
|
||||
defaults={
|
||||
'slug': 'rancho-vitaliya-premium',
|
||||
'pricing_method': 'from_sale_prices'
|
||||
}
|
||||
)
|
||||
|
||||
# Создаём позицию с группой вариантов
|
||||
kit_item, _ = KitItem.objects.get_or_create(
|
||||
kit=kit,
|
||||
variant_group=group,
|
||||
defaults={
|
||||
'quantity': Decimal('15.000'),
|
||||
'notes': 'Использовать самые длинные розы'
|
||||
}
|
||||
)
|
||||
|
||||
# Настраиваем приоритеты (для премиум букета - сначала длинные)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_70,
|
||||
defaults={'priority': 0} # Наивысший приоритет
|
||||
)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_60,
|
||||
defaults={'priority': 1}
|
||||
)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_50,
|
||||
defaults={'priority': 2} # Самый низкий приоритет
|
||||
)
|
||||
|
||||
print(f"✓ Создан букет: {kit.name}")
|
||||
print(f" Позиций: {kit.get_total_components_count()}")
|
||||
print(f" С вариантами: {kit.get_components_with_variants_count()}")
|
||||
print(f"\n Приоритеты для позиции '{kit_item.get_display_name()}':")
|
||||
for priority in kit_item.priorities.all().order_by('priority'):
|
||||
print(f" {priority.priority}. {priority.product.name} - {priority.product.sale_price} руб.")
|
||||
|
||||
return kit
|
||||
|
||||
|
||||
def example_3_create_economy_bouquet(group, rose_50, rose_60, rose_70):
|
||||
"""Пример 3: Создание эконом букета"""
|
||||
print("\n" + "="*60)
|
||||
print("ПРИМЕР 3: Создание эконом букета")
|
||||
print("="*60)
|
||||
|
||||
# Создаём эконом букет
|
||||
kit, _ = ProductKit.objects.get_or_create(
|
||||
name="Ранчо Виталия Эконом",
|
||||
defaults={
|
||||
'slug': 'rancho-vitaliya-econom',
|
||||
'pricing_method': 'from_sale_prices'
|
||||
}
|
||||
)
|
||||
|
||||
# Та же группа вариантов, но другие приоритеты
|
||||
kit_item, _ = KitItem.objects.get_or_create(
|
||||
kit=kit,
|
||||
variant_group=group,
|
||||
defaults={
|
||||
'quantity': Decimal('15.000'),
|
||||
'notes': 'Эконом вариант'
|
||||
}
|
||||
)
|
||||
|
||||
# Для эконом букета - сначала короткие (дешевые)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_50,
|
||||
defaults={'priority': 0} # Наивысший приоритет
|
||||
)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_60,
|
||||
defaults={'priority': 1}
|
||||
)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_70,
|
||||
defaults={'priority': 2} # Самый низкий приоритет
|
||||
)
|
||||
|
||||
print(f"✓ Создан букет: {kit.name}")
|
||||
print(f"\n Приоритеты для позиции '{kit_item.get_display_name()}':")
|
||||
for priority in kit_item.priorities.all().order_by('priority'):
|
||||
print(f" {priority.priority}. {priority.product.name} - {priority.product.sale_price} руб.")
|
||||
|
||||
return kit
|
||||
|
||||
|
||||
def example_4_check_availability(premium_kit, economy_kit):
|
||||
"""Пример 4: Проверка доступности"""
|
||||
print("\n" + "="*60)
|
||||
print("ПРИМЕР 4: Проверка доступности букетов")
|
||||
print("="*60)
|
||||
|
||||
stock_manager = StockManager()
|
||||
|
||||
# Проверяем премиум букет
|
||||
print(f"\nПремиум букет: {premium_kit.name}")
|
||||
if premium_kit.check_availability(stock_manager):
|
||||
print(" ✓ Доступен для сборки")
|
||||
price = premium_kit.calculate_price_with_substitutions(stock_manager)
|
||||
print(f" Цена: {price} руб.")
|
||||
else:
|
||||
print(" ✗ Недоступен")
|
||||
|
||||
# Проверяем эконом букет
|
||||
print(f"\nЭконом букет: {economy_kit.name}")
|
||||
if economy_kit.check_availability(stock_manager):
|
||||
print(" ✓ Доступен для сборки")
|
||||
price = economy_kit.calculate_price_with_substitutions(stock_manager)
|
||||
print(f" Цена: {price} руб.")
|
||||
else:
|
||||
print(" ✗ Недоступен")
|
||||
|
||||
|
||||
def example_5_best_product():
|
||||
"""Пример 5: Получение лучшего доступного товара"""
|
||||
print("\n" + "="*60)
|
||||
print("ПРИМЕР 5: Выбор лучшего доступного товара")
|
||||
print("="*60)
|
||||
|
||||
# Получаем премиум букет
|
||||
kit = ProductKit.objects.filter(name="Ранчо Виталия Премиум").first()
|
||||
if not kit:
|
||||
print(" Букет не найден")
|
||||
return
|
||||
|
||||
stock_manager = StockManager()
|
||||
|
||||
for kit_item in kit.kit_items.all():
|
||||
print(f"\nПозиция: {kit_item.get_display_name()}")
|
||||
print(f"Количество: {kit_item.quantity}")
|
||||
|
||||
best_product = kit_item.get_best_available_product(stock_manager)
|
||||
if best_product:
|
||||
print(f"✓ Лучший доступный товар: {best_product.name}")
|
||||
print(f" Цена: {best_product.sale_price} руб.")
|
||||
print(f" Стоимость позиции: {best_product.sale_price * kit_item.quantity} руб.")
|
||||
else:
|
||||
print("✗ Нет доступных товаров")
|
||||
|
||||
|
||||
def main():
|
||||
"""Запуск всех примеров"""
|
||||
print("\n" + "="*60)
|
||||
print("ДЕМОНСТРАЦИЯ СИСТЕМЫ ВАРИАНТОВ ТОВАРОВ")
|
||||
print("="*60)
|
||||
|
||||
# Создаём данные
|
||||
group, rose_50, rose_60, rose_70 = example_1_create_variant_group()
|
||||
|
||||
# Создаём букеты
|
||||
premium_kit = example_2_create_premium_bouquet(group, rose_50, rose_60, rose_70)
|
||||
economy_kit = example_3_create_economy_bouquet(group, rose_50, rose_60, rose_70)
|
||||
|
||||
# Проверяем доступность
|
||||
example_4_check_availability(premium_kit, economy_kit)
|
||||
|
||||
# Получаем лучший товар
|
||||
example_5_best_product()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("ДЕМОНСТРАЦИЯ ЗАВЕРШЕНА")
|
||||
print("="*60 + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
310
myproject/docs/product_variants_guide.md
Normal file
310
myproject/docs/product_variants_guide.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Руководство по работе с вариантами товаров
|
||||
|
||||
## Введение
|
||||
|
||||
Система вариантов товаров позволяет создавать букеты с гибкими заменами компонентов. Это полезно когда один товар может быть заменен на другой похожий товар (например, роза 50см на розу 70см), и приоритет замены индивидуален для каждого букета.
|
||||
|
||||
## Основные концепции
|
||||
|
||||
### 1. Группа вариантов (ProductVariantGroup)
|
||||
|
||||
**Группа вариантов** - это набор взаимозаменяемых товаров.
|
||||
|
||||
Пример:
|
||||
- Группа: "Роза красная Freedom"
|
||||
- Роза Freedom 50см
|
||||
- Роза Freedom 60см
|
||||
- Роза Freedom 70см
|
||||
|
||||
Один товар может входить в несколько групп вариантов.
|
||||
|
||||
### 2. Позиция в букете (KitItem)
|
||||
|
||||
Каждая позиция в букете может быть:
|
||||
- **Конкретным товаром** - без возможности замены
|
||||
- **Группой вариантов** - с приоритетами замен
|
||||
|
||||
### 3. Приоритеты (KitItemPriority)
|
||||
|
||||
Для каждой позиции с группой вариантов можно настроить индивидуальные приоритеты:
|
||||
- Меньшее число = выше приоритет
|
||||
- Приоритет 0 = наивысший приоритет
|
||||
- Приоритет 1 = второй по важности
|
||||
- И т.д.
|
||||
|
||||
## Как использовать
|
||||
|
||||
### Шаг 1: Создание группы вариантов
|
||||
|
||||
1. Откройте Django Admin
|
||||
2. Перейдите в раздел "Группы вариантов"
|
||||
3. Нажмите "Добавить группу вариантов"
|
||||
4. Заполните:
|
||||
- Название: например, "Роза красная Freedom"
|
||||
- Описание: опционально
|
||||
5. Сохраните
|
||||
|
||||
### Шаг 2: Добавление товаров в группу
|
||||
|
||||
1. Откройте нужный товар в разделе "Товары"
|
||||
2. В поле "Группы вариантов" выберите созданную группу
|
||||
3. Сохраните товар
|
||||
4. Повторите для всех товаров, которые должны быть в этой группе
|
||||
|
||||
Альтернативный способ:
|
||||
- Выберите несколько товаров через filter_horizontal в админке товара
|
||||
|
||||
### Шаг 3: Создание букета с вариантами
|
||||
|
||||
1. Создайте новый комплект (букет) или откройте существующий
|
||||
2. При добавлении позиции в букете:
|
||||
- **Вариант А**: Укажите конкретный товар (если замены не нужны)
|
||||
- **Вариант Б**: Укажите группу вариантов (если нужны замены)
|
||||
|
||||
⚠️ **Важно**: Нельзя указывать одновременно и товар, и группу вариантов!
|
||||
|
||||
3. Укажите количество
|
||||
4. При необходимости добавьте примечание
|
||||
5. Сохраните позицию
|
||||
|
||||
### Шаг 4: Настройка приоритетов
|
||||
|
||||
Если вы выбрали группу вариантов:
|
||||
|
||||
1. Откройте позицию букета (KitItem)
|
||||
2. В разделе "Приоритеты вариантов" добавьте товары из группы
|
||||
3. Для каждого товара укажите приоритет:
|
||||
```
|
||||
Роза Freedom 70см - приоритет 0 (первый выбор)
|
||||
Роза Freedom 60см - приоритет 1 (второй выбор)
|
||||
Роза Freedom 50см - приоритет 2 (третий выбор)
|
||||
```
|
||||
4. Сохраните
|
||||
|
||||
### Шаг 5: Проверка доступности
|
||||
|
||||
Система автоматически проверяет доступность букета:
|
||||
|
||||
```python
|
||||
# В коде или Django shell
|
||||
kit = ProductKit.objects.get(name="Ранчо Виталия")
|
||||
|
||||
# Проверить доступность
|
||||
if kit.check_availability():
|
||||
print("Букет можно собрать!")
|
||||
else:
|
||||
print("Букет недоступен")
|
||||
|
||||
# Рассчитать цену с учетом замен
|
||||
price = kit.calculate_price_with_substitutions()
|
||||
print(f"Цена: {price} руб.")
|
||||
```
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Пример 1: Премиум букет с розами
|
||||
|
||||
**Задача**: Создать букет "Ранчо Виталия" где нужны длинные розы, но можно заменить на средние.
|
||||
|
||||
**Решение**:
|
||||
1. Создать группу "Роза красная Freedom"
|
||||
2. Добавить в неё розы 50см, 60см, 70см
|
||||
3. В букете создать позицию с группой вариантов
|
||||
4. Настроить приоритеты:
|
||||
- Роза 70см - приоритет 0
|
||||
- Роза 60см - приоритет 1
|
||||
- Роза 50см - приоритет 2
|
||||
|
||||
При проверке доступности система сначала проверит наличие 70см, потом 60см, и только потом 50см.
|
||||
|
||||
### Пример 2: Эконом букет
|
||||
|
||||
**Задача**: Создать эконом-букет где приоритет у коротких роз.
|
||||
|
||||
**Решение**:
|
||||
1. Использовать ту же группу "Роза красная Freedom"
|
||||
2. Создать новый букет с другими приоритетами:
|
||||
- Роза 50см - приоритет 0 (первый выбор)
|
||||
- Роза 60см - приоритет 1
|
||||
- Роза 70см - приоритет 2
|
||||
|
||||
Та же группа товаров, но другой порядок приоритетов!
|
||||
|
||||
### Пример 3: Букет без замен
|
||||
|
||||
**Задача**: Создать букет где конкретные товары без замен.
|
||||
|
||||
**Решение**:
|
||||
1. При создании позиции в букете указать конкретный товар
|
||||
2. Оставить поле "Группа вариантов" пустым
|
||||
3. Приоритеты настраивать не нужно
|
||||
|
||||
### Пример 4: Смешанный букет
|
||||
|
||||
**Задача**: В одном букете часть позиций с заменами, часть без.
|
||||
|
||||
**Решение**:
|
||||
```
|
||||
Позиция 1: Роза Freedom (группа вариантов) - 15 шт
|
||||
Позиция 2: Упаковка крафт (конкретный товар) - 1 шт
|
||||
Позиция 3: Лента атласная (конкретный товар) - 2 м
|
||||
Позиция 4: Эустома белая (группа вариантов) - 5 шт
|
||||
```
|
||||
|
||||
## Как работает система
|
||||
|
||||
### Проверка доступности товара
|
||||
|
||||
Когда вызывается `kit_item.get_best_available_product()`:
|
||||
|
||||
1. Система получает список доступных товаров
|
||||
2. Если настроены приоритеты - сортирует по ним
|
||||
3. Проходит по списку от высшего приоритета к низшему
|
||||
4. Для каждого товара проверяет наличие на складе
|
||||
5. Возвращает первый доступный товар
|
||||
|
||||
### Проверка доступности букета
|
||||
|
||||
Когда вызывается `kit.check_availability()`:
|
||||
|
||||
1. Система проходит по всем позициям букета
|
||||
2. Для каждой позиции ищет доступный товар
|
||||
3. Если хотя бы одна позиция недоступна - весь букет недоступен
|
||||
4. Если все позиции доступны - букет можно собрать
|
||||
|
||||
### Расчет цены
|
||||
|
||||
Система рассчитывает цену на основе фактически доступных товаров:
|
||||
|
||||
```python
|
||||
# Пример
|
||||
Позиция: Роза Freedom - 15 шт
|
||||
Приоритеты:
|
||||
- Роза 70см (200 руб) - нет в наличии
|
||||
- Роза 60см (150 руб) - нет в наличии
|
||||
- Роза 50см (100 руб) - есть в наличии ✓
|
||||
|
||||
Цена позиции: 15 × 100 = 1500 руб
|
||||
```
|
||||
|
||||
## Интеграция со складом
|
||||
|
||||
Текущая версия использует заглушку `StockManager`, которая всегда возвращает `True`.
|
||||
|
||||
В будущем `StockManager` будет интегрирован с реальной системой складского учета:
|
||||
|
||||
```python
|
||||
# Будущая реализация
|
||||
class StockManager:
|
||||
def check_stock(self, product, quantity):
|
||||
# Запрос к складской системе
|
||||
available = get_stock_from_warehouse(product.sku)
|
||||
return available >= quantity
|
||||
```
|
||||
|
||||
## API моделей
|
||||
|
||||
### ProductVariantGroup
|
||||
|
||||
**Методы:**
|
||||
- `get_products_count()` - количество товаров в группе
|
||||
|
||||
**Поля:**
|
||||
- `name` - название группы
|
||||
- `description` - описание
|
||||
- `products` - товары в группе (M2M)
|
||||
|
||||
### KitItem
|
||||
|
||||
**Методы:**
|
||||
- `get_display_name()` - название для отображения
|
||||
- `has_priorities_set()` - настроены ли приоритеты
|
||||
- `get_available_products()` - список доступных товаров
|
||||
- `get_best_available_product(stock_manager)` - лучший доступный товар
|
||||
- `clean()` - валидация
|
||||
|
||||
**Поля:**
|
||||
- `product` - конкретный товар (nullable)
|
||||
- `variant_group` - группа вариантов (nullable)
|
||||
- `quantity` - количество
|
||||
- `notes` - примечание
|
||||
|
||||
### ProductKit
|
||||
|
||||
**Методы:**
|
||||
- `get_total_components_count()` - количество позиций
|
||||
- `get_components_with_variants_count()` - позиций с вариантами
|
||||
- `check_availability(stock_manager)` - проверка доступности
|
||||
- `calculate_price_with_substitutions(stock_manager)` - расчет цены
|
||||
|
||||
### Product
|
||||
|
||||
**Методы:**
|
||||
- `get_variant_groups()` - все группы вариантов
|
||||
- `get_similar_products()` - похожие товары
|
||||
|
||||
**Поля:**
|
||||
- `variant_groups` - группы вариантов (M2M)
|
||||
|
||||
## Советы и лучшие практики
|
||||
|
||||
1. **Именование групп**: Используйте понятные названия, например "Роза красная Freedom" вместо "Группа 1"
|
||||
|
||||
2. **Приоритеты**: Начинайте с 0 и увеличивайте по 1 для простоты
|
||||
|
||||
3. **Проверка**: Всегда проверяйте доступность букета перед оформлением заказа
|
||||
|
||||
4. **Цены**: Учитывайте, что цена может меняться в зависимости от того, какой вариант доступен
|
||||
|
||||
5. **Несколько групп**: Один товар может быть в нескольких группах - это нормально
|
||||
|
||||
6. **Валидация**: Система не даст сохранить позицию, где указаны одновременно товар И группа
|
||||
|
||||
## Часто задаваемые вопросы
|
||||
|
||||
**Q: Можно ли товар добавить в несколько групп вариантов?**
|
||||
A: Да, один товар может быть в любом количестве групп.
|
||||
|
||||
**Q: Что если не настроить приоритеты?**
|
||||
A: Система вернет все товары из группы в произвольном порядке.
|
||||
|
||||
**Q: Можно ли изменить приоритеты после создания букета?**
|
||||
A: Да, приоритеты можно менять в любое время.
|
||||
|
||||
**Q: Как система выбирает товар, если несколько имеют одинаковый приоритет?**
|
||||
A: По ID (первый созданный).
|
||||
|
||||
**Q: Влияет ли порядок товаров в группе на выбор?**
|
||||
A: Нет, только приоритеты имеют значение. Если приоритеты не настроены - порядок не определен.
|
||||
|
||||
## Устранение неполадок
|
||||
|
||||
### Ошибка: "Нельзя указывать одновременно товар и группу вариантов"
|
||||
|
||||
**Причина**: Заполнены оба поля - `product` и `variant_group`
|
||||
|
||||
**Решение**: Очистите одно из полей. Оставьте либо товар, либо группу.
|
||||
|
||||
### Букет показывается как недоступный, хотя товары есть
|
||||
|
||||
**Причина**: Возможно, `StockManager` некорректно работает
|
||||
|
||||
**Решение**: Проверьте реализацию `StockManager.check_stock()`
|
||||
|
||||
### Приоритеты не работают
|
||||
|
||||
**Причина**: Приоритеты не были сохранены для позиции
|
||||
|
||||
**Решение**:
|
||||
1. Откройте позицию букета
|
||||
2. Убедитесь, что в разделе "Приоритеты вариантов" есть записи
|
||||
3. Проверьте значения приоритетов (меньше = выше)
|
||||
|
||||
## Дополнительная информация
|
||||
|
||||
Для получения помощи обратитесь к разработчикам или создайте issue в репозитории проекта.
|
||||
|
||||
---
|
||||
|
||||
**Дата создания**: 2025-10-21
|
||||
**Версия**: 1.0
|
||||
0
myproject/inventory/__init__.py
Normal file
0
myproject/inventory/__init__.py
Normal file
19
myproject/inventory/admin.py
Normal file
19
myproject/inventory/admin.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from django.contrib import admin
|
||||
from .models import Stock, StockMovement
|
||||
|
||||
|
||||
class StockAdmin(admin.ModelAdmin):
|
||||
list_display = ('product', 'quantity_available', 'quantity_reserved', 'updated_at')
|
||||
list_filter = ('updated_at',)
|
||||
search_fields = ('product__name', 'product__sku')
|
||||
|
||||
|
||||
class StockMovementAdmin(admin.ModelAdmin):
|
||||
list_display = ('product', 'change', 'reason', 'order', 'created_at')
|
||||
list_filter = ('reason', 'created_at')
|
||||
search_fields = ('product__name', 'order__id')
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
|
||||
admin.site.register(Stock, StockAdmin)
|
||||
admin.site.register(StockMovement, StockMovementAdmin)
|
||||
6
myproject/inventory/apps.py
Normal file
6
myproject/inventory/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class InventoryConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'inventory'
|
||||
48
myproject/inventory/migrations/0001_initial.py
Normal file
48
myproject/inventory/migrations/0001_initial.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-21 14:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('orders', '0001_initial'),
|
||||
('products', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Stock',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity_available', models.DecimalField(decimal_places=3, default=0, max_digits=10, verbose_name='Доступное количество')),
|
||||
('quantity_reserved', models.DecimalField(decimal_places=3, default=0, max_digits=10, verbose_name='Зарезервированное количество')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('product', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='stock', to='products.product', verbose_name='Товар')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Остаток на складе',
|
||||
'verbose_name_plural': 'Остатки на складе',
|
||||
'indexes': [models.Index(fields=['product'], name='inventory_s_product_4c1da7_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='StockMovement',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('change', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Изменение')),
|
||||
('reason', models.CharField(choices=[('purchase', 'Закупка'), ('sale', 'Продажа'), ('write_off', 'Списание'), ('adjustment', 'Корректировка')], max_length=20, verbose_name='Причина')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='stock_movements', to='orders.order', verbose_name='Заказ')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='movements', to='products.product', verbose_name='Товар')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Движение товара',
|
||||
'verbose_name_plural': 'Движения товаров',
|
||||
'indexes': [models.Index(fields=['product'], name='inventory_s_product_cbdc37_idx'), models.Index(fields=['created_at'], name='inventory_s_created_05ebf5_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
myproject/inventory/migrations/__init__.py
Normal file
0
myproject/inventory/migrations/__init__.py
Normal file
61
myproject/inventory/models.py
Normal file
61
myproject/inventory/models.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from django.db import models
|
||||
from products.models import Product
|
||||
|
||||
|
||||
class Stock(models.Model):
|
||||
"""
|
||||
Остатки по каждому товару.
|
||||
"""
|
||||
product = models.OneToOneField(Product, on_delete=models.CASCADE,
|
||||
related_name='stock', verbose_name="Товар")
|
||||
quantity_available = models.DecimalField(max_digits=10, decimal_places=3, default=0,
|
||||
verbose_name="Доступное количество")
|
||||
quantity_reserved = models.DecimalField(max_digits=10, decimal_places=3, default=0,
|
||||
verbose_name="Зарезервированное количество")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Остаток на складе"
|
||||
verbose_name_plural = "Остатки на складе"
|
||||
indexes = [
|
||||
models.Index(fields=['product']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name} - {self.quantity_available}"
|
||||
|
||||
@property
|
||||
def quantity_free(self):
|
||||
"""Свободное количество (доступное минус зарезервированное)"""
|
||||
return self.quantity_available - self.quantity_reserved
|
||||
|
||||
|
||||
class StockMovement(models.Model):
|
||||
"""
|
||||
Журнал всех складских операций (приход, списание, коррекция).
|
||||
"""
|
||||
REASON_CHOICES = [
|
||||
('purchase', 'Закупка'),
|
||||
('sale', 'Продажа'),
|
||||
('write_off', 'Списание'),
|
||||
('adjustment', 'Корректировка'),
|
||||
]
|
||||
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE,
|
||||
related_name='movements', verbose_name="Товар")
|
||||
change = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Изменение")
|
||||
reason = models.CharField(max_length=20, choices=REASON_CHOICES, verbose_name="Причина")
|
||||
order = models.ForeignKey('orders.Order', on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='stock_movements', verbose_name="Заказ")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Движение товара"
|
||||
verbose_name_plural = "Движения товаров"
|
||||
indexes = [
|
||||
models.Index(fields=['product']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name}: {self.change} ({self.reason})"
|
||||
3
myproject/inventory/tests.py
Normal file
3
myproject/inventory/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
myproject/inventory/views.py
Normal file
3
myproject/inventory/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
22
myproject/manage.py
Normal file
22
myproject/manage.py
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
0
myproject/myproject/__init__.py
Normal file
0
myproject/myproject/__init__.py
Normal file
16
myproject/myproject/asgi.py
Normal file
16
myproject/myproject/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for myproject project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
144
myproject/myproject/settings.py
Normal file
144
myproject/myproject/settings.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Django settings for myproject project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 5.2.7.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'django-insecure-bs^tx8!&v2qx9!)i0!%*p#=kwn&@x0%r6i3&l-3z14bw%k5yj-'
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'nested_admin', # Для вложенных inline в админке
|
||||
'accounts',
|
||||
'products',
|
||||
'inventory',
|
||||
'orders',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'myproject.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'templates'], # Добавили путь к шаблонам
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'myproject.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'ru-ru' # Изменили на русский
|
||||
|
||||
TIME_ZONE = 'Europe/Moscow' # Установили таймзону
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
STATICFILES_DIRS = [BASE_DIR / 'static'] # Добавили директорию для статических файлов
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles' # Для collectstatic
|
||||
|
||||
# Media files (User uploads)
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# Настройки категорий товаров
|
||||
# Максимальная глубина вложенности категорий (защита от слишком глубокой иерархии)
|
||||
MAX_CATEGORY_DEPTH = 10
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# Настройки для отправки email в консоль
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
DEFAULT_FROM_EMAIL = 'noreply@example.com'
|
||||
|
||||
# Указываем нашу кастомную модель пользователя
|
||||
AUTH_USER_MODEL = 'accounts.CustomUser'
|
||||
33
myproject/myproject/urls.py
Normal file
33
myproject/myproject/urls.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
URL configuration for myproject project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('_nested_admin/', include('nested_admin.urls')), # Для nested admin
|
||||
path('admin/', admin.site.urls),
|
||||
path('', views.index, name='index'), # Main page
|
||||
path('accounts/', include('accounts.urls')),
|
||||
path('products/', include('products.urls')),
|
||||
]
|
||||
|
||||
# Serve media files during development
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
12
myproject/myproject/views.py
Normal file
12
myproject/myproject/views.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
|
||||
def index(request):
|
||||
# Главная страница - отображается для всех пользователей
|
||||
# Если пользователь авторизован, можно показать персонализированное содержимое
|
||||
if request.user.is_authenticated:
|
||||
# Здесь можно отобразить персонализированное содержимое для авторизованных пользователей
|
||||
return render(request, 'dashboard.html') # или другую страницу
|
||||
else:
|
||||
# Для неавторизованных пользователей показываем приветственную страницу
|
||||
return render(request, 'home.html')
|
||||
16
myproject/myproject/wsgi.py
Normal file
16
myproject/myproject/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for myproject project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
0
myproject/orders/__init__.py
Normal file
0
myproject/orders/__init__.py
Normal file
28
myproject/orders/admin.py
Normal file
28
myproject/orders/admin.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from django.contrib import admin
|
||||
from .models import Customer, Order, OrderItem
|
||||
|
||||
|
||||
class CustomerAdmin(admin.ModelAdmin):
|
||||
list_display = ('first_name', 'last_name', 'email', 'phone', 'created_at')
|
||||
list_filter = ('created_at', 'updated_at')
|
||||
search_fields = ('first_name', 'last_name', 'email')
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
|
||||
class OrderItemInline(admin.TabularInline):
|
||||
model = OrderItem
|
||||
extra = 1
|
||||
readonly_fields = ('snapshot_name', 'snapshot_sku', 'sale_price', 'cost_price')
|
||||
|
||||
|
||||
class OrderAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'customer', 'status', 'total_price', 'created_at', 'updated_at')
|
||||
list_filter = ('status', 'created_at', 'updated_at')
|
||||
search_fields = ('customer__first_name', 'customer__last_name', 'customer__email', 'id')
|
||||
date_hierarchy = 'created_at'
|
||||
inlines = [OrderItemInline]
|
||||
|
||||
|
||||
admin.site.register(Customer, CustomerAdmin)
|
||||
admin.site.register(Order, OrderAdmin)
|
||||
admin.site.register(OrderItem)
|
||||
6
myproject/orders/apps.py
Normal file
6
myproject/orders/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class OrdersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'orders'
|
||||
89
myproject/orders/migrations/0001_initial.py
Normal file
89
myproject/orders/migrations/0001_initial.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-21 14:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('products', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Customer',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('first_name', models.CharField(max_length=100, verbose_name='Имя')),
|
||||
('last_name', models.CharField(max_length=100, verbose_name='Фамилия')),
|
||||
('email', models.EmailField(max_length=254, unique=True, verbose_name='Email')),
|
||||
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Телефон')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата регистрации')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='customer', to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Покупатель',
|
||||
'verbose_name_plural': 'Покупатели',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Order',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('created', 'Создан'), ('confirmed', 'Подтвержден'), ('assembled', 'Собран'), ('delivered', 'Доставлен'), ('cancelled', 'Отменен')], default='created', max_length=20, verbose_name='Статус')),
|
||||
('total_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Общая сумма')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='orders', to='orders.customer', verbose_name='Клиент')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Заказ',
|
||||
'verbose_name_plural': 'Заказы',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='OrderItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.DecimalField(decimal_places=3, default=1, max_digits=10, verbose_name='Количество')),
|
||||
('snapshot_name', models.CharField(max_length=200, verbose_name='Название (на момент заказа)')),
|
||||
('snapshot_sku', models.CharField(blank=True, max_length=100, null=True, verbose_name='Артикул (на момент заказа)')),
|
||||
('sale_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Цена продажи')),
|
||||
('cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Себестоимость')),
|
||||
('composition_snapshot', models.JSONField(blank=True, null=True, verbose_name='Состав комплекта (снапшот)')),
|
||||
('kit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order_items', to='products.productkit', verbose_name='Комплект')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to='orders.order', verbose_name='Заказ')),
|
||||
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='order_items', to='products.product', verbose_name='Товар')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Позиция заказа',
|
||||
'verbose_name_plural': 'Позиции заказов',
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='customer',
|
||||
index=models.Index(fields=['email'], name='orders_cust_email_e97b09_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='order',
|
||||
index=models.Index(fields=['status'], name='orders_orde_status_c6dd84_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='order',
|
||||
index=models.Index(fields=['customer'], name='orders_orde_custome_59b6fb_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='order',
|
||||
index=models.Index(fields=['created_at'], name='orders_orde_created_0e92de_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='orderitem',
|
||||
index=models.Index(fields=['order'], name='orders_orde_order_i_5d347b_idx'),
|
||||
),
|
||||
]
|
||||
0
myproject/orders/migrations/__init__.py
Normal file
0
myproject/orders/migrations/__init__.py
Normal file
135
myproject/orders/models.py
Normal file
135
myproject/orders/models.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from django.db import models
|
||||
from accounts.models import CustomUser
|
||||
from products.models import Product, ProductKit
|
||||
|
||||
|
||||
class Customer(models.Model):
|
||||
"""
|
||||
Модель покупателя.
|
||||
"""
|
||||
user = models.OneToOneField(CustomUser, on_delete=models.CASCADE, null=True, blank=True,
|
||||
related_name='customer', verbose_name="Пользователь")
|
||||
first_name = models.CharField(max_length=100, verbose_name="Имя")
|
||||
last_name = models.CharField(max_length=100, verbose_name="Фамилия")
|
||||
email = models.EmailField(unique=True, verbose_name="Email")
|
||||
phone = models.CharField(max_length=20, blank=True, null=True, verbose_name="Телефон")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата регистрации")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Покупатель"
|
||||
verbose_name_plural = "Покупатели"
|
||||
indexes = [
|
||||
models.Index(fields=['email']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.first_name} {self.last_name} ({self.email})"
|
||||
|
||||
|
||||
class Order(models.Model):
|
||||
"""
|
||||
Заказ клиента.
|
||||
"""
|
||||
STATUS_CHOICES = [
|
||||
('created', 'Создан'),
|
||||
('confirmed', 'Подтвержден'),
|
||||
('assembled', 'Собран'),
|
||||
('delivered', 'Доставлен'),
|
||||
('cancelled', 'Отменен'),
|
||||
]
|
||||
|
||||
customer = models.ForeignKey(Customer, on_delete=models.CASCADE,
|
||||
related_name='orders', verbose_name="Клиент")
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='created',
|
||||
verbose_name="Статус")
|
||||
total_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Общая сумма")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Заказ"
|
||||
verbose_name_plural = "Заказы"
|
||||
indexes = [
|
||||
models.Index(fields=['status']),
|
||||
models.Index(fields=['customer']),
|
||||
models.Index(fields=['created_at']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Заказ #{self.id} - {self.customer}"
|
||||
|
||||
|
||||
class OrderItem(models.Model):
|
||||
"""
|
||||
Строка заказа — может быть простым товаром или комплектом.
|
||||
"""
|
||||
order = models.ForeignKey(Order, on_delete=models.CASCADE,
|
||||
related_name='items', verbose_name="Заказ")
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE, null=True, blank=True,
|
||||
related_name='order_items', verbose_name="Товар")
|
||||
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, null=True, blank=True,
|
||||
related_name='order_items', verbose_name="Комплект")
|
||||
quantity = models.DecimalField(max_digits=10, decimal_places=3, default=1,
|
||||
verbose_name="Количество")
|
||||
|
||||
# Снапшот-поля (для истории и отчётов)
|
||||
snapshot_name = models.CharField(max_length=200, verbose_name="Название (на момент заказа)")
|
||||
snapshot_sku = models.CharField(max_length=100, blank=True, null=True,
|
||||
verbose_name="Артикул (на момент заказа)")
|
||||
sale_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Цена продажи")
|
||||
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Себестоимость")
|
||||
composition_snapshot = models.JSONField(null=True, blank=True,
|
||||
verbose_name="Состав комплекта (снапшот)")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Позиция заказа"
|
||||
verbose_name_plural = "Позиции заказов"
|
||||
indexes = [
|
||||
models.Index(fields=['order']),
|
||||
]
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Валидация: либо product, либо kit, но не оба
|
||||
if self.product and self.kit:
|
||||
raise ValueError("Нельзя одновременно указать товар и комплект")
|
||||
if not self.product and not self.kit:
|
||||
raise ValueError("Необходимо указать либо товар, либо комплект")
|
||||
|
||||
# Заполнение снапшот-полей
|
||||
if self.product:
|
||||
if not self.snapshot_name:
|
||||
self.snapshot_name = self.product.name
|
||||
if not self.snapshot_sku:
|
||||
self.snapshot_sku = self.product.sku
|
||||
if not self.sale_price:
|
||||
self.sale_price = self.product.sale_price
|
||||
if not self.cost_price:
|
||||
self.cost_price = self.product.cost_price
|
||||
elif self.kit:
|
||||
if not self.snapshot_name:
|
||||
self.snapshot_name = self.kit.name
|
||||
if not self.sale_price or not self.cost_price:
|
||||
# Здесь можно реализовать логику подсчета цены комплекта
|
||||
# в зависимости от метода ценообразования
|
||||
if self.kit.pricing_method == 'fixed' and self.kit.fixed_price:
|
||||
self.sale_price = self.kit.fixed_price
|
||||
# В реальном приложении нужно реализовать все методы ценообразования
|
||||
if self.kit.pricing_method != 'fixed' and not self.composition_snapshot:
|
||||
# Формирование снапшота состава комплекта
|
||||
composition = []
|
||||
for item in self.kit.kit_items.all():
|
||||
composition.append({
|
||||
"product_id": item.product.id,
|
||||
"name": item.product.name,
|
||||
"sku": item.product.sku,
|
||||
"quantity": float(item.quantity),
|
||||
"cost_price": float(item.product.cost_price),
|
||||
"sale_price": float(item.product.sale_price)
|
||||
})
|
||||
self.composition_snapshot = composition
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.snapshot_name} x{self.quantity} в заказе #{self.order.id}"
|
||||
3
myproject/orders/tests.py
Normal file
3
myproject/orders/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
3
myproject/orders/views.py
Normal file
3
myproject/orders/views.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.shortcuts import render
|
||||
|
||||
# Create your views here.
|
||||
0
myproject/products/__init__.py
Normal file
0
myproject/products/__init__.py
Normal file
310
myproject/products/admin.py
Normal file
310
myproject/products/admin.py
Normal file
@@ -0,0 +1,310 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
import nested_admin
|
||||
from .models import ProductCategory, ProductTag, Product, ProductKit, KitItem
|
||||
from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
|
||||
from .models import ProductVariantGroup, KitItemPriority, SKUCounter
|
||||
|
||||
|
||||
@admin.register(ProductVariantGroup)
|
||||
class ProductVariantGroupAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'get_products_count', 'created_at']
|
||||
search_fields = ['name', 'description']
|
||||
list_filter = ['created_at']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
def get_products_count(self, obj):
|
||||
return obj.products.count()
|
||||
get_products_count.short_description = 'Товаров'
|
||||
|
||||
|
||||
class ProductCategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ('photo_preview', 'name', 'sku', 'slug', 'parent', 'is_active')
|
||||
list_filter = ('is_active', 'parent')
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
search_fields = ('name', 'sku')
|
||||
readonly_fields = ('photo_preview_large',)
|
||||
|
||||
def photo_preview(self, obj):
|
||||
"""Превью фото в списке категорий"""
|
||||
first_photo = obj.photos.first()
|
||||
if first_photo and first_photo.image:
|
||||
return format_html(
|
||||
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />',
|
||||
first_photo.image.url
|
||||
)
|
||||
return "Нет фото"
|
||||
photo_preview.short_description = "Фото"
|
||||
|
||||
def photo_preview_large(self, obj):
|
||||
"""Большое превью фото в форме редактирования"""
|
||||
first_photo = obj.photos.first()
|
||||
if first_photo and first_photo.image:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 8px;" />',
|
||||
first_photo.image.url
|
||||
)
|
||||
return "Нет фото"
|
||||
photo_preview_large.short_description = "Превью основного фото"
|
||||
|
||||
|
||||
class ProductTagAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'slug')
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
search_fields = ('name',)
|
||||
|
||||
|
||||
class ProductAdmin(admin.ModelAdmin):
|
||||
list_display = ('photo_preview', 'name', 'sku', 'get_categories_display', 'cost_price', 'sale_price', 'get_variant_groups_display', 'is_active')
|
||||
list_filter = ('is_active', 'categories', 'tags', 'variant_groups')
|
||||
search_fields = ('name', 'sku', 'description', 'search_keywords')
|
||||
filter_horizontal = ('categories', 'tags', 'variant_groups')
|
||||
readonly_fields = ('photo_preview_large',)
|
||||
autocomplete_fields = []
|
||||
|
||||
fieldsets = (
|
||||
('Основная информация', {
|
||||
'fields': ('name', 'sku', 'variant_suffix', 'description', 'categories', 'unit')
|
||||
}),
|
||||
('Цены', {
|
||||
'fields': ('cost_price', 'sale_price')
|
||||
}),
|
||||
('Дополнительно', {
|
||||
'fields': ('tags', 'variant_groups', 'is_active')
|
||||
}),
|
||||
('Поиск', {
|
||||
'fields': ('search_keywords',),
|
||||
'classes': ('collapse',),
|
||||
'description': 'Поле для улучшенного поиска. Автоматически генерируется при сохранении, но вы можете добавить дополнительные ключевые слова (синонимы, альтернативные названия и т.д.).'
|
||||
}),
|
||||
('Фото', {
|
||||
'fields': ('photo_preview_large',),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
|
||||
def get_categories_display(self, obj):
|
||||
categories = obj.categories.all()[:3]
|
||||
if not categories:
|
||||
return "-"
|
||||
result = ", ".join([cat.name for cat in categories])
|
||||
if obj.categories.count() > 3:
|
||||
result += f" (+{obj.categories.count() - 3})"
|
||||
return result
|
||||
get_categories_display.short_description = 'Категории'
|
||||
|
||||
def get_variant_groups_display(self, obj):
|
||||
groups = obj.variant_groups.all()[:3]
|
||||
if not groups:
|
||||
return "-"
|
||||
result = ", ".join([g.name for g in groups])
|
||||
if obj.variant_groups.count() > 3:
|
||||
result += f" (+{obj.variant_groups.count() - 3})"
|
||||
return result
|
||||
get_variant_groups_display.short_description = 'Группы вариантов'
|
||||
|
||||
def photo_preview(self, obj):
|
||||
"""Превью фото в списке товаров"""
|
||||
first_photo = obj.photos.first()
|
||||
if first_photo and first_photo.image:
|
||||
return format_html(
|
||||
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />',
|
||||
first_photo.image.url
|
||||
)
|
||||
return "Нет фото"
|
||||
photo_preview.short_description = "Фото"
|
||||
|
||||
def photo_preview_large(self, obj):
|
||||
"""Большое превью фото в форме редактирования"""
|
||||
first_photo = obj.photos.first()
|
||||
if first_photo and first_photo.image:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 8px;" />',
|
||||
first_photo.image.url
|
||||
)
|
||||
return "Нет фото"
|
||||
photo_preview_large.short_description = "Превью основного фото"
|
||||
|
||||
|
||||
class ProductKitAdmin(admin.ModelAdmin):
|
||||
list_display = ('photo_preview', 'name', 'slug', 'get_categories_display', 'pricing_method', 'is_active')
|
||||
list_filter = ('is_active', 'pricing_method', 'categories', 'tags')
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
filter_horizontal = ('categories', 'tags')
|
||||
readonly_fields = ('photo_preview_large',)
|
||||
|
||||
def get_categories_display(self, obj):
|
||||
categories = obj.categories.all()[:3]
|
||||
if not categories:
|
||||
return "-"
|
||||
result = ", ".join([cat.name for cat in categories])
|
||||
if obj.categories.count() > 3:
|
||||
result += f" (+{obj.categories.count() - 3})"
|
||||
return result
|
||||
get_categories_display.short_description = 'Категории'
|
||||
|
||||
def photo_preview(self, obj):
|
||||
"""Превью фото в списке комплектов"""
|
||||
first_photo = obj.photos.first()
|
||||
if first_photo and first_photo.image:
|
||||
return format_html(
|
||||
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />',
|
||||
first_photo.image.url
|
||||
)
|
||||
return "Нет фото"
|
||||
photo_preview.short_description = "Фото"
|
||||
|
||||
def photo_preview_large(self, obj):
|
||||
"""Большое превью фото в форме редактирования"""
|
||||
first_photo = obj.photos.first()
|
||||
if first_photo and first_photo.image:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-width: 300px; max-height: 300px; border-radius: 8px;" />',
|
||||
first_photo.image.url
|
||||
)
|
||||
return "Нет фото"
|
||||
photo_preview_large.short_description = "Превью основного фото"
|
||||
|
||||
|
||||
class KitItemPriorityInline(nested_admin.NestedTabularInline):
|
||||
model = KitItemPriority
|
||||
extra = 0 # Не показывать пустые формы
|
||||
fields = ['product', 'priority']
|
||||
autocomplete_fields = ['product']
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
return qs.select_related('product')
|
||||
|
||||
def formfield_for_foreignkey(self, db_field, request, **kwargs):
|
||||
"""Показывать только товары из выбранной группы вариантов"""
|
||||
if db_field.name == "product":
|
||||
# Получаем kit_item из родительского объекта через request
|
||||
# Это будет работать автоматически с nested_admin
|
||||
pass
|
||||
return super().formfield_for_foreignkey(db_field, request, **kwargs)
|
||||
|
||||
|
||||
class KitItemInline(nested_admin.NestedStackedInline):
|
||||
model = KitItem
|
||||
extra = 0 # Не показывать пустые формы
|
||||
fields = ['product', 'variant_group', 'quantity', 'notes']
|
||||
autocomplete_fields = ['product']
|
||||
inlines = [KitItemPriorityInline]
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
'all': ('admin/css/custom_nested.css',)
|
||||
}
|
||||
|
||||
|
||||
class ProductPhotoInline(admin.TabularInline):
|
||||
model = ProductPhoto
|
||||
extra = 1
|
||||
readonly_fields = ('image_preview',)
|
||||
fields = ('image', 'image_preview', 'order')
|
||||
|
||||
def image_preview(self, obj):
|
||||
"""Превью загруженного фото"""
|
||||
if obj.image:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-width: 200px; max-height: 200px; border-radius: 4px;" />',
|
||||
obj.image.url
|
||||
)
|
||||
return "Нет изображения"
|
||||
image_preview.short_description = "Превью"
|
||||
|
||||
class ProductKitPhotoInline(nested_admin.NestedTabularInline):
|
||||
model = ProductKitPhoto
|
||||
extra = 0 # Не показывать пустые формы
|
||||
readonly_fields = ('image_preview',)
|
||||
fields = ('image', 'image_preview', 'order')
|
||||
|
||||
def image_preview(self, obj):
|
||||
"""Превью загруженного фото"""
|
||||
if obj.image:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-width: 200px; max-height: 200px; border-radius: 4px;" />',
|
||||
obj.image.url
|
||||
)
|
||||
return "Нет изображения"
|
||||
image_preview.short_description = "Превью"
|
||||
|
||||
class ProductCategoryPhotoInline(admin.TabularInline):
|
||||
model = ProductCategoryPhoto
|
||||
extra = 1
|
||||
readonly_fields = ('image_preview',)
|
||||
fields = ('image', 'image_preview', 'order')
|
||||
|
||||
def image_preview(self, obj):
|
||||
"""Превью загруженного фото"""
|
||||
if obj.image:
|
||||
return format_html(
|
||||
'<img src="{}" style="max-width: 200px; max-height: 200px; border-radius: 4px;" />',
|
||||
obj.image.url
|
||||
)
|
||||
return "Нет изображения"
|
||||
image_preview.short_description = "Превью"
|
||||
|
||||
class ProductKitAdminWithItems(ProductKitAdmin):
|
||||
inlines = [KitItemInline]
|
||||
|
||||
|
||||
# Update admin classes to include photo inlines
|
||||
class ProductAdminWithPhotos(ProductAdmin):
|
||||
inlines = [ProductPhotoInline]
|
||||
|
||||
class ProductKitAdminWithItemsAndPhotos(nested_admin.NestedModelAdmin, ProductKitAdmin):
|
||||
inlines = [KitItemInline, ProductKitPhotoInline]
|
||||
|
||||
class ProductCategoryAdminWithPhotos(ProductCategoryAdmin):
|
||||
inlines = [ProductCategoryPhotoInline]
|
||||
|
||||
|
||||
@admin.register(KitItem)
|
||||
class KitItemAdmin(admin.ModelAdmin):
|
||||
list_display = ['__str__', 'kit', 'get_type', 'quantity', 'has_priorities']
|
||||
list_filter = ['kit']
|
||||
list_select_related = ['kit', 'product', 'variant_group']
|
||||
inlines = [KitItemPriorityInline]
|
||||
fields = ['kit', 'product', 'variant_group', 'quantity', 'notes']
|
||||
|
||||
def get_type(self, obj):
|
||||
if obj.variant_group:
|
||||
return format_html('<span style="color: #0066cc;">Группа: {}</span>', obj.variant_group.name)
|
||||
return f"Товар: {obj.product.name if obj.product else '-'}"
|
||||
get_type.short_description = 'Тип'
|
||||
|
||||
def has_priorities(self, obj):
|
||||
return obj.priorities.exists()
|
||||
has_priorities.boolean = True
|
||||
has_priorities.short_description = 'Приоритеты настроены'
|
||||
|
||||
|
||||
@admin.register(SKUCounter)
|
||||
class SKUCounterAdmin(admin.ModelAdmin):
|
||||
list_display = ['counter_type', 'current_value', 'get_next_preview']
|
||||
list_filter = ['counter_type']
|
||||
readonly_fields = ['get_next_preview']
|
||||
|
||||
def get_next_preview(self, obj):
|
||||
"""Показывает, каким будет следующий артикул"""
|
||||
next_val = obj.current_value + 1
|
||||
if obj.counter_type == 'product':
|
||||
return format_html('<strong>PROD-{:06d}</strong>', next_val)
|
||||
elif obj.counter_type == 'kit':
|
||||
return format_html('<strong>KIT-{:06d}</strong>', next_val)
|
||||
elif obj.counter_type == 'category':
|
||||
return format_html('<strong>CAT-{:04d}</strong>', next_val)
|
||||
return str(next_val)
|
||||
get_next_preview.short_description = 'Следующий артикул'
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
# Запрещаем удаление счетчиков
|
||||
return False
|
||||
|
||||
|
||||
admin.site.register(ProductCategory, ProductCategoryAdminWithPhotos)
|
||||
admin.site.register(ProductTag, ProductTagAdmin)
|
||||
admin.site.register(Product, ProductAdminWithPhotos)
|
||||
admin.site.register(ProductKit, ProductKitAdminWithItemsAndPhotos)
|
||||
6
myproject/products/apps.py
Normal file
6
myproject/products/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProductsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'products'
|
||||
259
myproject/products/forms.py
Normal file
259
myproject/products/forms.py
Normal file
@@ -0,0 +1,259 @@
|
||||
from django import forms
|
||||
from django.forms import inlineformset_factory
|
||||
from .models import Product, ProductKit, ProductCategory, ProductTag, ProductPhoto, KitItem, ProductKitPhoto, ProductCategoryPhoto
|
||||
|
||||
|
||||
class ProductForm(forms.ModelForm):
|
||||
"""
|
||||
Форма для создания и редактирования товара.
|
||||
Поле photos НЕ включено в форму - файлы обрабатываются отдельно в view.
|
||||
"""
|
||||
categories = forms.ModelMultipleChoiceField(
|
||||
queryset=ProductCategory.objects.filter(is_active=True),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
label="Категории"
|
||||
)
|
||||
|
||||
tags = forms.ModelMultipleChoiceField(
|
||||
queryset=ProductTag.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
label="Теги"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Product
|
||||
fields = [
|
||||
'name', 'sku', 'description', 'categories',
|
||||
'tags', 'unit', 'cost_price', 'sale_price', 'is_active'
|
||||
]
|
||||
labels = {
|
||||
'name': 'Название',
|
||||
'sku': 'Артикул',
|
||||
'description': 'Описание',
|
||||
'categories': 'Категории',
|
||||
'tags': 'Теги',
|
||||
'unit': 'Единица измерения',
|
||||
'cost_price': 'Себестоимость',
|
||||
'sale_price': 'Цена продажи',
|
||||
'is_active': 'Активен'
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Make fields more user-friendly
|
||||
self.fields['name'].widget.attrs.update({
|
||||
'class': 'form-control form-control-lg fw-semibold',
|
||||
'placeholder': 'Введите название товара'
|
||||
})
|
||||
self.fields['sku'].widget.attrs.update({
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Артикул (необязательно, будет сгенерирован автоматически)'
|
||||
})
|
||||
self.fields['description'].widget.attrs.update({
|
||||
'class': 'form-control',
|
||||
'rows': 3
|
||||
})
|
||||
self.fields['cost_price'].widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['sale_price'].widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['unit'].widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
|
||||
|
||||
|
||||
class ProductKitForm(forms.ModelForm):
|
||||
"""
|
||||
Форма для создания и редактирования комплекта.
|
||||
"""
|
||||
categories = forms.ModelMultipleChoiceField(
|
||||
queryset=ProductCategory.objects.filter(is_active=True),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
label="Категории"
|
||||
)
|
||||
|
||||
tags = forms.ModelMultipleChoiceField(
|
||||
queryset=ProductTag.objects.all(),
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
label="Теги"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ProductKit
|
||||
fields = [
|
||||
'name', 'description', 'categories',
|
||||
'tags', 'pricing_method', 'fixed_price', 'markup_percent', 'markup_amount', 'is_active'
|
||||
]
|
||||
labels = {
|
||||
'name': 'Название',
|
||||
'description': 'Описание',
|
||||
'categories': 'Категории',
|
||||
'tags': 'Теги',
|
||||
'pricing_method': 'Метод ценообразования',
|
||||
'fixed_price': 'Фиксированная цена',
|
||||
'markup_percent': 'Процент наценки',
|
||||
'markup_amount': 'Фиксированная наценка',
|
||||
'is_active': 'Активен'
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Make fields more user-friendly
|
||||
self.fields['name'].widget.attrs.update({
|
||||
'class': 'form-control',
|
||||
'placeholder': 'Введите название комплекта'
|
||||
})
|
||||
self.fields['description'].widget.attrs.update({
|
||||
'class': 'form-control',
|
||||
'rows': 3
|
||||
})
|
||||
self.fields['pricing_method'].widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['fixed_price'].widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['markup_percent'].widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['markup_amount'].widget.attrs.update({'class': 'form-control'})
|
||||
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
|
||||
|
||||
|
||||
class KitItemForm(forms.ModelForm):
|
||||
"""
|
||||
Форма для одного компонента комплекта.
|
||||
Валидирует, что указан либо product, либо variant_group (но не оба).
|
||||
Если обе поля пусты - это пустая форма, которая будет удалена.
|
||||
"""
|
||||
class Meta:
|
||||
model = KitItem
|
||||
fields = ['product', 'variant_group', 'quantity', 'notes']
|
||||
labels = {
|
||||
'product': 'Конкретный товар',
|
||||
'variant_group': 'Группа вариантов',
|
||||
'quantity': 'Количество',
|
||||
'notes': 'Примечание'
|
||||
}
|
||||
widgets = {
|
||||
'product': forms.Select(attrs={'class': 'form-control'}),
|
||||
'variant_group': forms.Select(attrs={'class': 'form-control'}),
|
||||
'quantity': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.001', 'min': '0'}),
|
||||
'notes': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Опциональное примечание'}),
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
product = cleaned_data.get('product')
|
||||
variant_group = cleaned_data.get('variant_group')
|
||||
|
||||
# Если оба поля пусты - это пустая форма (не валидируем, она будет удалена)
|
||||
if not product and not variant_group:
|
||||
return cleaned_data
|
||||
|
||||
# Валидация: должен быть указан либо product, либо variant_group (но не оба)
|
||||
if product and variant_group:
|
||||
raise forms.ValidationError(
|
||||
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
|
||||
)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
|
||||
# Формсет для создания комплектов (с пустой формой для удобства)
|
||||
KitItemFormSetCreate = inlineformset_factory(
|
||||
ProductKit,
|
||||
KitItem,
|
||||
form=KitItemForm,
|
||||
fields=['id', 'product', 'variant_group', 'quantity', 'notes'],
|
||||
extra=1, # Показать 1 пустую форму для первого компонента
|
||||
can_delete=True, # Разрешить удаление компонентов
|
||||
min_num=0, # Минимум 0 компонентов (можно создать пустой комплект)
|
||||
validate_min=False, # Не требовать минимум компонентов
|
||||
can_delete_extra=True, # Разрешить удалять дополнительные формы
|
||||
)
|
||||
|
||||
# Формсет для редактирования комплектов (без пустых форм, только существующие компоненты)
|
||||
KitItemFormSetUpdate = inlineformset_factory(
|
||||
ProductKit,
|
||||
KitItem,
|
||||
form=KitItemForm,
|
||||
fields=['id', 'product', 'variant_group', 'quantity', 'notes'],
|
||||
extra=0, # НЕ показывать пустые формы при редактировании
|
||||
can_delete=True, # Разрешить удаление компонентов
|
||||
min_num=0, # Минимум 0 компонентов
|
||||
validate_min=False, # Не требовать минимум компонентов
|
||||
can_delete_extra=True, # Разрешить удалять дополнительные формы
|
||||
)
|
||||
|
||||
# Для обратной совместимости (если где-то еще используется KitItemFormSet)
|
||||
KitItemFormSet = KitItemFormSetCreate
|
||||
|
||||
|
||||
class ProductCategoryForm(forms.ModelForm):
|
||||
"""
|
||||
Форма для создания и редактирования категории товаров.
|
||||
Поле photos НЕ включено в форму - файлы обрабатываются отдельно в view.
|
||||
"""
|
||||
parent = forms.ModelChoiceField(
|
||||
queryset=ProductCategory.objects.filter(is_active=True),
|
||||
required=False,
|
||||
empty_label="Нет (корневая категория)",
|
||||
label="Родительская категория",
|
||||
widget=forms.Select(attrs={'class': 'form-control'})
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ProductCategory
|
||||
fields = ['name', 'sku', 'slug', 'parent', 'is_active']
|
||||
labels = {
|
||||
'name': 'Название',
|
||||
'sku': 'Артикул',
|
||||
'slug': 'URL-идентификатор',
|
||||
'parent': 'Родительская категория',
|
||||
'is_active': 'Активна'
|
||||
}
|
||||
help_texts = {
|
||||
'sku': 'Оставьте пустым для автоматической генерации (CAT-XXXX)',
|
||||
'slug': 'Оставьте пустым для автоматической генерации из названия',
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Make fields more user-friendly
|
||||
self.fields['name'].widget.attrs.update({
|
||||
'class': 'form-control form-control-lg fw-semibold',
|
||||
'placeholder': 'Введите название категории'
|
||||
})
|
||||
self.fields['sku'].widget.attrs.update({
|
||||
'class': 'form-control',
|
||||
'placeholder': 'CAT-XXXX (автоматически)'
|
||||
})
|
||||
self.fields['slug'].widget.attrs.update({
|
||||
'class': 'form-control',
|
||||
'placeholder': 'url-identifier (автоматически)'
|
||||
})
|
||||
self.fields['slug'].required = False # Делаем поле необязательным
|
||||
self.fields['is_active'].widget.attrs.update({'class': 'form-check-input'})
|
||||
|
||||
# Исключаем текущую категорию и её потомков из списка родительских
|
||||
# (чтобы не создать циклическую зависимость)
|
||||
if self.instance and self.instance.pk:
|
||||
# Получаем все потомки текущей категории
|
||||
descendants = self._get_descendants(self.instance)
|
||||
# Исключаем текущую категорию и все её потомки
|
||||
exclude_ids = [self.instance.pk] + [cat.pk for cat in descendants]
|
||||
self.fields['parent'].queryset = ProductCategory.objects.filter(
|
||||
is_active=True
|
||||
).exclude(pk__in=exclude_ids)
|
||||
|
||||
def clean_slug(self):
|
||||
"""Преобразуем пустую строку в None для автогенерации slug"""
|
||||
slug = self.cleaned_data.get('slug')
|
||||
if slug == '' or slug is None:
|
||||
return None
|
||||
return slug
|
||||
|
||||
def _get_descendants(self, category):
|
||||
"""Рекурсивно получает всех потомков категории"""
|
||||
descendants = []
|
||||
children = category.children.all()
|
||||
for child in children:
|
||||
descendants.append(child)
|
||||
descendants.extend(self._get_descendants(child))
|
||||
return descendants
|
||||
0
myproject/products/management/__init__.py
Normal file
0
myproject/products/management/__init__.py
Normal file
0
myproject/products/management/commands/__init__.py
Normal file
0
myproject/products/management/commands/__init__.py
Normal file
245
myproject/products/management/commands/demo_variants.py
Normal file
245
myproject/products/management/commands/demo_variants.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Management команда для демонстрации работы системы вариантов товаров.
|
||||
|
||||
Использование:
|
||||
python manage.py demo_variants
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from django.core.management.base import BaseCommand
|
||||
from products.models import (
|
||||
Product, ProductKit, KitItem, ProductCategory,
|
||||
ProductVariantGroup, KitItemPriority
|
||||
)
|
||||
from products.utils.stock_manager import StockManager
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Демонстрация работы системы вариантов товаров'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write("\n" + "="*60)
|
||||
self.stdout.write(self.style.SUCCESS("ДЕМОНСТРАЦИЯ СИСТЕМЫ ВАРИАНТОВ ТОВАРОВ"))
|
||||
self.stdout.write("="*60 + "\n")
|
||||
|
||||
# Создаём демо-данные
|
||||
group, rose_50, rose_60, rose_70 = self.create_variant_group()
|
||||
|
||||
# Создаём букеты
|
||||
premium_kit = self.create_premium_bouquet(group, rose_50, rose_60, rose_70)
|
||||
economy_kit = self.create_economy_bouquet(group, rose_50, rose_60, rose_70)
|
||||
|
||||
# Проверяем доступность
|
||||
self.check_availability(premium_kit, economy_kit)
|
||||
|
||||
# Получаем лучший товар
|
||||
self.show_best_product(premium_kit)
|
||||
|
||||
self.stdout.write("\n" + "="*60)
|
||||
self.stdout.write(self.style.SUCCESS("ДЕМОНСТРАЦИЯ ЗАВЕРШЕНА"))
|
||||
self.stdout.write("="*60 + "\n")
|
||||
|
||||
def create_variant_group(self):
|
||||
"""Создание группы вариантов"""
|
||||
self.stdout.write("\n" + "="*60)
|
||||
self.stdout.write(self.style.HTTP_INFO("ШАГ 1: Создание группы вариантов"))
|
||||
self.stdout.write("="*60)
|
||||
|
||||
# Создаём категорию
|
||||
category, _ = ProductCategory.objects.get_or_create(
|
||||
name="Цветы",
|
||||
defaults={'slug': 'cvety'}
|
||||
)
|
||||
|
||||
# Создаём товары - розы разной длины
|
||||
rose_50, _ = Product.objects.get_or_create(
|
||||
name="Роза Freedom 50см красная",
|
||||
defaults={
|
||||
'cost_price': Decimal('80.00'),
|
||||
'sale_price': Decimal('100.00'),
|
||||
'category': category
|
||||
}
|
||||
)
|
||||
|
||||
rose_60, _ = Product.objects.get_or_create(
|
||||
name="Роза Freedom 60см красная",
|
||||
defaults={
|
||||
'cost_price': Decimal('120.00'),
|
||||
'sale_price': Decimal('150.00'),
|
||||
'category': category
|
||||
}
|
||||
)
|
||||
|
||||
rose_70, _ = Product.objects.get_or_create(
|
||||
name="Роза Freedom 70см красная",
|
||||
defaults={
|
||||
'cost_price': Decimal('160.00'),
|
||||
'sale_price': Decimal('200.00'),
|
||||
'category': category
|
||||
}
|
||||
)
|
||||
|
||||
# Создаём группу вариантов
|
||||
group, created = ProductVariantGroup.objects.get_or_create(
|
||||
name="Роза красная Freedom",
|
||||
defaults={
|
||||
'description': 'Красная роза Freedom различной длины (50-70см)'
|
||||
}
|
||||
)
|
||||
|
||||
# Добавляем товары в группу
|
||||
rose_50.variant_groups.add(group)
|
||||
rose_60.variant_groups.add(group)
|
||||
rose_70.variant_groups.add(group)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"[OK] Создана группа: {group.name}"))
|
||||
self.stdout.write(f" Товаров в группе: {group.get_products_count()}")
|
||||
self.stdout.write(" Товары:")
|
||||
for product in group.products.all():
|
||||
self.stdout.write(f" - {product.name} ({product.sale_price} руб.)")
|
||||
|
||||
return group, rose_50, rose_60, rose_70
|
||||
|
||||
def create_premium_bouquet(self, group, rose_50, rose_60, rose_70):
|
||||
"""Создание премиум букета"""
|
||||
self.stdout.write("\n" + "="*60)
|
||||
self.stdout.write(self.style.HTTP_INFO("ШАГ 2: Создание премиум букета"))
|
||||
self.stdout.write("="*60)
|
||||
|
||||
# Создаём букет
|
||||
kit, _ = ProductKit.objects.get_or_create(
|
||||
name="Ранчо Виталия Премиум",
|
||||
defaults={
|
||||
'slug': 'rancho-vitaliya-premium',
|
||||
'pricing_method': 'from_sale_prices'
|
||||
}
|
||||
)
|
||||
|
||||
# Создаём позицию с группой вариантов
|
||||
kit_item, _ = KitItem.objects.get_or_create(
|
||||
kit=kit,
|
||||
variant_group=group,
|
||||
defaults={
|
||||
'quantity': Decimal('15.000'),
|
||||
'notes': 'Использовать самые длинные розы'
|
||||
}
|
||||
)
|
||||
|
||||
# Настраиваем приоритеты (для премиум букета - сначала длинные)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_70,
|
||||
defaults={'priority': 0}
|
||||
)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_60,
|
||||
defaults={'priority': 1}
|
||||
)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_50,
|
||||
defaults={'priority': 2}
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"[OK] Создан букет: {kit.name}"))
|
||||
self.stdout.write(f" Позиций: {kit.get_total_components_count()}")
|
||||
self.stdout.write(f" С вариантами: {kit.get_components_with_variants_count()}")
|
||||
self.stdout.write(f"\n Приоритеты для позиции '{kit_item.get_display_name()}':")
|
||||
for priority in kit_item.priorities.all().order_by('priority'):
|
||||
self.stdout.write(f" {priority.priority}. {priority.product.name} - {priority.product.sale_price} руб.")
|
||||
|
||||
return kit
|
||||
|
||||
def create_economy_bouquet(self, group, rose_50, rose_60, rose_70):
|
||||
"""Создание эконом букета"""
|
||||
self.stdout.write("\n" + "="*60)
|
||||
self.stdout.write(self.style.HTTP_INFO("ШАГ 3: Создание эконом букета"))
|
||||
self.stdout.write("="*60)
|
||||
|
||||
kit, _ = ProductKit.objects.get_or_create(
|
||||
name="Ранчо Виталия Эконом",
|
||||
defaults={
|
||||
'slug': 'rancho-vitaliya-econom',
|
||||
'pricing_method': 'from_sale_prices'
|
||||
}
|
||||
)
|
||||
|
||||
kit_item, _ = KitItem.objects.get_or_create(
|
||||
kit=kit,
|
||||
variant_group=group,
|
||||
defaults={
|
||||
'quantity': Decimal('15.000'),
|
||||
'notes': 'Эконом вариант'
|
||||
}
|
||||
)
|
||||
|
||||
# Для эконом букета - сначала короткие (дешевые)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_50,
|
||||
defaults={'priority': 0}
|
||||
)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_60,
|
||||
defaults={'priority': 1}
|
||||
)
|
||||
KitItemPriority.objects.get_or_create(
|
||||
kit_item=kit_item,
|
||||
product=rose_70,
|
||||
defaults={'priority': 2}
|
||||
)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"[OK] Создан букет: {kit.name}"))
|
||||
self.stdout.write(f"\n Приоритеты для позиции '{kit_item.get_display_name()}':")
|
||||
for priority in kit_item.priorities.all().order_by('priority'):
|
||||
self.stdout.write(f" {priority.priority}. {priority.product.name} - {priority.product.sale_price} руб.")
|
||||
|
||||
return kit
|
||||
|
||||
def check_availability(self, premium_kit, economy_kit):
|
||||
"""Проверка доступности"""
|
||||
self.stdout.write("\n" + "="*60)
|
||||
self.stdout.write(self.style.HTTP_INFO("ШАГ 4: Проверка доступности букетов"))
|
||||
self.stdout.write("="*60)
|
||||
|
||||
stock_manager = StockManager()
|
||||
|
||||
# Проверяем премиум букет
|
||||
self.stdout.write(f"\nПремиум букет: {premium_kit.name}")
|
||||
if premium_kit.check_availability(stock_manager):
|
||||
self.stdout.write(self.style.SUCCESS(" [OK] Доступен для сборки"))
|
||||
price = premium_kit.calculate_price_with_substitutions(stock_manager)
|
||||
self.stdout.write(f" Цена: {price} руб.")
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR(" [ERROR] Недоступен"))
|
||||
|
||||
# Проверяем эконом букет
|
||||
self.stdout.write(f"\nЭконом букет: {economy_kit.name}")
|
||||
if economy_kit.check_availability(stock_manager):
|
||||
self.stdout.write(self.style.SUCCESS(" [OK] Доступен для сборки"))
|
||||
price = economy_kit.calculate_price_with_substitutions(stock_manager)
|
||||
self.stdout.write(f" Цена: {price} руб.")
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR(" [ERROR] Недоступен"))
|
||||
|
||||
def show_best_product(self, kit):
|
||||
"""Показать лучший доступный товар"""
|
||||
self.stdout.write("\n" + "="*60)
|
||||
self.stdout.write(self.style.HTTP_INFO("ШАГ 5: Выбор лучшего доступного товара"))
|
||||
self.stdout.write("="*60)
|
||||
|
||||
stock_manager = StockManager()
|
||||
|
||||
for kit_item in kit.kit_items.all():
|
||||
self.stdout.write(f"\nПозиция: {kit_item.get_display_name()}")
|
||||
self.stdout.write(f"Количество: {kit_item.quantity}")
|
||||
|
||||
best_product = kit_item.get_best_available_product(stock_manager)
|
||||
if best_product:
|
||||
self.stdout.write(self.style.SUCCESS(f"[OK] Лучший доступный товар: {best_product.name}"))
|
||||
self.stdout.write(f" Цена: {best_product.sale_price} руб.")
|
||||
self.stdout.write(f" Стоимость позиции: {best_product.sale_price * kit_item.quantity} руб.")
|
||||
else:
|
||||
self.stdout.write(self.style.ERROR("[ERROR] Нет доступных товаров"))
|
||||
34
myproject/products/management/commands/fix_category_slugs.py
Normal file
34
myproject/products/management/commands/fix_category_slugs.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils.text import slugify
|
||||
from unidecode import unidecode
|
||||
from products.models import ProductCategory
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Fixes category slugs by converting Cyrillic to Latin transliteration'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
categories = ProductCategory.objects.all()
|
||||
fixed_count = 0
|
||||
|
||||
for category in categories:
|
||||
old_slug = category.slug
|
||||
# Generate new slug with Latin transliteration
|
||||
transliterated_name = unidecode(category.name)
|
||||
new_slug = slugify(transliterated_name)
|
||||
|
||||
if old_slug != new_slug:
|
||||
category.slug = new_slug
|
||||
category.save()
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f'Fixed: "{category.name}" | {old_slug} -> {new_slug}'
|
||||
)
|
||||
)
|
||||
fixed_count += 1
|
||||
else:
|
||||
self.stdout.write(f'OK: "{category.name}" | {old_slug}')
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'\nTotal fixed: {fixed_count} categories')
|
||||
)
|
||||
206
myproject/products/migrations/0001_initial.py
Normal file
206
myproject/products/migrations/0001_initial.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-21 14:41
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProductTag',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True, verbose_name='Название')),
|
||||
('slug', models.SlugField(max_length=100, unique=True, verbose_name='URL-идентификатор')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Тег товара',
|
||||
'verbose_name_plural': 'Теги товаров',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductVariantGroup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Название')),
|
||||
('description', models.TextField(blank=True, verbose_name='Описание')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Группа вариантов',
|
||||
'verbose_name_plural': 'Группы вариантов',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='SKUCounter',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('counter_type', models.CharField(choices=[('product', 'Product Counter'), ('kit', 'Kit Counter')], max_length=20, unique=True, verbose_name='Тип счетчика')),
|
||||
('current_value', models.IntegerField(default=0, verbose_name='Текущее значение')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Счетчик артикулов',
|
||||
'verbose_name_plural': 'Счетчики артикулов',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductCategory',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Название')),
|
||||
('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL-идентификатор')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='products.productcategory', verbose_name='Родительская категория')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Категория товара',
|
||||
'verbose_name_plural': 'Категории товаров',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Product',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Название')),
|
||||
('sku', models.CharField(blank=True, db_index=True, max_length=100, null=True, verbose_name='Артикул')),
|
||||
('variant_suffix', models.CharField(blank=True, help_text='Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия.', max_length=20, null=True, verbose_name='Суффикс варианта')),
|
||||
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
|
||||
('unit', models.CharField(choices=[('шт', 'Штука'), ('м', 'Метр'), ('г', 'Грамм'), ('л', 'Литр'), ('кг', 'Килограмм')], default='шт', max_length=10, verbose_name='Единица измерения')),
|
||||
('cost_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Себестоимость')),
|
||||
('sale_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Розничная цена')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('search_keywords', models.TextField(blank=True, help_text='Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную.', verbose_name='Ключевые слова для поиска')),
|
||||
('categories', models.ManyToManyField(blank=True, related_name='products', to='products.productcategory', verbose_name='Категории')),
|
||||
('tags', models.ManyToManyField(blank=True, related_name='products', to='products.producttag', verbose_name='Теги')),
|
||||
('variant_groups', models.ManyToManyField(blank=True, related_name='products', to='products.productvariantgroup', verbose_name='Группы вариантов')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Товар',
|
||||
'verbose_name_plural': 'Товары',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductCategoryPhoto',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(upload_to='categories/', verbose_name='Фото')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productcategory', verbose_name='Категория')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Фото категории',
|
||||
'verbose_name_plural': 'Фото категорий',
|
||||
'ordering': ['order', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductKit',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Название')),
|
||||
('sku', models.CharField(blank=True, max_length=100, null=True, verbose_name='Артикул')),
|
||||
('slug', models.SlugField(max_length=200, unique=True, verbose_name='URL-идентификатор')),
|
||||
('description', models.TextField(blank=True, null=True, verbose_name='Описание')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Активен')),
|
||||
('pricing_method', models.CharField(choices=[('fixed', 'Фиксированная цена'), ('from_sale_prices', 'По ценам продажи компонентов'), ('from_cost_plus_percent', 'Себестоимость + процент наценки'), ('from_cost_plus_amount', 'Себестоимость + фикс. наценка')], default='from_sale_prices', max_length=30, verbose_name='Метод ценообразования')),
|
||||
('fixed_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Фиксированная цена')),
|
||||
('markup_percent', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='Процент наценки')),
|
||||
('markup_amount', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Фиксированная наценка')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
|
||||
('categories', models.ManyToManyField(blank=True, related_name='kits', to='products.productcategory', verbose_name='Категории')),
|
||||
('tags', models.ManyToManyField(blank=True, related_name='kits', to='products.producttag', verbose_name='Теги')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Комплект',
|
||||
'verbose_name_plural': 'Комплекты',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductKitPhoto',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(upload_to='kits/', verbose_name='Фото')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.productkit', verbose_name='Комплект')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Фото комплекта',
|
||||
'verbose_name_plural': 'Фото комплектов',
|
||||
'ordering': ['order', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductPhoto',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(upload_to='products/', verbose_name='Фото')),
|
||||
('order', models.PositiveIntegerField(default=0, verbose_name='Порядок')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='products.product', verbose_name='Товар')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Фото товара',
|
||||
'verbose_name_plural': 'Фото товаров',
|
||||
'ordering': ['order', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='KitItem',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.DecimalField(decimal_places=3, max_digits=10, verbose_name='Количество')),
|
||||
('notes', models.CharField(blank=True, max_length=200, verbose_name='Примечание')),
|
||||
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kit_items_direct', to='products.product', verbose_name='Конкретный товар')),
|
||||
('kit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kit_items', to='products.productkit', verbose_name='Комплект')),
|
||||
('variant_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='kit_items', to='products.productvariantgroup', verbose_name='Группа вариантов')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Компонент комплекта',
|
||||
'verbose_name_plural': 'Компоненты комплектов',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='KitItemPriority',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('priority', models.PositiveIntegerField(default=0, help_text='Меньше = выше приоритет (0 - наивысший)')),
|
||||
('kit_item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='priorities', to='products.kititem', verbose_name='Позиция в букете')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='products.product', verbose_name='Товар')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Приоритет варианта',
|
||||
'verbose_name_plural': 'Приоритеты вариантов',
|
||||
'ordering': ['priority', 'id'],
|
||||
'unique_together': {('kit_item', 'product')},
|
||||
},
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='productcategory',
|
||||
index=models.Index(fields=['is_active'], name='products_pr_is_acti_226e74_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='productkit',
|
||||
index=models.Index(fields=['is_active'], name='products_pr_is_acti_214d4f_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='productkit',
|
||||
index=models.Index(fields=['slug'], name='products_pr_slug_b5e185_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='product',
|
||||
index=models.Index(fields=['is_active'], name='products_pr_is_acti_ca4d9a_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-21 17:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='productcategory',
|
||||
name='sku',
|
||||
field=models.CharField(blank=True, db_index=True, max_length=100, null=True, unique=True, verbose_name='Артикул'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='skucounter',
|
||||
name='counter_type',
|
||||
field=models.CharField(choices=[('product', 'Product Counter'), ('kit', 'Kit Counter'), ('category', 'Category Counter')], max_length=20, unique=True, verbose_name='Тип счетчика'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.7 on 2025-10-21 19:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('products', '0002_productcategory_sku_alter_skucounter_counter_type'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='productcategory',
|
||||
name='slug',
|
||||
field=models.SlugField(blank=True, max_length=200, unique=True, verbose_name='URL-идентификатор'),
|
||||
),
|
||||
]
|
||||
0
myproject/products/migrations/__init__.py
Normal file
0
myproject/products/migrations/__init__.py
Normal file
618
myproject/products/models.py
Normal file
618
myproject/products/models.py
Normal file
@@ -0,0 +1,618 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
|
||||
from .utils.sku_generator import generate_product_sku, generate_kit_sku, generate_category_sku
|
||||
|
||||
|
||||
class SKUCounter(models.Model):
|
||||
"""
|
||||
Глобальные счетчики для генерации уникальных номеров артикулов.
|
||||
Используется для товаров (product), комплектов (kit) и категорий (category).
|
||||
"""
|
||||
COUNTER_TYPE_CHOICES = [
|
||||
('product', 'Product Counter'),
|
||||
('kit', 'Kit Counter'),
|
||||
('category', 'Category Counter'),
|
||||
]
|
||||
|
||||
counter_type = models.CharField(
|
||||
max_length=20,
|
||||
unique=True,
|
||||
choices=COUNTER_TYPE_CHOICES,
|
||||
verbose_name="Тип счетчика"
|
||||
)
|
||||
current_value = models.IntegerField(
|
||||
default=0,
|
||||
verbose_name="Текущее значение"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Счетчик артикулов"
|
||||
verbose_name_plural = "Счетчики артикулов"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_counter_type_display()}: {self.current_value}"
|
||||
|
||||
@classmethod
|
||||
def get_next_value(cls, counter_type):
|
||||
"""
|
||||
Получить следующее значение счетчика (thread-safe).
|
||||
Использует select_for_update для предотвращения race conditions.
|
||||
"""
|
||||
with transaction.atomic():
|
||||
counter, created = cls.objects.select_for_update().get_or_create(
|
||||
counter_type=counter_type,
|
||||
defaults={'current_value': 0}
|
||||
)
|
||||
counter.current_value += 1
|
||||
counter.save()
|
||||
return counter.current_value
|
||||
|
||||
|
||||
class ActiveManager(models.Manager):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(is_active=True)
|
||||
|
||||
|
||||
class ProductCategory(models.Model):
|
||||
"""
|
||||
Категории товаров и комплектов (поддержка нескольких уровней не обязательна, но возможна позже).
|
||||
"""
|
||||
name = models.CharField(max_length=200, verbose_name="Название")
|
||||
sku = models.CharField(max_length=100, blank=True, null=True, unique=True, verbose_name="Артикул", db_index=True)
|
||||
slug = models.SlugField(max_length=200, unique=True, blank=True, verbose_name="URL-идентификатор")
|
||||
parent = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True,
|
||||
related_name='children', verbose_name="Родительская категория")
|
||||
is_active = models.BooleanField(default=True, verbose_name="Активна")
|
||||
|
||||
objects = models.Manager() # Менеджер по умолчанию
|
||||
active = ActiveManager() # Кастомный менеджер для активных категорий
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Категория товара"
|
||||
verbose_name_plural = "Категории товаров"
|
||||
indexes = [
|
||||
models.Index(fields=['is_active']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
"""Валидация категории перед сохранением"""
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
# 1. Защита от самоссылки
|
||||
if self.parent and self.parent.pk == self.pk:
|
||||
raise ValidationError({
|
||||
'parent': 'Категория не может быть родителем самой себя.'
|
||||
})
|
||||
|
||||
# 2. Защита от циклических ссылок (только для существующих категорий)
|
||||
if self.parent and self.pk:
|
||||
self._check_parent_chain()
|
||||
|
||||
# 3. Проверка активности родителя
|
||||
if self.parent and not self.parent.is_active:
|
||||
raise ValidationError({
|
||||
'parent': 'Нельзя выбрать неактивную категорию в качестве родителя.'
|
||||
})
|
||||
|
||||
def _check_parent_chain(self):
|
||||
"""Проверяет цепочку родителей на циклы и глубину вложенности"""
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
|
||||
current = self.parent
|
||||
depth = 0
|
||||
max_depth = getattr(settings, 'MAX_CATEGORY_DEPTH', 10)
|
||||
|
||||
while current:
|
||||
if current.pk == self.pk:
|
||||
raise ValidationError({
|
||||
'parent': f'Обнаружена циклическая ссылка. '
|
||||
f'Категория "{self.name}" не может быть потомком самой себя.'
|
||||
})
|
||||
|
||||
depth += 1
|
||||
if depth > max_depth:
|
||||
raise ValidationError({
|
||||
'parent': f'Слишком глубокая вложенность категорий '
|
||||
f'(максимум {max_depth} уровней).'
|
||||
})
|
||||
|
||||
current = current.parent
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Вызываем валидацию перед сохранением
|
||||
self.full_clean()
|
||||
|
||||
# Автоматическая генерация slug из названия с транслитерацией
|
||||
if not self.slug or self.slug.strip() == '':
|
||||
from unidecode import unidecode
|
||||
# Транслитерируем кириллицу в латиницу, затем применяем slugify
|
||||
transliterated_name = unidecode(self.name)
|
||||
self.slug = slugify(transliterated_name)
|
||||
|
||||
# Автоматическая генерация артикула при создании новой категории
|
||||
if not self.sku and not self.pk:
|
||||
from .utils.sku_generator import generate_category_sku
|
||||
self.sku = generate_category_sku()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ProductTag(models.Model):
|
||||
"""
|
||||
Свободные теги для фильтрации и поиска.
|
||||
"""
|
||||
name = models.CharField(max_length=100, unique=True, verbose_name="Название")
|
||||
slug = models.SlugField(max_length=100, unique=True, verbose_name="URL-идентификатор")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Тег товара"
|
||||
verbose_name_plural = "Теги товаров"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class ProductVariantGroup(models.Model):
|
||||
"""
|
||||
Группа вариантов товара (взаимозаменяемые товары).
|
||||
Например: "Роза красная Freedom" включает розы 50см, 60см, 70см.
|
||||
"""
|
||||
name = models.CharField(max_length=200, verbose_name="Название")
|
||||
description = models.TextField(blank=True, verbose_name="Описание")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Группа вариантов"
|
||||
verbose_name_plural = "Группы вариантов"
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_products_count(self):
|
||||
"""Возвращает количество товаров в группе"""
|
||||
return self.products.count()
|
||||
|
||||
|
||||
class Product(models.Model):
|
||||
"""
|
||||
Базовый товар (цветок, упаковка, аксессуар).
|
||||
"""
|
||||
UNIT_CHOICES = [
|
||||
('шт', 'Штука'),
|
||||
('м', 'Метр'),
|
||||
('г', 'Грамм'),
|
||||
('л', 'Литр'),
|
||||
('кг', 'Килограмм'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=200, verbose_name="Название")
|
||||
sku = models.CharField(max_length=100, blank=True, null=True, verbose_name="Артикул", db_index=True)
|
||||
variant_suffix = models.CharField(
|
||||
max_length=20,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name="Суффикс варианта",
|
||||
help_text="Суффикс для артикула (например: 50, 60, S, M). Автоматически извлекается из названия."
|
||||
)
|
||||
description = models.TextField(blank=True, null=True, verbose_name="Описание")
|
||||
categories = models.ManyToManyField(
|
||||
ProductCategory,
|
||||
blank=True,
|
||||
related_name='products',
|
||||
verbose_name="Категории"
|
||||
)
|
||||
tags = models.ManyToManyField(ProductTag, blank=True, related_name='products', verbose_name="Теги")
|
||||
variant_groups = models.ManyToManyField(
|
||||
ProductVariantGroup,
|
||||
blank=True,
|
||||
related_name='products',
|
||||
verbose_name="Группы вариантов"
|
||||
)
|
||||
unit = models.CharField(max_length=10, choices=UNIT_CHOICES, default='шт', verbose_name="Единица измерения")
|
||||
cost_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Себестоимость")
|
||||
sale_price = models.DecimalField(max_digits=10, decimal_places=2, verbose_name="Розничная цена")
|
||||
is_active = models.BooleanField(default=True, verbose_name="Активен")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||
|
||||
# Поле для улучшенного поиска (задел на будущее)
|
||||
search_keywords = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Ключевые слова для поиска",
|
||||
help_text="Автоматически генерируется из названия, артикула, описания и категории. Можно дополнить вручную."
|
||||
)
|
||||
|
||||
objects = models.Manager() # Менеджер по умолчанию
|
||||
active = ActiveManager() # Кастомный менеджер для активных товаров
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Товар"
|
||||
verbose_name_plural = "Товары"
|
||||
indexes = [
|
||||
models.Index(fields=['is_active']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Автоматическое извлечение variant_suffix из названия
|
||||
# (только если не задан вручную и товар еще не сохранен с суффиксом)
|
||||
if not self.variant_suffix and self.name:
|
||||
from .utils.sku_generator import parse_variant_suffix
|
||||
parsed_suffix = parse_variant_suffix(self.name)
|
||||
if parsed_suffix:
|
||||
self.variant_suffix = parsed_suffix
|
||||
|
||||
# Генерация артикула для новых товаров
|
||||
if not self.sku:
|
||||
self.sku = generate_product_sku(self)
|
||||
|
||||
# Автоматическая генерация ключевых слов для поиска
|
||||
# Собираем все релевантные данные в одну строку
|
||||
keywords_parts = [
|
||||
self.name or '',
|
||||
self.sku or '',
|
||||
self.description or '',
|
||||
]
|
||||
|
||||
# Генерируем строку для поиска (только если поле пустое)
|
||||
# Это позволит администратору добавлять кастомные ключевые слова вручную
|
||||
if not self.search_keywords:
|
||||
self.search_keywords = ' '.join(filter(None, keywords_parts))
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# Добавляем названия категорий в search_keywords после сохранения
|
||||
# (ManyToMany требует, чтобы объект уже существовал в БД)
|
||||
if self.pk and self.categories.exists():
|
||||
category_names = ' '.join([cat.name for cat in self.categories.all()])
|
||||
if category_names and category_names not in self.search_keywords:
|
||||
self.search_keywords = f"{self.search_keywords} {category_names}".strip()
|
||||
# Используем update чтобы избежать рекурсии
|
||||
Product.objects.filter(pk=self.pk).update(search_keywords=self.search_keywords)
|
||||
|
||||
def get_variant_groups(self):
|
||||
"""Возвращает все группы вариантов товара"""
|
||||
return self.variant_groups.all()
|
||||
|
||||
def get_similar_products(self):
|
||||
"""Возвращает все товары из тех же групп вариантов (исключая себя)"""
|
||||
return Product.objects.filter(
|
||||
variant_groups__in=self.variant_groups.all()
|
||||
).exclude(id=self.id).distinct()
|
||||
|
||||
|
||||
class ProductKit(models.Model):
|
||||
"""
|
||||
Шаблон комплекта / букета (рецепт).
|
||||
"""
|
||||
PRICING_METHOD_CHOICES = [
|
||||
('fixed', 'Фиксированная цена'),
|
||||
('from_sale_prices', 'По ценам продажи компонентов'),
|
||||
('from_cost_plus_percent', 'Себестоимость + процент наценки'),
|
||||
('from_cost_plus_amount', 'Себестоимость + фикс. наценка'),
|
||||
]
|
||||
|
||||
name = models.CharField(max_length=200, verbose_name="Название")
|
||||
sku = models.CharField(max_length=100, blank=True, null=True, verbose_name="Артикул")
|
||||
slug = models.SlugField(max_length=200, unique=True, verbose_name="URL-идентификатор")
|
||||
description = models.TextField(blank=True, null=True, verbose_name="Описание")
|
||||
categories = models.ManyToManyField(
|
||||
ProductCategory,
|
||||
blank=True,
|
||||
related_name='kits',
|
||||
verbose_name="Категории"
|
||||
)
|
||||
tags = models.ManyToManyField(ProductTag, blank=True, related_name='kits', verbose_name="Теги")
|
||||
is_active = models.BooleanField(default=True, verbose_name="Активен")
|
||||
pricing_method = models.CharField(max_length=30, choices=PRICING_METHOD_CHOICES,
|
||||
default='from_sale_prices', verbose_name="Метод ценообразования")
|
||||
fixed_price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True,
|
||||
verbose_name="Фиксированная цена")
|
||||
markup_percent = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True,
|
||||
verbose_name="Процент наценки")
|
||||
markup_amount = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True,
|
||||
verbose_name="Фиксированная наценка")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Дата обновления")
|
||||
|
||||
objects = models.Manager() # Менеджер по умолчанию
|
||||
active = ActiveManager() # Кастомный менеджер для активных комплектов
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Комплект"
|
||||
verbose_name_plural = "Комплекты"
|
||||
indexes = [
|
||||
models.Index(fields=['is_active']),
|
||||
models.Index(fields=['slug']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
# Убеждаемся, что slug уникален
|
||||
original_slug = self.slug
|
||||
counter = 1
|
||||
while ProductKit.objects.filter(slug=self.slug).exclude(pk=self.pk).exists():
|
||||
self.slug = f"{original_slug}-{counter}"
|
||||
counter += 1
|
||||
|
||||
if not self.sku:
|
||||
self.sku = generate_kit_sku()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_total_components_count(self):
|
||||
"""Возвращает количество позиций в букете"""
|
||||
return self.kit_items.count()
|
||||
|
||||
def get_components_with_variants_count(self):
|
||||
"""Возвращает количество позиций с группами вариантов"""
|
||||
return self.kit_items.filter(variant_group__isnull=False).count()
|
||||
|
||||
def get_sale_price(self):
|
||||
"""Возвращает рассчитанную цену продажи комплекта"""
|
||||
try:
|
||||
return self.calculate_price_with_substitutions()
|
||||
except Exception:
|
||||
# Если что-то пошло не так, возвращаем фиксированную цену если есть
|
||||
if self.pricing_method == 'fixed' and self.fixed_price:
|
||||
return self.fixed_price
|
||||
return 0
|
||||
|
||||
def check_availability(self, stock_manager=None):
|
||||
"""
|
||||
Проверяет доступность всего букета.
|
||||
Букет доступен, если для каждой позиции есть хотя бы один доступный вариант.
|
||||
"""
|
||||
from .utils.stock_manager import StockManager
|
||||
|
||||
if stock_manager is None:
|
||||
stock_manager = StockManager()
|
||||
|
||||
for kit_item in self.kit_items.all():
|
||||
best_product = kit_item.get_best_available_product(stock_manager)
|
||||
if not best_product:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def calculate_price_with_substitutions(self, stock_manager=None):
|
||||
"""
|
||||
Расчёт цены букета с учётом доступных замен.
|
||||
Использует цены фактически доступных товаров.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
from .utils.stock_manager import StockManager
|
||||
|
||||
if stock_manager is None:
|
||||
stock_manager = StockManager()
|
||||
|
||||
# Если указана фиксированная цена, используем её
|
||||
if self.pricing_method == 'fixed' and self.fixed_price:
|
||||
return self.fixed_price
|
||||
|
||||
total_cost = Decimal('0.00')
|
||||
total_sale = Decimal('0.00')
|
||||
|
||||
for kit_item in self.kit_items.select_related('product', 'variant_group'):
|
||||
best_product = kit_item.get_best_available_product(stock_manager)
|
||||
|
||||
if not best_product:
|
||||
# Если товар недоступен, используем цену первого в списке
|
||||
available_products = kit_item.get_available_products()
|
||||
best_product = available_products[0] if available_products else None
|
||||
|
||||
if best_product:
|
||||
total_cost += best_product.cost_price * kit_item.quantity
|
||||
total_sale += best_product.sale_price * kit_item.quantity
|
||||
|
||||
# Применяем метод ценообразования
|
||||
if self.pricing_method == 'from_sale_prices':
|
||||
return total_sale
|
||||
elif self.pricing_method == 'from_cost_plus_percent' and self.markup_percent:
|
||||
return total_cost * (Decimal('1') + self.markup_percent / Decimal('100'))
|
||||
elif self.pricing_method == 'from_cost_plus_amount' and self.markup_amount:
|
||||
return total_cost + self.markup_amount
|
||||
elif self.pricing_method == 'fixed' and self.fixed_price:
|
||||
return self.fixed_price
|
||||
|
||||
return total_sale
|
||||
|
||||
|
||||
class KitItem(models.Model):
|
||||
"""
|
||||
Состав комплекта: связь между ProductKit и Product или ProductVariantGroup.
|
||||
Позиция может быть либо конкретным товаром, либо группой вариантов.
|
||||
"""
|
||||
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='kit_items',
|
||||
verbose_name="Комплект")
|
||||
product = models.ForeignKey(
|
||||
Product,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='kit_items_direct',
|
||||
verbose_name="Конкретный товар"
|
||||
)
|
||||
variant_group = models.ForeignKey(
|
||||
ProductVariantGroup,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='kit_items',
|
||||
verbose_name="Группа вариантов"
|
||||
)
|
||||
quantity = models.DecimalField(max_digits=10, decimal_places=3, verbose_name="Количество")
|
||||
notes = models.CharField(
|
||||
max_length=200,
|
||||
blank=True,
|
||||
verbose_name="Примечание"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Компонент комплекта"
|
||||
verbose_name_plural = "Компоненты комплектов"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.kit.name} - {self.get_display_name()}"
|
||||
|
||||
def clean(self):
|
||||
"""Валидация: должен быть указан либо product, либо variant_group (но не оба)"""
|
||||
if self.product and self.variant_group:
|
||||
raise ValidationError(
|
||||
"Нельзя указывать одновременно товар и группу вариантов. Выберите что-то одно."
|
||||
)
|
||||
if not self.product and not self.variant_group:
|
||||
raise ValidationError(
|
||||
"Необходимо указать либо товар, либо группу вариантов."
|
||||
)
|
||||
|
||||
def get_display_name(self):
|
||||
"""Возвращает название для отображения (товар или группа)"""
|
||||
if self.variant_group:
|
||||
return f"[Варианты] {self.variant_group.name}"
|
||||
return self.product.name if self.product else "Не указан"
|
||||
|
||||
def has_priorities_set(self):
|
||||
"""Проверяет, настроены ли приоритеты"""
|
||||
return self.priorities.exists()
|
||||
|
||||
def get_available_products(self):
|
||||
"""Возвращает список доступных товаров с учётом приоритетов"""
|
||||
if self.product:
|
||||
# Если указан конкретный товар, возвращаем только его
|
||||
return [self.product]
|
||||
|
||||
if self.variant_group:
|
||||
# Если есть настроенные приоритеты, используем их
|
||||
if self.has_priorities_set():
|
||||
return [
|
||||
priority.product
|
||||
for priority in self.priorities.select_related('product').order_by('priority', 'id')
|
||||
]
|
||||
# Иначе возвращаем все товары из группы
|
||||
return list(self.variant_group.products.filter(is_active=True))
|
||||
|
||||
return []
|
||||
|
||||
def get_best_available_product(self, stock_manager=None):
|
||||
"""Возвращает первый доступный товар по приоритету"""
|
||||
from .utils.stock_manager import StockManager
|
||||
|
||||
if stock_manager is None:
|
||||
stock_manager = StockManager()
|
||||
|
||||
available_products = self.get_available_products()
|
||||
|
||||
for product in available_products:
|
||||
if stock_manager.check_stock(product, self.quantity):
|
||||
return product
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class KitItemPriority(models.Model):
|
||||
"""
|
||||
Приоритеты товаров для конкретной позиции букета.
|
||||
Позволяет настроить индивидуальные приоритеты замен для каждого букета.
|
||||
"""
|
||||
kit_item = models.ForeignKey(
|
||||
KitItem,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='priorities',
|
||||
verbose_name="Позиция в букете"
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
Product,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name="Товар"
|
||||
)
|
||||
priority = models.PositiveIntegerField(
|
||||
default=0,
|
||||
help_text="Меньше = выше приоритет (0 - наивысший)"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Приоритет варианта"
|
||||
verbose_name_plural = "Приоритеты вариантов"
|
||||
ordering = ['priority', 'id']
|
||||
unique_together = ['kit_item', 'product']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name} (приоритет {self.priority})"
|
||||
|
||||
|
||||
class ProductPhoto(models.Model):
|
||||
"""
|
||||
Модель для хранения фото товара (один товар может иметь несколько фото).
|
||||
"""
|
||||
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='photos',
|
||||
verbose_name="Товар")
|
||||
image = models.ImageField(upload_to='products/', verbose_name="Фото")
|
||||
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Фото товара"
|
||||
verbose_name_plural = "Фото товаров"
|
||||
ordering = ['order', '-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Фото для {self.product.name}"
|
||||
|
||||
|
||||
class ProductKitPhoto(models.Model):
|
||||
"""
|
||||
Модель для хранения фото комплекта (один комплект может иметь несколько фото).
|
||||
"""
|
||||
kit = models.ForeignKey(ProductKit, on_delete=models.CASCADE, related_name='photos',
|
||||
verbose_name="Комплект")
|
||||
image = models.ImageField(upload_to='kits/', verbose_name="Фото")
|
||||
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Фото комплекта"
|
||||
verbose_name_plural = "Фото комплектов"
|
||||
ordering = ['order', '-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Фото для {self.kit.name}"
|
||||
|
||||
|
||||
class ProductCategoryPhoto(models.Model):
|
||||
"""
|
||||
Модель для хранения фото категории (одна категория может иметь несколько фото).
|
||||
"""
|
||||
category = models.ForeignKey(ProductCategory, on_delete=models.CASCADE, related_name='photos',
|
||||
verbose_name="Категория")
|
||||
image = models.ImageField(upload_to='categories/', verbose_name="Фото")
|
||||
order = models.PositiveIntegerField(default=0, verbose_name="Порядок")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата создания")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Фото категории"
|
||||
verbose_name_plural = "Фото категорий"
|
||||
ordering = ['order', '-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Фото для {self.category.name}"
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Удалить категорию{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h4>Подтверждение удаления</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Вы уверены, что хотите удалить категорию <strong>"{{ category.name }}"</strong>?</p>
|
||||
|
||||
{% if products_count > 0 %}
|
||||
<div class="alert alert-danger">
|
||||
<strong>Внимание!</strong> В этой категории есть <strong>{{ products_count }}</strong> товар(ов).
|
||||
<br>Удаление невозможно. Сначала удалите или переместите товары.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if children_count > 0 %}
|
||||
<div class="alert alert-warning">
|
||||
<strong>Внимание!</strong> У этой категории есть <strong>{{ children_count }}</strong> подкатегорий.
|
||||
<br>Удаление невозможно. Сначала удалите или переместите подкатегории.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<a href="{% url 'products:category-detail' category.pk %}" class="btn btn-secondary">Отмена</a>
|
||||
{% if products_count == 0 and children_count == 0 %}
|
||||
<button type="submit" class="btn btn-danger">Да, удалить</button>
|
||||
{% else %}
|
||||
<a href="{% url 'products:category-detail' category.pk %}" class="btn btn-primary">Вернуться к категории</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
105
myproject/products/templates/products/category_detail.html
Normal file
105
myproject/products/templates/products/category_detail.html
Normal file
@@ -0,0 +1,105 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ category.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h3>{{ category.name }}</h3>
|
||||
<div>
|
||||
{% if category.is_active %}
|
||||
<span class="badge bg-success">Активна</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Неактивна</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4"><strong>Артикул:</strong></div>
|
||||
<div class="col-md-8"><code>{{ category.sku|default:"—" }}</code></div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4"><strong>URL-идентификатор:</strong></div>
|
||||
<div class="col-md-8"><code>{{ category.slug }}</code></div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4"><strong>Родительская категория:</strong></div>
|
||||
<div class="col-md-8">
|
||||
{% if category.parent %}
|
||||
<a href="{% url 'products:category-detail' category.parent.pk %}">{{ category.parent.name }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">Корневая категория</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Фотографии -->
|
||||
{% if category_photos %}
|
||||
<div class="mt-4">
|
||||
<h5>Фотографии ({{ photos_count }})</h5>
|
||||
<div class="row g-2">
|
||||
{% for photo in category_photos %}
|
||||
<div class="col-md-3 col-sm-4 col-6">
|
||||
<div class="card">
|
||||
<img src="{{ photo.image.url }}" class="card-img-top" alt="Фото категории" style="height: 150px; object-fit: cover;">
|
||||
{% if photo.order == 0 %}
|
||||
<div class="card-body p-1 text-center">
|
||||
<span class="badge bg-success">Главное</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Подкатегории -->
|
||||
{% if children_categories %}
|
||||
<div class="mt-4">
|
||||
<h5>Подкатегории ({{ children_categories.count }})</h5>
|
||||
<ul class="list-group">
|
||||
{% for child in children_categories %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<a href="{% url 'products:category-detail' child.pk %}">{{ child.name }}</a>
|
||||
<span class="badge bg-primary">{{ child.sku|default:"—" }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Товары в категории -->
|
||||
{% if products %}
|
||||
<div class="mt-4">
|
||||
<h5>Товары в категории ({{ products_count }})</h5>
|
||||
<div class="list-group">
|
||||
{% for product in products %}
|
||||
<a href="{% url 'products:product-detail' product.pk %}" class="list-group-item list-group-item-action">
|
||||
{{ product.name }} <span class="text-muted">({{ product.sku }})</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if products_count > 20 %}
|
||||
<div class="list-group-item text-center text-muted">
|
||||
... и еще {{ products_count|add:"-20" }} товар(ов)
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<a href="{% url 'products:category-list' %}" class="btn btn-secondary">Назад к списку</a>
|
||||
<a href="{% url 'products:category-update' category.pk %}" class="btn btn-primary">Редактировать</a>
|
||||
<a href="{% url 'products:category-delete' category.pk %}" class="btn btn-danger">Удалить</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
191
myproject/products/templates/products/category_form.html
Normal file
191
myproject/products/templates/products/category_form.html
Normal file
@@ -0,0 +1,191 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{% if object %}Редактировать категорию{% else %}Создать категорию{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{% for error in form.non_field_errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Блок 1: Основная информация -->
|
||||
<div class="mb-4">
|
||||
<!-- Название -->
|
||||
<div class="mb-3">
|
||||
<label for="id_name" class="form-label fw-bold fs-5">Название</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.help_text %}
|
||||
<small class="form-text text-muted">{{ form.name.help_text }}</small>
|
||||
{% endif %}
|
||||
{% if form.name.errors %}
|
||||
<div class="text-danger">{{ form.name.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Артикул и Slug в один ряд -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
{{ form.sku.label_tag }}
|
||||
{{ form.sku }}
|
||||
{% if form.sku.help_text %}
|
||||
<small class="form-text text-muted">{{ form.sku.help_text }}</small>
|
||||
{% endif %}
|
||||
{% if form.sku.errors %}
|
||||
<div class="text-danger">{{ form.sku.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form.slug.label_tag }}
|
||||
{{ form.slug }}
|
||||
{% if form.slug.help_text %}
|
||||
<small class="form-text text-muted">{{ form.slug.help_text }}</small>
|
||||
{% endif %}
|
||||
{% if form.slug.errors %}
|
||||
<div class="text-danger">{{ form.slug.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Родитель и Статус в один ряд -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-8">
|
||||
{{ form.parent.label_tag }}
|
||||
{{ form.parent }}
|
||||
{% if form.parent.help_text %}
|
||||
<small class="form-text text-muted">{{ form.parent.help_text }}</small>
|
||||
{% endif %}
|
||||
{% if form.parent.errors %}
|
||||
<div class="text-danger">{{ form.parent.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check mt-4">
|
||||
{{ form.is_active }}
|
||||
{{ form.is_active.label_tag }}
|
||||
</div>
|
||||
{% if form.is_active.help_text %}
|
||||
<small class="form-text text-muted">{{ form.is_active.help_text }}</small>
|
||||
{% endif %}
|
||||
{% if form.is_active.errors %}
|
||||
<div class="text-danger">{{ form.is_active.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Блок 2: Фотографии -->
|
||||
<div class="mb-4 p-3 bg-light rounded">
|
||||
<h5 class="mb-3">Фотографии</h5>
|
||||
|
||||
<!-- Существующие фотографии (только при редактировании) -->
|
||||
{% if object and category_photos %}
|
||||
<div class="mb-3">
|
||||
<h6 class="mb-3">Текущие фотографии ({{ photos_count }})</h6>
|
||||
<div class="row g-2 mb-3">
|
||||
{% for photo in category_photos %}
|
||||
<div class="col-md-3 col-sm-4 col-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div style="width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; background-color: #f8f9fa; cursor: pointer; overflow: hidden;"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#photoModal{{ photo.pk }}"
|
||||
title="Нажмите для увеличения">
|
||||
<img src="{{ photo.image.url }}"
|
||||
alt="Фото категории"
|
||||
style="max-width: 100%; max-height: 100%; object-fit: contain;">
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
{% if photo.order == 0 %}
|
||||
<div class="badge bg-success w-100 mb-1">Главное</div>
|
||||
{% else %}
|
||||
<a href="{% url 'products:category-photo-set-main' photo.pk %}"
|
||||
class="btn btn-outline-primary btn-sm w-100 mb-1 py-0"
|
||||
title="Сделать главным">
|
||||
Главным
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="btn-group w-100 mb-1" role="group">
|
||||
<a href="{% url 'products:category-photo-move-up' photo.pk %}"
|
||||
class="btn btn-outline-secondary btn-sm py-0">
|
||||
↑
|
||||
</a>
|
||||
<a href="{% url 'products:category-photo-move-down' photo.pk %}"
|
||||
class="btn btn-outline-secondary btn-sm py-0">
|
||||
↓
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a href="{% url 'products:category-photo-delete' photo.pk %}"
|
||||
class="btn btn-danger btn-sm w-100 py-0"
|
||||
onclick="return confirm('Удалить это фото?');">
|
||||
Удалить
|
||||
</a>
|
||||
|
||||
<small class="text-muted d-block mt-1 text-center">Позиция: {{ photo.order }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для просмотра фото -->
|
||||
<div class="modal fade" id="photoModal{{ photo.pk }}" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Фото категории</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<img src="{{ photo.image.url }}" class="img-fluid" alt="Фото категории" style="max-height: 70vh;">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||
<a href="{% url 'products:category-photo-delete' photo.pk %}"
|
||||
class="btn btn-danger"
|
||||
onclick="return confirm('Удалить это фото?');">
|
||||
Удалить фото
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Поле для загрузки новых фотографий -->
|
||||
<div class="mb-0">
|
||||
<label for="id_photos" class="form-label fw-bold">
|
||||
{% if object %}Добавить новые фото{% else %}Загрузить фото{% endif %}
|
||||
</label>
|
||||
<input type="file" name="photos" accept="image/*" multiple class="form-control" id="id_photos">
|
||||
<small class="form-text text-muted">
|
||||
Выберите фото для категории (можно выбрать несколько, до 10 штук)
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-4 gap-2 flex-wrap">
|
||||
<a href="{% url 'products:category-list' %}" class="btn btn-secondary">Отмена</a>
|
||||
<button type="submit" class="btn btn-primary">{% if object %}Сохранить изменения{% else %}Создать категорию{% endif %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
214
myproject/products/templates/products/category_list.html
Normal file
214
myproject/products/templates/products/category_list.html
Normal file
@@ -0,0 +1,214 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Список категорий{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<h2 class="mb-4">Категории товаров</h2>
|
||||
|
||||
<!-- Панель фильтрации -->
|
||||
{% include 'components/filter_panel.html' with title="Категории" filters=filters action_buttons=action_buttons %}
|
||||
|
||||
<!-- Кнопка "Развернуть/Свернуть все" -->
|
||||
<div class="mb-3">
|
||||
<button id="expandAllBtn" class="btn btn-sm btn-outline-secondary" onclick="expandAll()">
|
||||
Развернуть все
|
||||
</button>
|
||||
<button id="collapseAllBtn" class="btn btn-sm btn-outline-secondary" onclick="collapseAll()">
|
||||
Свернуть все
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if category_tree %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Артикул</th>
|
||||
<th>Цена</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in category_tree %}
|
||||
<tr data-category-id="{{ item.pk }}"
|
||||
data-item-type="{{ item.item_type }}"
|
||||
data-parent-id="{{ item.parent_id|default:'null' }}"
|
||||
data-depth="{{ item.depth }}"
|
||||
{% if item.depth > 0 %}style="display: none;"{% endif %}
|
||||
class="category-row">
|
||||
|
||||
<!-- Колонка "Название" -->
|
||||
<td style="padding-left: calc(20px * {{ item.depth }} + 12px);">
|
||||
{% if item.item_type == 'category' %}
|
||||
{% if item.has_children %}
|
||||
<button class="btn btn-sm btn-outline-secondary p-0 toggle-btn"
|
||||
data-target="{{ item.pk }}"
|
||||
onclick="toggleCategory({{ item.pk }}); return false;"
|
||||
style="width: 24px; height: 24px; text-align: center; display: inline-flex; align-items: center; justify-content: center; margin-right: 8px; border-radius: 4px; font-weight: bold; font-size: 16px; line-height: 1;">
|
||||
+
|
||||
</button>
|
||||
{% else %}
|
||||
<span style="display: inline-block; width: 24px; margin-right: 8px;"></span>
|
||||
{% endif %}
|
||||
<a href="{% url 'products:category-detail' item.pk %}"
|
||||
style="font-weight: 600; color: #212529;">{{ item.name }}</a>
|
||||
{% if item.has_children %}
|
||||
<span class="badge bg-info text-dark ms-1">{{ item.obj.children.count }} подкат.</span>
|
||||
{% endif %}
|
||||
|
||||
{% elif item.item_type == 'product' %}
|
||||
<span style="display: inline-block; width: 24px; margin-right: 8px;"></span>
|
||||
<a href="{% url 'products:product-detail' item.pk %}"
|
||||
style="color: #6c757d;">{{ item.name }}</a>
|
||||
|
||||
{% elif item.item_type == 'kit' %}
|
||||
<span style="display: inline-block; width: 24px; margin-right: 8px;"></span>
|
||||
<a href="{% url 'products:kit-detail' item.pk %}"
|
||||
style="color: #6c757d;">{{ item.name }}</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Колонка "Артикул" -->
|
||||
<td>
|
||||
{% if item.sku %}
|
||||
<code>{{ item.sku }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Колонка "Цена" -->
|
||||
<td>
|
||||
{% if item.price %}
|
||||
{{ item.price|floatformat:0 }} ₽
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Колонка "Статус" (только для категорий) -->
|
||||
<td>
|
||||
{% if item.item_type == 'category' %}
|
||||
{% if item.obj.is_active %}
|
||||
<span class="badge bg-success">Активна</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Неактивна</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<!-- Колонка "Действия" -->
|
||||
<td>
|
||||
{% if item.item_type == 'category' %}
|
||||
<a href="{% url 'products:category-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||
<a href="{% url 'products:category-delete' item.pk %}" class="btn btn-sm btn-outline-danger">Удалить</a>
|
||||
{% elif item.item_type == 'product' %}
|
||||
<a href="{% url 'products:product-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||
{% elif item.item_type == 'kit' %}
|
||||
<a href="{% url 'products:kit-update' item.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<p>Категории не найдены.</p>
|
||||
<a href="{% url 'products:category-create' %}" class="btn btn-primary">Создать первую категорию</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Функция для раскрытия/сворачивания категории
|
||||
function toggleCategory(categoryId) {
|
||||
// Находим все прямые дочерние элементы
|
||||
const children = document.querySelectorAll(`tr[data-parent-id="${categoryId}"]`);
|
||||
const toggleBtn = document.querySelector(`button[data-target="${categoryId}"]`);
|
||||
|
||||
if (!toggleBtn) return;
|
||||
|
||||
const isExpanded = toggleBtn.textContent.trim() === '-';
|
||||
|
||||
children.forEach(child => {
|
||||
const childId = child.getAttribute('data-category-id');
|
||||
const childToggleBtn = child.querySelector(`button[data-target="${childId}"]`);
|
||||
|
||||
if (isExpanded) {
|
||||
// Сворачиваем
|
||||
child.style.display = 'none';
|
||||
// Рекурсивно сворачиваем всех потомков
|
||||
collapseAllChildren(childId);
|
||||
if (childToggleBtn) {
|
||||
childToggleBtn.textContent = '+';
|
||||
}
|
||||
} else {
|
||||
// Раскрываем только прямых детей
|
||||
child.style.display = 'table-row';
|
||||
}
|
||||
});
|
||||
|
||||
// Переключаем кнопку
|
||||
toggleBtn.textContent = isExpanded ? '+' : '-';
|
||||
}
|
||||
|
||||
// Рекурсивно сворачивает всех потомков
|
||||
function collapseAllChildren(categoryId) {
|
||||
const children = document.querySelectorAll(`tr[data-parent-id="${categoryId}"]`);
|
||||
children.forEach(child => {
|
||||
const childId = child.getAttribute('data-category-id');
|
||||
child.style.display = 'none';
|
||||
const childToggleBtn = child.querySelector(`button[data-target="${childId}"]`);
|
||||
if (childToggleBtn) {
|
||||
childToggleBtn.textContent = '+';
|
||||
}
|
||||
collapseAllChildren(childId);
|
||||
});
|
||||
}
|
||||
|
||||
// Развернуть все категории
|
||||
function expandAll() {
|
||||
const allRows = document.querySelectorAll('.category-row');
|
||||
const allToggleBtns = document.querySelectorAll('.toggle-btn');
|
||||
|
||||
allRows.forEach(row => {
|
||||
row.style.display = 'table-row';
|
||||
});
|
||||
|
||||
allToggleBtns.forEach(btn => {
|
||||
btn.textContent = '-';
|
||||
});
|
||||
}
|
||||
|
||||
// Свернуть все категории (показать только корневые)
|
||||
function collapseAll() {
|
||||
const allRows = document.querySelectorAll('.category-row');
|
||||
const allToggleBtns = document.querySelectorAll('.toggle-btn');
|
||||
|
||||
allRows.forEach(row => {
|
||||
const depth = parseInt(row.getAttribute('data-depth'));
|
||||
if (depth > 0) {
|
||||
row.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
allToggleBtns.forEach(btn => {
|
||||
btn.textContent = '+';
|
||||
});
|
||||
}
|
||||
|
||||
// При поиске автоматически раскрываем все категории
|
||||
{% if has_search %}
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
expandAll();
|
||||
});
|
||||
{% endif %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Удалить товар{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h3>Подтверждение удаления</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Вы уверены, что хотите удалить товар <strong>"{{ product.name }}"</strong>?</p>
|
||||
<p class="text-muted">Артикул: {{ product.sku }}</p>
|
||||
<p class="text-danger">Внимание! Это действие нельзя будет отменить.</p>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="{% url 'products:product-list' %}" class="btn btn-secondary">Отмена</a>
|
||||
<button type="submit" class="btn btn-danger">Удалить товар</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
261
myproject/products/templates/products/product_detail.html
Normal file
261
myproject/products/templates/products/product_detail.html
Normal file
@@ -0,0 +1,261 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ product.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h3>{{ product.name }}</h3>
|
||||
<div>
|
||||
{% if perms.products.change_product %}
|
||||
<a href="{% url 'products:product-update' product.pk %}" class="btn btn-outline-primary btn-sm">Редактировать</a>
|
||||
{% endif %}
|
||||
{% if perms.products.delete_product %}
|
||||
<a href="{% url 'products:product-delete' product.pk %}" class="btn btn-outline-danger btn-sm">Удалить</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Секция фотографий в начале -->
|
||||
{% if product_photos %}
|
||||
<div class="mb-4">
|
||||
<h5 class="mb-3">Фотографии товара ({{ photos_count }})</h5>
|
||||
<div class="row g-2">
|
||||
{% for photo in product_photos %}
|
||||
<div class="col-md-3 col-sm-4 col-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<!-- Кликабельное фото для открытия галереи -->
|
||||
<div style="width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; background-color: #f8f9fa; cursor: pointer; overflow: hidden;"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#photoGalleryModal"
|
||||
data-bs-slide-to="{{ forloop.counter0 }}"
|
||||
title="Нажмите для увеличения">
|
||||
<img src="{{ photo.image.url }}"
|
||||
alt="Фото товара"
|
||||
style="max-width: 100%; max-height: 100%; object-fit: contain;">
|
||||
</div>
|
||||
<div class="card-body p-2 text-center">
|
||||
{% if photo.order == 0 %}
|
||||
<div class="badge bg-success w-100">Главное</div>
|
||||
{% else %}
|
||||
<small class="text-muted">Позиция: {{ photo.order }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно с галереей (Bootstrap Carousel) -->
|
||||
<div class="modal fade" id="photoGalleryModal" tabindex="-1" aria-labelledby="photoGalleryModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="photoGalleryModalLabel">Галерея фотографий товара</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="photoCarousel" class="carousel slide" data-bs-ride="false">
|
||||
<div class="carousel-inner">
|
||||
{% for photo in product_photos %}
|
||||
<div class="carousel-item {% if forloop.first %}active{% endif %}">
|
||||
<div class="text-center" style="min-height: 60vh; display: flex; align-items: center; justify-content: center; background-color: #f8f9fa;">
|
||||
<img src="{{ photo.image.url }}" class="d-block" alt="Фото товара" style="max-height: 70vh; max-width: 100%; object-fit: contain;">
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Навигация и индикаторы под фото -->
|
||||
{% if photos_count > 1 %}
|
||||
<div class="d-flex justify-content-center align-items-center mt-3 gap-3">
|
||||
<button class="btn btn-outline-secondary" type="button" data-bs-target="#photoCarousel" data-bs-slide="prev">
|
||||
<i class="bi bi-chevron-left"></i> Предыдущее
|
||||
</button>
|
||||
|
||||
<div class="carousel-indicators position-static m-0">
|
||||
{% for photo in product_photos %}
|
||||
<button type="button" data-bs-target="#photoCarousel" data-bs-slide-to="{{ forloop.counter0 }}"
|
||||
{% if forloop.first %}class="active" aria-current="true"{% endif %}
|
||||
aria-label="Слайд {{ forloop.counter }}"
|
||||
style="background-color: #6c757d;"></button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<button class="btn btn-outline-secondary" type="button" data-bs-target="#photoCarousel" data-bs-slide="next">
|
||||
Следующее <i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-2">
|
||||
<small class="text-muted">
|
||||
<span id="currentSlide">1</span> из {{ photos_count }}
|
||||
<span id="mainBadge" {% if not product_photos.0.order == 0 %}style="display: none;"{% endif %} class="badge bg-success ms-2">Главное</span>
|
||||
</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
{% endif %}
|
||||
|
||||
<!-- Основная информация о товаре -->
|
||||
<h5 class="mb-3">Основная информация</h5>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>Артикул:</th>
|
||||
<td>{{ product.sku }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Описание:</th>
|
||||
<td>{{ product.description|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Категории:</th>
|
||||
<td>
|
||||
{% if product.categories.all %}
|
||||
{% for category in product.categories.all %}
|
||||
<span class="badge bg-primary">{{ category.name }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Теги:</th>
|
||||
<td>
|
||||
{% if product.tags.all %}
|
||||
{% for tag in product.tags.all %}
|
||||
<span class="badge bg-secondary">{{ tag.name }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Единица измерения:</th>
|
||||
<td>{{ product.unit }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Себестоимость:</th>
|
||||
<td>{{ product.cost_price }} руб.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Цена продажи:</th>
|
||||
<td>{{ product.sale_price }} руб.</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Статус:</th>
|
||||
<td>
|
||||
{% if product.is_active %}
|
||||
<span class="badge bg-success">Активен</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Неактивен</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Дата создания:</th>
|
||||
<td>{{ product.created_at }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Дата обновления:</th>
|
||||
<td>{{ product.updated_at }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>Действия</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<a href="{% url 'products:product-list' %}" class="btn btn-secondary btn-block">Назад к списку</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// Обработка галереи фотографий
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const photoGalleryModal = document.getElementById('photoGalleryModal');
|
||||
const photoCarousel = document.getElementById('photoCarousel');
|
||||
|
||||
if (photoGalleryModal && photoCarousel) {
|
||||
const carousel = bootstrap.Carousel.getOrCreateInstance(photoCarousel);
|
||||
const currentSlideEl = document.getElementById('currentSlide');
|
||||
const mainBadgeEl = document.getElementById('mainBadge');
|
||||
|
||||
// Массив с информацией о фотографиях
|
||||
const photos = [
|
||||
{% for photo in product_photos %}
|
||||
{ order: {{ photo.order }}, index: {{ forloop.counter0 }} }{% if not forloop.last %},{% endif %}
|
||||
{% endfor %}
|
||||
];
|
||||
|
||||
// Обработка клика на миниатюру для открытия галереи на нужном слайде
|
||||
photoGalleryModal.addEventListener('show.bs.modal', function (event) {
|
||||
const button = event.relatedTarget;
|
||||
const slideIndex = button.getAttribute('data-bs-slide-to');
|
||||
if (slideIndex !== null) {
|
||||
carousel.to(parseInt(slideIndex));
|
||||
}
|
||||
});
|
||||
|
||||
// Обновление счетчика и бейджа при переключении слайдов
|
||||
photoCarousel.addEventListener('slid.bs.carousel', function (event) {
|
||||
const activeIndex = event.to;
|
||||
if (currentSlideEl) {
|
||||
currentSlideEl.textContent = activeIndex + 1;
|
||||
}
|
||||
if (mainBadgeEl && photos[activeIndex]) {
|
||||
mainBadgeEl.style.display = photos[activeIndex].order === 0 ? 'inline' : 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Обработка нажатий клавиш-стрелок для навигации
|
||||
const handleKeydown = function(event) {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
carousel.prev();
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
carousel.next();
|
||||
}
|
||||
};
|
||||
|
||||
// Добавляем обработчик клавиш при открытии модального окна
|
||||
photoGalleryModal.addEventListener('shown.bs.modal', function () {
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
// Удаляем обработчик клавиш при закрытии модального окна
|
||||
photoGalleryModal.addEventListener('hidden.bs.modal', function () {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
270
myproject/products/templates/products/product_form.html
Normal file
270
myproject/products/templates/products/product_form.html
Normal file
@@ -0,0 +1,270 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{% if object %}Редактировать товар{% else %}Создать товар{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{% for error in form.non_field_errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Блок 1: Основная информация -->
|
||||
<div class="mb-4">
|
||||
<!-- Название -->
|
||||
<div class="mb-3">
|
||||
<label for="id_name" class="form-label fw-bold fs-5">Название</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.help_text %}
|
||||
<small class="form-text text-muted">{{ form.name.help_text }}</small>
|
||||
{% endif %}
|
||||
{% if form.name.errors %}
|
||||
<div class="text-danger">{{ form.name.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Артикул -->
|
||||
<div class="mb-3">
|
||||
{{ form.sku.label_tag }}
|
||||
{{ form.sku }}
|
||||
{% if form.sku.help_text %}
|
||||
<small class="form-text text-muted">{{ form.sku.help_text }}</small>
|
||||
{% endif %}
|
||||
{% if form.sku.errors %}
|
||||
<div class="text-danger">{{ form.sku.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Описание -->
|
||||
<div class="mb-3">
|
||||
{{ form.description.label_tag }}
|
||||
{{ form.description }}
|
||||
{% if form.description.help_text %}
|
||||
<small class="form-text text-muted">{{ form.description.help_text }}</small>
|
||||
{% endif %}
|
||||
{% if form.description.errors %}
|
||||
<div class="text-danger">{{ form.description.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Единица измерения и Статус в один ряд -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-8">
|
||||
{{ form.unit.label_tag }}
|
||||
{{ form.unit }}
|
||||
{% if form.unit.help_text %}
|
||||
<small class="form-text text-muted">{{ form.unit.help_text }}</small>
|
||||
{% endif %}
|
||||
{% if form.unit.errors %}
|
||||
<div class="text-danger">{{ form.unit.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="form-check mt-4">
|
||||
{{ form.is_active }}
|
||||
{{ form.is_active.label_tag }}
|
||||
</div>
|
||||
{% if form.is_active.help_text %}
|
||||
<small class="form-text text-muted">{{ form.is_active.help_text }}</small>
|
||||
{% endif %}
|
||||
{% if form.is_active.errors %}
|
||||
<div class="text-danger">{{ form.is_active.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Блок 2: Ценообразование -->
|
||||
<div class="mb-4">
|
||||
<h5 class="mb-3">Ценообразование</h5>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
{{ form.cost_price.label_tag }}
|
||||
{{ form.cost_price }}
|
||||
{% if form.cost_price.help_text %}
|
||||
<small class="form-text text-muted">{{ form.cost_price.help_text }}</small>
|
||||
{% endif %}
|
||||
{% if form.cost_price.errors %}
|
||||
<div class="text-danger">{{ form.cost_price.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{{ form.sale_price.label_tag }}
|
||||
{{ form.sale_price }}
|
||||
{% if form.sale_price.help_text %}
|
||||
<small class="form-text text-muted">{{ form.sale_price.help_text }}</small>
|
||||
{% endif %}
|
||||
{% if form.sale_price.errors %}
|
||||
<div class="text-danger">{{ form.sale_price.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Блок 3: Фотографии -->
|
||||
<div class="mb-4 p-3 bg-light rounded">
|
||||
<h5 class="mb-3">Фотографии</h5>
|
||||
|
||||
<!-- Существующие фотографии (только при редактировании) -->
|
||||
{% if object and product_photos %}
|
||||
<div class="mb-3">
|
||||
<h6 class="mb-3">Текущие фотографии ({{ photos_count }})</h6>
|
||||
<div class="row g-2 mb-3">
|
||||
{% for photo in product_photos %}
|
||||
<div class="col-md-3 col-sm-4 col-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<!-- Кликабельное фото для открытия модального окна -->
|
||||
<div style="width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; background-color: #f8f9fa; cursor: pointer; overflow: hidden;"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#photoModal{{ photo.pk }}"
|
||||
title="Нажмите для увеличения">
|
||||
<img src="{{ photo.image.url }}"
|
||||
alt="Фото товара"
|
||||
style="max-width: 100%; max-height: 100%; object-fit: contain;">
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
{% if photo.order == 0 %}
|
||||
<div class="badge bg-success w-100 mb-1">⭐ Главное</div>
|
||||
{% else %}
|
||||
<a href="{% url 'products:product-photo-set-main' photo.pk %}"
|
||||
class="btn btn-outline-primary btn-sm w-100 mb-1 py-0"
|
||||
title="Сделать главным">
|
||||
⭐ Главным
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="btn-group w-100 mb-1" role="group">
|
||||
<a href="{% url 'products:product-photo-move-up' photo.pk %}"
|
||||
class="btn btn-outline-secondary btn-sm py-0"
|
||||
title="Переместить вверх">
|
||||
⬆️
|
||||
</a>
|
||||
<a href="{% url 'products:product-photo-move-down' photo.pk %}"
|
||||
class="btn btn-outline-secondary btn-sm py-0"
|
||||
title="Переместить вниз">
|
||||
⬇️
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a href="{% url 'products:product-photo-delete' photo.pk %}"
|
||||
class="btn btn-danger btn-sm w-100 py-0"
|
||||
onclick="return confirm('Удалить это фото?');">
|
||||
🗑️ Удалить
|
||||
</a>
|
||||
|
||||
<small class="text-muted d-block mt-1 text-center">Позиция: {{ photo.order }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для просмотра фото -->
|
||||
<div class="modal fade" id="photoModal{{ photo.pk }}" tabindex="-1" aria-labelledby="photoModalLabel{{ photo.pk }}" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="photoModalLabel{{ photo.pk }}">Фото товара</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<img src="{{ photo.image.url }}" class="img-fluid" alt="Фото товара" style="max-height: 70vh;">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||
<a href="{% url 'products:product-photo-delete' photo.pk %}"
|
||||
class="btn btn-danger"
|
||||
onclick="return confirm('Удалить это фото?');">
|
||||
🗑️ Удалить фото
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Поле для загрузки новых фотографий -->
|
||||
<div class="mb-0">
|
||||
<label for="id_photos" class="form-label fw-bold">
|
||||
{% if object %}Добавить новые фото{% else %}Загрузить фото{% endif %}
|
||||
</label>
|
||||
<input type="file" name="photos" accept="image/*" multiple class="form-control" id="id_photos">
|
||||
<small class="form-text text-muted">
|
||||
{% if object %}
|
||||
Выберите фото для добавления к товару (можно выбрать несколько, до 10 штук всего)
|
||||
{% else %}
|
||||
Выберите фото для товара (можно выбрать несколько, до 10 штук)
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Блок 4: Классификация -->
|
||||
<div class="mb-4">
|
||||
<h5 class="mb-3">Классификация</h5>
|
||||
|
||||
<!-- Категории -->
|
||||
<div class="mb-3">
|
||||
{{ form.categories.label_tag }}
|
||||
<div class="p-3 bg-light rounded">
|
||||
{{ form.categories }}
|
||||
</div>
|
||||
{% if form.categories.help_text %}
|
||||
<small class="form-text text-muted">{{ form.categories.help_text }}</small>
|
||||
{% endif %}
|
||||
{% if form.categories.errors %}
|
||||
<div class="text-danger">{{ form.categories.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Теги -->
|
||||
<div class="mb-3">
|
||||
{{ form.tags.label_tag }}
|
||||
<div class="p-3 bg-light rounded">
|
||||
{{ form.tags }}
|
||||
</div>
|
||||
{% if form.tags.help_text %}
|
||||
<small class="form-text text-muted">{{ form.tags.help_text }}</small>
|
||||
{% endif %}
|
||||
{% if form.tags.errors %}
|
||||
<div class="text-danger">{{ form.tags.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-4 gap-2 flex-wrap">
|
||||
<div>
|
||||
<a href="{% url 'products:product-list' %}" class="btn btn-secondary">Отмена</a>
|
||||
{% if perms.products.add_productkit %}
|
||||
<a href="{% url 'products:productkit-create' %}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-box-seam"></i> Создать комплект
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{% if object %}Сохранить изменения{% else %}Создать товар{% endif %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
110
myproject/products/templates/products/product_list.html
Normal file
110
myproject/products/templates/products/product_list.html
Normal file
@@ -0,0 +1,110 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Список товаров{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<h2 class="mb-4">Список товаров</h2>
|
||||
|
||||
<!-- Панель фильтрации -->
|
||||
{% include 'components/filter_panel.html' with title="Товары" filters=filters action_buttons=action_buttons %}
|
||||
|
||||
{% if products %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Фото</th>
|
||||
<th>Название</th>
|
||||
<th>Артикул</th>
|
||||
<th>Категория</th>
|
||||
<th>Цена продажи</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for product in products %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if product.photos.all %}
|
||||
{% with photo=product.photos.first %}
|
||||
<img src="{{ photo.image.url }}" alt="{{ product.name }}" style="max-width: 50px; max-height: 50px;" class="img-thumbnail">
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<span class="text-muted">Нет фото</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'products:product-detail' product.pk %}">{{ product.name }}</a>
|
||||
</td>
|
||||
<td>{{ product.sku }}</td>
|
||||
<td>
|
||||
{% if product.categories.all %}
|
||||
{% for category in product.categories.all %}
|
||||
{{ category.name }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ product.sale_price }} руб.</td>
|
||||
<td>
|
||||
{% if product.is_active %}
|
||||
<span class="badge bg-success">Активен</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Неактивен</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if perms.products.change_product %}
|
||||
<a href="{% url 'products:product-update' product.pk %}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||
{% endif %}
|
||||
{% if perms.products.delete_product %}
|
||||
<a href="{% url 'products:product-delete' product.pk %}" class="btn btn-sm btn-outline-danger">Удалить</a>
|
||||
{% endif %}
|
||||
</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">Первая</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Предыдущая</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 }}">Следующая</a>
|
||||
</li>
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Последняя</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<p>Товары не найдены.</p>
|
||||
{% if perms.products.add_product %}
|
||||
<a href="{% url 'products:product-create' %}" class="btn btn-primary">Создать первый товар</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Удалить комплект - {{ kit.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6">
|
||||
<div class="card border-danger">
|
||||
<div class="card-header bg-danger text-white">
|
||||
<h4 class="mb-0"><i class="bi bi-exclamation-triangle"></i> Подтверждение удаления</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="lead">Вы действительно хотите удалить комплект?</p>
|
||||
|
||||
<div class="alert alert-warning mb-3">
|
||||
<strong>{{ kit.name }}</strong>
|
||||
<br>
|
||||
<small class="text-muted">Артикул: {{ kit.sku }}</small>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-danger mb-4">
|
||||
<i class="bi bi-exclamation-circle"></i>
|
||||
<strong>Внимание!</strong> Это действие нельзя отменить. Комплект будет удален с все его компоненты и фотографии.
|
||||
</div>
|
||||
|
||||
<form method="post" class="d-flex gap-2">
|
||||
{% csrf_token %}
|
||||
<a href="{% url 'products:productkit-detail' kit.pk %}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> Отмена
|
||||
</a>
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-trash"></i> Удалить комплект
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Информация о комплекте -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Информация о комплекте</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-5">Название:</dt>
|
||||
<dd class="col-sm-7">{{ kit.name }}</dd>
|
||||
|
||||
<dt class="col-sm-5">Артикул:</dt>
|
||||
<dd class="col-sm-7">{{ kit.sku }}</dd>
|
||||
|
||||
{% if kit.categories.all %}
|
||||
<dt class="col-sm-5">Категории:</dt>
|
||||
<dd class="col-sm-7">
|
||||
{% for category in kit.categories.all %}
|
||||
{{ category.name }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
<dt class="col-sm-5">Компонентов:</dt>
|
||||
<dd class="col-sm-7">{{ kit.get_total_components_count }}</dd>
|
||||
|
||||
<dt class="col-sm-5">Статус:</dt>
|
||||
<dd class="col-sm-7">
|
||||
{% if kit.is_active %}
|
||||
<span class="badge bg-success">Активен</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Неактивен</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-5">Создан:</dt>
|
||||
<dd class="col-sm-7">{{ kit.created_at|date:"d.m.Y" }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
239
myproject/products/templates/products/productkit_detail.html
Normal file
239
myproject/products/templates/products/productkit_detail.html
Normal file
@@ -0,0 +1,239 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{{ kit.name }} - Комплект{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-8">
|
||||
<h2>{{ kit.name }}</h2>
|
||||
<p class="text-muted">Артикул: <strong>{{ kit.sku }}</strong></p>
|
||||
</div>
|
||||
<div class="col-md-4 text-end">
|
||||
{% if perms.products.change_productkit %}
|
||||
<a href="{% url 'products:productkit-update' kit.pk %}" class="btn btn-primary">
|
||||
<i class="bi bi-pencil"></i> Редактировать
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.products.delete_productkit %}
|
||||
<a href="{% url 'products:productkit-delete' kit.pk %}" class="btn btn-danger">
|
||||
<i class="bi bi-trash"></i> Удалить
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Основная информация -->
|
||||
<div class="col-md-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Основная информация</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-4">Название:</dt>
|
||||
<dd class="col-sm-8">{{ kit.name }}</dd>
|
||||
|
||||
<dt class="col-sm-4">Артикул:</dt>
|
||||
<dd class="col-sm-8">{{ kit.sku }}</dd>
|
||||
|
||||
{% if kit.categories.all %}
|
||||
<dt class="col-sm-4">Категории:</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% for category in kit.categories.all %}
|
||||
<span class="badge bg-primary">{{ category.name }}</span>
|
||||
{% endfor %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
|
||||
<dt class="col-sm-4">Цена продажи:</dt>
|
||||
<dd class="col-sm-8">
|
||||
<strong class="text-success fs-5">{{ kit.get_sale_price|floatformat:2 }} ₽</strong>
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Ценообразование:</dt>
|
||||
<dd class="col-sm-8">
|
||||
<span class="badge bg-info text-dark">{{ kit.get_pricing_method_display }}</span>
|
||||
</dd>
|
||||
|
||||
{% if kit.fixed_price %}
|
||||
<dt class="col-sm-4">Фиксированная цена:</dt>
|
||||
<dd class="col-sm-8">{{ kit.fixed_price }} ₽</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if kit.markup_percent %}
|
||||
<dt class="col-sm-4">Процент наценки:</dt>
|
||||
<dd class="col-sm-8">{{ kit.markup_percent }}%</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if kit.markup_amount %}
|
||||
<dt class="col-sm-4">Фиксированная наценка:</dt>
|
||||
<dd class="col-sm-8">{{ kit.markup_amount }} ₽</dd>
|
||||
{% endif %}
|
||||
|
||||
<dt class="col-sm-4">Статус:</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if kit.is_active %}
|
||||
<span class="badge bg-success">Активен</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Неактивен</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{% if kit.description %}
|
||||
<div class="mt-3">
|
||||
<h6>Описание:</h6>
|
||||
<p>{{ kit.description|linebreaks }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if kit.tags.all %}
|
||||
<div class="mt-3">
|
||||
<h6>Теги:</h6>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for tag in kit.tags.all %}
|
||||
<span class="badge bg-secondary">{{ tag.name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Компоненты комплекта -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Состав комплекта ({{ kit_items.count }} компонентов)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if kit_items %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Компонент</th>
|
||||
<th>Тип</th>
|
||||
<th>Количество</th>
|
||||
<th>Примечание</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in kit_items %}
|
||||
<tr>
|
||||
<td>{{ forloop.counter }}</td>
|
||||
<td>
|
||||
{% if item.product %}
|
||||
<a href="{% url 'products:product-detail' item.product.pk %}">
|
||||
{{ item.product.name }}
|
||||
</a>
|
||||
<br>
|
||||
<small class="text-muted">Артикул: {{ item.product.sku }}</small>
|
||||
{% else %}
|
||||
<span class="badge bg-primary">{{ item.variant_group.name }}</span>
|
||||
<br>
|
||||
<small class="text-muted">Варианты: {{ item.variant_group.get_products_count }} товаров</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if item.product %}
|
||||
<span class="badge bg-success">Товар</span>
|
||||
{% else %}
|
||||
<span class="badge bg-primary">Варианты</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td>
|
||||
{% if item.notes %}
|
||||
{{ item.notes }}
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Нет компонентов в этом комплекте</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сбоку: фотографии и информация -->
|
||||
<div class="col-md-4">
|
||||
<!-- Фотографии -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Фотографии ({{ photos_count }})</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if productkit_photos %}
|
||||
<div class="row g-2">
|
||||
{% for photo in productkit_photos %}
|
||||
<div class="col-6">
|
||||
<div class="card">
|
||||
<img src="{{ photo.image.url }}" class="card-img-top" alt="{{ kit.name }}"
|
||||
style="height: 120px; object-fit: cover; cursor: pointer;"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#photoModal{{ photo.pk }}">
|
||||
{% if photo.order == 0 %}
|
||||
<div class="card-footer bg-success text-white text-center small">⭐ Главное</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для просмотра -->
|
||||
<div class="modal fade" id="photoModal{{ photo.pk }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Фото комплекта</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<img src="{{ photo.image.url }}" class="img-fluid" style="max-height: 70vh;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">Нет фотографий</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Метаинформация -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Информация</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row">
|
||||
<dt class="col-sm-8 text-truncate">Создан:</dt>
|
||||
<dd class="col-sm-4">{{ kit.created_at|date:"d.m.Y H:i" }}</dd>
|
||||
|
||||
<dt class="col-sm-8 text-truncate">Обновлен:</dt>
|
||||
<dd class="col-sm-4">{{ kit.updated_at|date:"d.m.Y H:i" }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка назад -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<a href="{% url 'products:productkit-list' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> К списку комплектов
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
664
myproject/products/templates/products/productkit_form.html
Normal file
664
myproject/products/templates/products/productkit_form.html
Normal file
@@ -0,0 +1,664 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}{% if object %}Редактировать комплект{% else %}Создать комплект{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-10">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>{% if object %}Редактировать комплект{% else %}Создать комплект{% endif %}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{% for error in form.non_field_errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Секция управления фотографиями -->
|
||||
{% if object %}
|
||||
<div class="mb-4 p-3 bg-light rounded">
|
||||
<h5 class="mb-3">Управление фотографиями</h5>
|
||||
|
||||
<!-- Существующие фотографии (только при редактировании) -->
|
||||
{% if productkit_photos %}
|
||||
<div class="mb-3">
|
||||
<h6 class="mb-3">Текущие фотографии ({{ photos_count }})</h6>
|
||||
<div class="row g-2 mb-3">
|
||||
{% for photo in productkit_photos %}
|
||||
<div class="col-md-3 col-sm-4 col-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<!-- Кликабельное фото для открытия модального окна -->
|
||||
<div style="width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; background-color: #f8f9fa; cursor: pointer; overflow: hidden;"
|
||||
data-bs-toggle="modal"
|
||||
data-bs-target="#photoModal{{ photo.pk }}"
|
||||
title="Нажмите для увеличения">
|
||||
<img src="{{ photo.image.url }}"
|
||||
alt="Фото комплекта"
|
||||
style="max-width: 100%; max-height: 100%; object-fit: contain;">
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
{% if photo.order == 0 %}
|
||||
<div class="badge bg-success w-100 mb-1">⭐ Главное</div>
|
||||
{% else %}
|
||||
<a href="{% url 'products:productkit-photo-set-main' photo.pk %}"
|
||||
class="btn btn-outline-primary btn-sm w-100 mb-1 py-0"
|
||||
title="Сделать главным">
|
||||
⭐ Главным
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<div class="btn-group w-100 mb-1" role="group">
|
||||
<a href="{% url 'products:productkit-photo-move-up' photo.pk %}"
|
||||
class="btn btn-outline-secondary btn-sm py-0"
|
||||
title="Переместить вверх">
|
||||
⬆️
|
||||
</a>
|
||||
<a href="{% url 'products:productkit-photo-move-down' photo.pk %}"
|
||||
class="btn btn-outline-secondary btn-sm py-0"
|
||||
title="Переместить вниз">
|
||||
⬇️
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a href="{% url 'products:productkit-photo-delete' photo.pk %}"
|
||||
class="btn btn-danger btn-sm w-100 py-0"
|
||||
onclick="return confirm('Удалить это фото?');">
|
||||
🗑️ Удалить
|
||||
</a>
|
||||
|
||||
<small class="text-muted d-block mt-1 text-center">Позиция: {{ photo.order }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для просмотра фото -->
|
||||
<div class="modal fade" id="photoModal{{ photo.pk }}" tabindex="-1" aria-labelledby="photoModalLabel{{ photo.pk }}" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="photoModalLabel{{ photo.pk }}">Фото комплекта</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<img src="{{ photo.image.url }}" class="img-fluid" alt="Фото комплекта" style="max-height: 70vh;">
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||
<a href="{% url 'products:productkit-photo-delete' photo.pk %}"
|
||||
class="btn btn-danger"
|
||||
onclick="return confirm('Удалить это фото?');">
|
||||
🗑️ Удалить фото
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Поле для загрузки новых фотографий -->
|
||||
<div class="mb-0">
|
||||
<label for="id_photos" class="form-label fw-bold">
|
||||
{% if object %}➕ Добавить новые фото{% else %}📷 Загрузить фото{% endif %}
|
||||
</label>
|
||||
<input type="file" name="photos" accept="image/*" multiple class="form-control" id="id_photos">
|
||||
<small class="form-text text-muted">
|
||||
{% if object %}
|
||||
Выберите фото для добавления к комплекту (можно выбрать несколько, до 10 штук всего)
|
||||
{% else %}
|
||||
Выберите фото для комплекта (можно выбрать несколько, до 10 штук)
|
||||
{% endif %}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
{% endif %}
|
||||
|
||||
<!-- Основная информация о комплекте -->
|
||||
<h5 class="mb-3">Основная информация</h5>
|
||||
|
||||
{% for field in form %}
|
||||
{% if field.name == 'fixed_price' %}
|
||||
<!-- Alert для неактивного поля fixed_price -->
|
||||
<div id="fixed-price-alert" class="alert alert-warning d-none mb-3" role="alert">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<strong>Внимание!</strong> Поле "Фиксированная цена" неактивно при текущем методе ценообразования.
|
||||
Оно используется только когда выбран метод <strong>"Фиксированная цена"</strong>.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
{{ field.label_tag }}
|
||||
|
||||
{% if field.name == 'tags' %}
|
||||
<div class="form-control p-3 bg-light">
|
||||
{{ field }}
|
||||
</div>
|
||||
{% else %}
|
||||
{{ field }}
|
||||
{% endif %}
|
||||
|
||||
{% if field.help_text %}
|
||||
<small class="form-text text-muted">{{ field.help_text }}</small>
|
||||
{% endif %}
|
||||
|
||||
{% if field.errors %}
|
||||
<div class="text-danger">{{ field.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<hr class="my-4">
|
||||
|
||||
<!-- Компоненты комплекта (формсет) -->
|
||||
<h5 class="mb-3">Состав комплекта</h5>
|
||||
|
||||
<div class="alert alert-info mb-3">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
<strong>Подсказка:</strong> Добавьте компоненты комплекта ниже. Каждый компонент может быть либо конкретным товаром, либо группой вариантов (например, розы разных размеров).
|
||||
</div>
|
||||
|
||||
<!-- Поиск товаров для быстрого добавления (показываем только при редактировании) -->
|
||||
<div class="card mb-4 bg-light border-info" id="searchCard">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-search"></i> Поиск товара для добавления
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="input-group mb-2">
|
||||
<input type="text"
|
||||
id="productSearch"
|
||||
class="form-control"
|
||||
placeholder="Введите название или артикул товара (минимум 2 символа)..."
|
||||
autocomplete="off">
|
||||
<button class="btn btn-info" type="button" id="searchBtn">
|
||||
<i class="bi bi-search"></i> Поиск
|
||||
</button>
|
||||
</div>
|
||||
<div id="searchResults" class="list-group" style="display: none; max-height: 300px; overflow-y: auto;"></div>
|
||||
<small class="text-muted">Используйте поиск для быстрого нахождения товара и добавления его в комплект</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Management form для формсета -->
|
||||
{{ kititem_formset.management_form }}
|
||||
|
||||
<!-- Ошибки формсета на уровне формсета -->
|
||||
{% if kititem_formset.non_form_errors %}
|
||||
<div class="alert alert-danger mb-3">
|
||||
{% for error in kititem_formset.non_form_errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Формы компонентов -->
|
||||
<div id="kititem-forms" class="mb-4">
|
||||
{% for kititem_form in kititem_formset %}
|
||||
<div class="card mb-3 kititem-form" data-form-index="{{ forloop.counter0 }}">
|
||||
<!-- Скрытые поля для inline formset -->
|
||||
{{ kititem_form.id }}
|
||||
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<span><strong>Компонент #{{ forloop.counter }}</strong></span>
|
||||
{% if kititem_form.DELETE %}
|
||||
<div class="form-check">
|
||||
{{ kititem_form.DELETE }}
|
||||
<label class="form-check-label" for="{{ kititem_form.DELETE.id_for_label }}">
|
||||
Удалить
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
{% if kititem_form.non_field_errors %}
|
||||
<div class="alert alert-danger">
|
||||
{% for error in kititem_form.non_field_errors %}
|
||||
{{ error }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ kititem_form.product.id_for_label }}" class="form-label">
|
||||
{{ kititem_form.product.label }}
|
||||
</label>
|
||||
{{ kititem_form.product }}
|
||||
{% if kititem_form.product.errors %}
|
||||
<div class="text-danger">{{ kititem_form.product.errors }}</div>
|
||||
{% endif %}
|
||||
<small class="form-text text-muted">Конкретный товар (если выбран, группа вариантов не нужна)</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="{{ kititem_form.variant_group.id_for_label }}" class="form-label">
|
||||
{{ kititem_form.variant_group.label }}
|
||||
</label>
|
||||
{{ kititem_form.variant_group }}
|
||||
{% if kititem_form.variant_group.errors %}
|
||||
<div class="text-danger">{{ kititem_form.variant_group.errors }}</div>
|
||||
{% endif %}
|
||||
<small class="form-text text-muted">Группа вариантов (если выбрана, конкретный товар не нужен)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="{{ kititem_form.quantity.id_for_label }}" class="form-label">
|
||||
{{ kititem_form.quantity.label }}
|
||||
</label>
|
||||
{{ kititem_form.quantity }}
|
||||
{% if kititem_form.quantity.errors %}
|
||||
<div class="text-danger">{{ kititem_form.quantity.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="col-md-8 mb-3">
|
||||
<label for="{{ kititem_form.notes.id_for_label }}" class="form-label">
|
||||
{{ kititem_form.notes.label }}
|
||||
</label>
|
||||
{{ kititem_form.notes }}
|
||||
{% if kititem_form.notes.errors %}
|
||||
<div class="text-danger">{{ kititem_form.notes.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Кнопки действия -->
|
||||
<div class="d-flex justify-content-between mt-4 gap-2 flex-wrap">
|
||||
<a href="{% url 'products:productkit-list' %}" class="btn btn-secondary">Отмена</a>
|
||||
<div class="btn-group">
|
||||
<button type="submit" name="action" value="exit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> Сохранить и выйти
|
||||
</button>
|
||||
<button type="submit" name="action" value="continue" class="btn btn-outline-primary">
|
||||
<i class="bi bi-arrow-repeat"></i> Сохранить и продолжить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// ========== ОПРЕДЕЛЯЕМ РЕЖИМ: СОЗДАНИЕ ИЛИ РЕДАКТИРОВАНИЕ ==========
|
||||
const existingForms = document.querySelectorAll('.kititem-form');
|
||||
const isEditMode = existingForms.length > 0 &&
|
||||
document.querySelector('[name$="-id"]') !== null &&
|
||||
document.querySelector('[name$="-id"]').value !== '';
|
||||
|
||||
// Если режим редактирования - показываем карточку поиска
|
||||
// Если создание - скрываем (будет одна пустая форма)
|
||||
const searchCard = document.getElementById('searchCard');
|
||||
if (!isEditMode && searchCard) {
|
||||
searchCard.style.display = 'none';
|
||||
}
|
||||
|
||||
// ========== ФУНКЦИИ ДЛЯ УПРАВЛЕНИЯ ВИДИМОСТЬЮ ПОЛЕЙ ==========
|
||||
function updateFieldStatus(form) {
|
||||
const productSelect = form.querySelector('[name$="-product"]');
|
||||
const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
|
||||
|
||||
if (productSelect && variantGroupSelect) {
|
||||
const hasProduct = productSelect.value;
|
||||
const hasVariant = variantGroupSelect.value;
|
||||
|
||||
// Если выбран товар - отключаем группу вариантов
|
||||
if (hasProduct) {
|
||||
variantGroupSelect.disabled = true;
|
||||
variantGroupSelect.closest('.col-md-6').style.opacity = '0.6';
|
||||
} else {
|
||||
variantGroupSelect.disabled = false;
|
||||
variantGroupSelect.closest('.col-md-6').style.opacity = '1';
|
||||
}
|
||||
|
||||
// Если выбрана группа - отключаем товар
|
||||
if (hasVariant) {
|
||||
productSelect.disabled = true;
|
||||
productSelect.closest('.col-md-6').style.opacity = '0.6';
|
||||
} else {
|
||||
productSelect.disabled = false;
|
||||
productSelect.closest('.col-md-6').style.opacity = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализируем все существующие формы
|
||||
const kititemForms = document.querySelectorAll('.kititem-form');
|
||||
kititemForms.forEach((form) => {
|
||||
updateFieldStatus(form);
|
||||
const productSelect = form.querySelector('[name$="-product"]');
|
||||
const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
|
||||
if (productSelect) productSelect.addEventListener('change', () => updateFieldStatus(form));
|
||||
if (variantGroupSelect) variantGroupSelect.addEventListener('change', () => updateFieldStatus(form));
|
||||
});
|
||||
|
||||
// ========== ФУНКЦИИ ДЛЯ ПОИСКА И ДОБАВЛЕНИЯ ТОВАРОВ ==========
|
||||
const searchInput = document.getElementById('productSearch');
|
||||
const searchBtn = document.getElementById('searchBtn');
|
||||
const searchResults = document.getElementById('searchResults');
|
||||
const managementForm = document.querySelector('[name$="TOTAL_FORMS"]');
|
||||
|
||||
// Функция для выполнения поиска
|
||||
async function performSearch(query) {
|
||||
if (query.length < 2) {
|
||||
searchResults.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`{% url 'products:api-search-products-variants' %}?q=${encodeURIComponent(query)}`);
|
||||
const data = await response.json();
|
||||
|
||||
searchResults.innerHTML = '';
|
||||
|
||||
if (data.results.length === 0) {
|
||||
searchResults.innerHTML = '<div class="list-group-item text-muted">Товары не найдены</div>';
|
||||
} else {
|
||||
data.results.forEach((item) => {
|
||||
const resultItem = document.createElement('button');
|
||||
resultItem.type = 'button';
|
||||
resultItem.className = 'list-group-item list-group-item-action';
|
||||
resultItem.innerHTML = `
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h6 class="mb-1">${item.name}</h6>
|
||||
<small class="text-muted">
|
||||
${item.type === 'product' ? '📦 Товар • Цена: ' + item.price + ' ₽' : '📋 Варианты • Вариантов: ' + item.count}
|
||||
</small>
|
||||
</div>
|
||||
<span class="badge ${item.type === 'product' ? 'bg-success' : 'bg-primary'}">
|
||||
${item.type === 'product' ? 'Товар' : 'Варианты'}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
resultItem.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
addItemToFormset(item);
|
||||
});
|
||||
|
||||
searchResults.appendChild(resultItem);
|
||||
});
|
||||
}
|
||||
|
||||
searchResults.style.display = 'block';
|
||||
} catch (error) {
|
||||
console.error('Ошибка при поиске:', error);
|
||||
searchResults.innerHTML = '<div class="list-group-item text-danger">Ошибка при поиске</div>';
|
||||
searchResults.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Функция для добавления товара в формсет
|
||||
function addItemToFormset(item) {
|
||||
const formsContainer = document.getElementById('kititem-forms');
|
||||
const existingForms = formsContainer.querySelectorAll('.kititem-form');
|
||||
|
||||
// Ищем первую пустую форму и используем ее
|
||||
let emptyForm = null;
|
||||
existingForms.forEach((form) => {
|
||||
if (!emptyForm) {
|
||||
const productSelect = form.querySelector('[name$="-product"]');
|
||||
const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
|
||||
const deleteCheckbox = form.querySelector('[name$="-DELETE"]');
|
||||
|
||||
// Если форма пустая и не помечена на удаление
|
||||
if ((!productSelect || !productSelect.value) &&
|
||||
(!variantGroupSelect || !variantGroupSelect.value) &&
|
||||
(!deleteCheckbox || !deleteCheckbox.checked)) {
|
||||
emptyForm = form;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Если нашли пустую форму, заполняем ее
|
||||
if (emptyForm) {
|
||||
fillFormWithItem(emptyForm, item);
|
||||
} else {
|
||||
// Если нет пустой формы, создаем новую
|
||||
const totalForms = parseInt(document.querySelector('[name*="TOTAL_FORMS"]').value);
|
||||
const newFormHtml = createNewKitItemForm(totalForms, item);
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.innerHTML = newFormHtml;
|
||||
formsContainer.appendChild(tempDiv.firstElementChild);
|
||||
|
||||
// Обновляем счетчик форм
|
||||
document.querySelector('[name*="TOTAL_FORMS"]').value = totalForms + 1;
|
||||
|
||||
// Инициализируем новую форму
|
||||
const newForm = formsContainer.lastElementChild;
|
||||
initializeForm(newForm);
|
||||
}
|
||||
|
||||
// Очищаем поиск
|
||||
searchInput.value = '';
|
||||
searchResults.style.display = 'none';
|
||||
|
||||
// Показываем сообщение
|
||||
const alertDiv = document.createElement('div');
|
||||
alertDiv.className = 'alert alert-success alert-dismissible fade show';
|
||||
alertDiv.setAttribute('role', 'alert');
|
||||
alertDiv.innerHTML = `
|
||||
<strong>✓ Товар добавлен!</strong> "${item.name}" добавлен в комплект.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.querySelector('.card-body').prepend(alertDiv);
|
||||
setTimeout(() => alertDiv.remove(), 3000);
|
||||
}
|
||||
|
||||
// Функция для заполнения существующей формы
|
||||
function fillFormWithItem(form, item) {
|
||||
const itemType = item.type === 'product' ? 'product' : 'variant';
|
||||
const selectedId = item.id;
|
||||
|
||||
if (itemType === 'product') {
|
||||
const productSelect = form.querySelector('[name$="-product"]');
|
||||
if (productSelect) {
|
||||
productSelect.value = selectedId;
|
||||
productSelect.innerHTML = `<option value="">---------</option><option value="${selectedId}" selected>${item.name}</option>`;
|
||||
productSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
} else {
|
||||
const variantSelect = form.querySelector('[name$="-variant_group"]');
|
||||
if (variantSelect) {
|
||||
variantSelect.value = selectedId;
|
||||
variantSelect.innerHTML = `<option value="">---------</option><option value="${selectedId}" selected>${item.name}</option>`;
|
||||
variantSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
updateFieldStatus(form);
|
||||
}
|
||||
|
||||
// Функция для инициализации формы
|
||||
function initializeForm(form) {
|
||||
updateFieldStatus(form);
|
||||
const productSelect = form.querySelector('[name$="-product"]');
|
||||
const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
|
||||
if (productSelect) productSelect.addEventListener('change', () => updateFieldStatus(form));
|
||||
if (variantGroupSelect) variantGroupSelect.addEventListener('change', () => updateFieldStatus(form));
|
||||
}
|
||||
|
||||
// Функция для создания HTML новой формы
|
||||
function createNewKitItemForm(formIndex, item) {
|
||||
const fieldName = `kititem_set-${formIndex}`;
|
||||
const itemType = item.type === 'product' ? 'product' : 'variant';
|
||||
const selectedId = item.id;
|
||||
const selectedField = itemType === 'product' ? 'product' : 'variant_group';
|
||||
|
||||
return `
|
||||
<div class="card mb-3 kititem-form" data-form-index="${formIndex}">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<span><strong>Компонент #${formIndex + 1}</strong></span>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="${fieldName}-DELETE" name="${fieldName}-DELETE">
|
||||
<label class="form-check-label" for="${fieldName}-DELETE">
|
||||
Удалить
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="${fieldName}-product" class="form-label">
|
||||
Конкретный товар
|
||||
</label>
|
||||
<select class="form-select" id="${fieldName}-product" name="${fieldName}-product"
|
||||
data-new-record="False" ${itemType === 'variant' ? 'disabled' : ''} style="${itemType === 'variant' ? 'opacity: 0.6' : ''}">
|
||||
<option value="">---------</option>
|
||||
${itemType === 'product' ? `<option value="${selectedId}" selected>${item.name}</option>` : ''}
|
||||
</select>
|
||||
<small class="form-text text-muted">Конкретный товар (если выбран, группа вариантов не нужна)</small>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="${fieldName}-variant_group" class="form-label">
|
||||
Группа вариантов
|
||||
</label>
|
||||
<select class="form-select" id="${fieldName}-variant_group" name="${fieldName}-variant_group"
|
||||
data-new-record="False" ${itemType === 'product' ? 'disabled' : ''} style="${itemType === 'product' ? 'opacity: 0.6' : ''}">
|
||||
<option value="">---------</option>
|
||||
${itemType === 'variant' ? `<option value="${selectedId}" selected>${item.name}</option>` : ''}
|
||||
</select>
|
||||
<small class="form-text text-muted">Группа вариантов (если выбрана, конкретный товар не нужен)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="${fieldName}-quantity" class="form-label">
|
||||
Количество
|
||||
</label>
|
||||
<input type="number" class="form-control" id="${fieldName}-quantity" name="${fieldName}-quantity"
|
||||
value="1" step="0.001" min="0">
|
||||
</div>
|
||||
|
||||
<div class="col-md-8 mb-3">
|
||||
<label for="${fieldName}-notes" class="form-label">
|
||||
Примечание
|
||||
</label>
|
||||
<input type="text" class="form-control" id="${fieldName}-notes" name="${fieldName}-notes"
|
||||
placeholder="Опциональное примечание">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="${fieldName}-id">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Обработчики событий поиска
|
||||
searchBtn.addEventListener('click', () => {
|
||||
performSearch(searchInput.value);
|
||||
});
|
||||
|
||||
searchInput.addEventListener('keyup', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
performSearch(searchInput.value);
|
||||
} else {
|
||||
// Автопоиск с задержкой
|
||||
clearTimeout(searchInput.searchTimeout);
|
||||
searchInput.searchTimeout = setTimeout(() => {
|
||||
performSearch(searchInput.value);
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
|
||||
// Очистка результатов при клике вне области
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#productSearch') && !e.target.closest('#searchResults') && !e.target.closest('#searchBtn')) {
|
||||
// searchResults.style.display = 'none';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ========== ОЧИСТКА ПУСТЫХ ФОРМ ПЕРЕД СОХРАНЕНИЕМ ==========
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const kitForm = document.querySelector('form[method="post"]');
|
||||
if (kitForm) {
|
||||
kitForm.addEventListener('submit', function(e) {
|
||||
// Отмечаем пустые компоненты для удаления
|
||||
const formsContainer = document.getElementById('kititem-forms');
|
||||
if (formsContainer) {
|
||||
const allForms = formsContainer.querySelectorAll('.kititem-form');
|
||||
allForms.forEach((form) => {
|
||||
const productSelect = form.querySelector('[name$="-product"]');
|
||||
const variantGroupSelect = form.querySelector('[name$="-variant_group"]');
|
||||
const deleteCheckbox = form.querySelector('[name$="-DELETE"]');
|
||||
|
||||
const hasProduct = productSelect && productSelect.value;
|
||||
const hasVariant = variantGroupSelect && variantGroupSelect.value;
|
||||
|
||||
// Если форма пустая - помечаем на удаление
|
||||
if (!hasProduct && !hasVariant && deleteCheckbox) {
|
||||
deleteCheckbox.checked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
// Продолжаем нормальную отправку формы
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========== УПРАВЛЕНИЕ ВИДИМОСТЬЮ ПОЛЯ FIXED_PRICE ==========
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const pricingMethodField = document.querySelector('[name="pricing_method"]');
|
||||
const fixedPriceField = document.querySelector('[name="fixed_price"]');
|
||||
const fixedPriceAlert = document.getElementById('fixed-price-alert');
|
||||
|
||||
function updatePricingFieldsState() {
|
||||
if (!pricingMethodField || !fixedPriceField) return;
|
||||
|
||||
const method = pricingMethodField.value;
|
||||
|
||||
if (method === 'fixed') {
|
||||
// Поле активно
|
||||
fixedPriceField.disabled = false;
|
||||
fixedPriceField.style.opacity = '1';
|
||||
if (fixedPriceAlert) {
|
||||
fixedPriceAlert.classList.add('d-none');
|
||||
}
|
||||
} else {
|
||||
// Поле неактивно
|
||||
fixedPriceField.disabled = true;
|
||||
fixedPriceField.style.opacity = '0.6';
|
||||
if (fixedPriceAlert) {
|
||||
fixedPriceAlert.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализируем состояние при загрузке
|
||||
updatePricingFieldsState();
|
||||
|
||||
// Обновляем при изменении метода ценообразования
|
||||
if (pricingMethodField) {
|
||||
pricingMethodField.addEventListener('change', updatePricingFieldsState);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
127
myproject/products/templates/products/productkit_list.html
Normal file
127
myproject/products/templates/products/productkit_list.html
Normal file
@@ -0,0 +1,127 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Список комплектов (букетов){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<h2 class="mb-4">Список комплектов (букетов)</h2>
|
||||
|
||||
<!-- Панель фильтрации -->
|
||||
{% include 'components/filter_panel.html' with title="Комплекты" filters=filters action_buttons=action_buttons %}
|
||||
|
||||
{% if kits %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th>Фото</th>
|
||||
<th>Название</th>
|
||||
<th>Артикул</th>
|
||||
<th>Категория</th>
|
||||
<th>Цена продажи</th>
|
||||
<th>Компонентов</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for kit in kits %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if kit.photos.all %}
|
||||
{% with photo=kit.photos.first %}
|
||||
<img src="{{ photo.image.url }}" alt="{{ kit.name }}" style="max-width: 50px; max-height: 50px;" class="img-thumbnail">
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
<span class="text-muted">Нет фото</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{% url 'products:productkit-detail' kit.pk %}">{{ kit.name }}</a>
|
||||
</td>
|
||||
<td>{{ kit.sku }}</td>
|
||||
<td>
|
||||
{% if kit.categories.all %}
|
||||
{% for category in kit.categories.all %}
|
||||
{{ category.name }}{% if not forloop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ kit.get_sale_price|floatformat:2 }} руб.</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">{{ kit.get_total_components_count }} шт</span>
|
||||
{% if kit.get_components_with_variants_count > 0 %}
|
||||
<span class="badge bg-primary" title="С вариантами">
|
||||
<i class="bi bi-shuffle"></i> {{ kit.get_components_with_variants_count }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if kit.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">
|
||||
<a href="{% url 'products:productkit-detail' kit.pk %}" class="btn btn-outline-info" title="Просмотреть">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="{% url 'products:productkit-update' kit.pk %}" class="btn btn-outline-primary" title="Редактировать">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="{% url 'products:productkit-delete' kit.pk %}" class="btn btn-outline-danger" title="Удалить">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
</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>
|
||||
{% if perms.products.add_productkit %}
|
||||
<a href="{% url 'products:productkit-create' %}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Создать первый комплект
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
3
myproject/products/tests.py
Normal file
3
myproject/products/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
48
myproject/products/urls.py
Normal file
48
myproject/products/urls.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'products'
|
||||
|
||||
urlpatterns = [
|
||||
# CRUD URLs for Product
|
||||
path('', views.ProductListView.as_view(), name='product-list'),
|
||||
path('create/', views.ProductCreateView.as_view(), name='product-create'),
|
||||
path('<int:pk>/', views.ProductDetailView.as_view(), name='product-detail'),
|
||||
path('<int:pk>/update/', views.ProductUpdateView.as_view(), name='product-update'),
|
||||
path('<int:pk>/delete/', views.ProductDeleteView.as_view(), name='product-delete'),
|
||||
|
||||
# Photo management
|
||||
path('photo/<int:pk>/delete/', views.product_photo_delete, name='product-photo-delete'),
|
||||
path('photo/<int:pk>/set-main/', views.product_photo_set_main, name='product-photo-set-main'),
|
||||
path('photo/<int:pk>/move-up/', views.product_photo_move_up, name='product-photo-move-up'),
|
||||
path('photo/<int:pk>/move-down/', views.product_photo_move_down, name='product-photo-move-down'),
|
||||
|
||||
# CRUD URLs for ProductKit (комплекты/букеты)
|
||||
path('kits/', views.ProductKitListView.as_view(), name='productkit-list'),
|
||||
path('kits/create/', views.ProductKitCreateView.as_view(), name='productkit-create'),
|
||||
path('kits/<int:pk>/', views.ProductKitDetailView.as_view(), name='productkit-detail'),
|
||||
path('kits/<int:pk>/update/', views.ProductKitUpdateView.as_view(), name='productkit-update'),
|
||||
path('kits/<int:pk>/delete/', views.ProductKitDeleteView.as_view(), name='productkit-delete'),
|
||||
|
||||
# Photo management for ProductKit
|
||||
path('kits/photo/<int:pk>/delete/', views.productkit_photo_delete, name='productkit-photo-delete'),
|
||||
path('kits/photo/<int:pk>/set-main/', views.productkit_photo_set_main, name='productkit-photo-set-main'),
|
||||
path('kits/photo/<int:pk>/move-up/', views.productkit_photo_move_up, name='productkit-photo-move-up'),
|
||||
path('kits/photo/<int:pk>/move-down/', views.productkit_photo_move_down, name='productkit-photo-move-down'),
|
||||
|
||||
# API endpoints
|
||||
path('api/search-products-variants/', views.search_products_and_variants, name='api-search-products-variants'),
|
||||
|
||||
# CRUD URLs for ProductCategory
|
||||
path('categories/', views.ProductCategoryListView.as_view(), name='category-list'),
|
||||
path('categories/create/', views.ProductCategoryCreateView.as_view(), name='category-create'),
|
||||
path('categories/<int:pk>/', views.ProductCategoryDetailView.as_view(), name='category-detail'),
|
||||
path('categories/<int:pk>/update/', views.ProductCategoryUpdateView.as_view(), name='category-update'),
|
||||
path('categories/<int:pk>/delete/', views.ProductCategoryDeleteView.as_view(), name='category-delete'),
|
||||
|
||||
# Category photo management
|
||||
path('categories/photo/<int:pk>/delete/', views.category_photo_delete, name='category-photo-delete'),
|
||||
path('categories/photo/<int:pk>/set-main/', views.category_photo_set_main, name='category-photo-set-main'),
|
||||
path('categories/photo/<int:pk>/move-up/', views.category_photo_move_up, name='category-photo-move-up'),
|
||||
path('categories/photo/<int:pk>/move-down/', views.category_photo_move_down, name='category-photo-move-down'),
|
||||
]
|
||||
4
myproject/products/utils/__init__.py
Normal file
4
myproject/products/utils/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
Utility package for the products app.
|
||||
Contains various helper functions and utilities.
|
||||
"""
|
||||
212
myproject/products/utils/sku_generator.py
Normal file
212
myproject/products/utils/sku_generator.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
Utility functions for generating SKUs for products, kits, and categories.
|
||||
|
||||
New SKU format:
|
||||
- Products: PROD-XXXXXX or PROD-XXXXXX-VARIANT
|
||||
- Kits: KIT-XXXXXX
|
||||
- Categories: CAT-XXXX
|
||||
|
||||
Examples:
|
||||
- PROD-000001
|
||||
- PROD-000002-50
|
||||
- KIT-000001
|
||||
- CAT-0001
|
||||
"""
|
||||
import re
|
||||
from string import ascii_uppercase
|
||||
|
||||
|
||||
def parse_variant_suffix(name):
|
||||
"""
|
||||
Извлекает суффикс варианта из названия товара.
|
||||
|
||||
Поддерживаемые форматы:
|
||||
- "Роза Freedom 50см" -> "50"
|
||||
- "Роза Freedom 60 см" -> "60"
|
||||
- "Лента 2.5м" -> "25" (метры в дециметры)
|
||||
- "Коробка S" -> "S"
|
||||
- "Коробка размер M" -> "M"
|
||||
|
||||
Args:
|
||||
name (str): Название товара
|
||||
|
||||
Returns:
|
||||
str or None: Извлеченный суффикс или None
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
|
||||
# Паттерны для извлечения суффикса
|
||||
patterns = [
|
||||
# Размеры в см: "50см", "60 см"
|
||||
(r'(\d+)\s*см', lambda m: m.group(1)),
|
||||
|
||||
# Размеры в метрах: "2.5м" -> "25" (конвертируем в дециметры)
|
||||
(r'(\d+\.?\d*)\s*м(?:\s|$)', lambda m: str(int(float(m.group(1)) * 10))),
|
||||
|
||||
# Буквенные размеры в конце: "S", "M", "L", "XL"
|
||||
(r'\b([XSML]{1,3})\s*$', lambda m: m.group(1)),
|
||||
|
||||
# "размер S", "размер M"
|
||||
(r'размер\s+([XSML]{1,3})', lambda m: m.group(1)),
|
||||
|
||||
# Просто число в конце: "Товар 50"
|
||||
(r'\s+(\d+)\s*$', lambda m: m.group(1)),
|
||||
]
|
||||
|
||||
for pattern, extractor in patterns:
|
||||
match = re.search(pattern, name, re.IGNORECASE)
|
||||
if match:
|
||||
return extractor(match)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def ensure_sku_unique(base_sku, exclude_id=None, model_type=None):
|
||||
"""
|
||||
Проверяет уникальность артикула и добавляет буквенный суффикс при конфликте.
|
||||
|
||||
Если артикул уже существует:
|
||||
PROD-000001 -> PROD-000001A -> PROD-000001B -> ... -> PROD-000001Z
|
||||
|
||||
Args:
|
||||
base_sku (str): Базовый артикул для проверки
|
||||
exclude_id (int): ID товара/комплекта/категории, который нужно исключить из проверки
|
||||
model_type (str): Тип модели ('product', 'kit', 'category') для исключения из проверки
|
||||
|
||||
Returns:
|
||||
str: Уникальный артикул
|
||||
"""
|
||||
from products.models import Product, ProductKit, ProductCategory
|
||||
|
||||
# Проверяем, существует ли базовый артикул
|
||||
sku = base_sku
|
||||
|
||||
# Проверка во всех моделях с артикулами
|
||||
def sku_exists(sku_to_check):
|
||||
product_exists = Product.objects.filter(sku=sku_to_check)
|
||||
if model_type == 'product' and exclude_id:
|
||||
product_exists = product_exists.exclude(id=exclude_id)
|
||||
product_exists = product_exists.exists()
|
||||
|
||||
kit_exists = ProductKit.objects.filter(sku=sku_to_check)
|
||||
if model_type == 'kit' and exclude_id:
|
||||
kit_exists = kit_exists.exclude(id=exclude_id)
|
||||
kit_exists = kit_exists.exists()
|
||||
|
||||
category_exists = ProductCategory.objects.filter(sku=sku_to_check)
|
||||
if model_type == 'category' and exclude_id:
|
||||
category_exists = category_exists.exclude(id=exclude_id)
|
||||
category_exists = category_exists.exists()
|
||||
|
||||
return product_exists or kit_exists or category_exists
|
||||
|
||||
# Если базовый артикул свободен, возвращаем его
|
||||
if not sku_exists(sku):
|
||||
return sku
|
||||
|
||||
# Иначе добавляем буквы A-Z
|
||||
for letter in ascii_uppercase:
|
||||
sku_with_letter = f"{base_sku}{letter}"
|
||||
if not sku_exists(sku_with_letter):
|
||||
return sku_with_letter
|
||||
|
||||
# Если все буквы заняты (маловероятно), добавляем AA, AB, и т.д.
|
||||
for first_letter in ascii_uppercase:
|
||||
for second_letter in ascii_uppercase:
|
||||
sku_with_letters = f"{base_sku}{first_letter}{second_letter}"
|
||||
if not sku_exists(sku_with_letters):
|
||||
return sku_with_letters
|
||||
|
||||
# В крайнем случае возвращаем базовый + timestamp
|
||||
from django.utils import timezone
|
||||
return f"{base_sku}-{timezone.now().strftime('%Y%m%d%H%M%S')}"
|
||||
|
||||
|
||||
def generate_product_sku(product):
|
||||
"""
|
||||
Генерирует уникальный артикул для товара.
|
||||
|
||||
Формат: PROD-XXXXXX или PROD-XXXXXX-VARIANT
|
||||
|
||||
Args:
|
||||
product: Экземпляр модели Product
|
||||
|
||||
Returns:
|
||||
str: Сгенерированный артикул
|
||||
"""
|
||||
from products.models import SKUCounter
|
||||
|
||||
# Получаем следующий номер из глобального счетчика
|
||||
next_number = SKUCounter.get_next_value('product')
|
||||
|
||||
# Форматируем номер с ведущими нулями (6 цифр)
|
||||
base_sku = f"PROD-{next_number:06d}"
|
||||
|
||||
# Определяем суффикс варианта
|
||||
variant_suffix = None
|
||||
|
||||
# 1. Если суффикс задан вручную в поле variant_suffix
|
||||
if product.variant_suffix:
|
||||
variant_suffix = product.variant_suffix.strip()
|
||||
# 2. Если суффикс не задан, пытаемся извлечь из названия
|
||||
# (это работает и для товаров в группах вариантов, и без них)
|
||||
else:
|
||||
parsed_suffix = parse_variant_suffix(product.name)
|
||||
if parsed_suffix:
|
||||
variant_suffix = parsed_suffix
|
||||
|
||||
# Добавляем суффикс, если он есть
|
||||
if variant_suffix:
|
||||
base_sku = f"{base_sku}-{variant_suffix}"
|
||||
|
||||
# Обеспечиваем уникальность
|
||||
unique_sku = ensure_sku_unique(base_sku, exclude_id=product.id if product.id else None, model_type='product')
|
||||
|
||||
return unique_sku
|
||||
|
||||
|
||||
def generate_kit_sku():
|
||||
"""
|
||||
Генерирует уникальный артикул для комплекта.
|
||||
|
||||
Формат: KIT-XXXXXX
|
||||
|
||||
Returns:
|
||||
str: Сгенерированный артикул
|
||||
"""
|
||||
from products.models import SKUCounter
|
||||
|
||||
# Получаем следующий номер из глобального счетчика
|
||||
next_number = SKUCounter.get_next_value('kit')
|
||||
|
||||
# Форматируем номер с ведущими нулями (6 цифр)
|
||||
base_sku = f"KIT-{next_number:06d}"
|
||||
|
||||
# Обеспечиваем уникальность
|
||||
unique_sku = ensure_sku_unique(base_sku, model_type='kit')
|
||||
|
||||
return unique_sku
|
||||
|
||||
|
||||
def generate_category_sku():
|
||||
"""
|
||||
Генерирует уникальный артикул для категории.
|
||||
|
||||
Формат: CAT-XXXX (4 цифры)
|
||||
|
||||
Returns:
|
||||
str: Сгенерированный артикул
|
||||
"""
|
||||
from products.models import SKUCounter
|
||||
|
||||
# Получаем следующий номер из глобального счетчика
|
||||
next_number = SKUCounter.get_next_value('category')
|
||||
|
||||
# Форматируем номер с ведущими нулями (4 цифры)
|
||||
base_sku = f"CAT-{next_number:04d}"
|
||||
|
||||
# Обеспечиваем уникальность
|
||||
unique_sku = ensure_sku_unique(base_sku, model_type='category')
|
||||
|
||||
return unique_sku
|
||||
83
myproject/products/utils/stock_manager.py
Normal file
83
myproject/products/utils/stock_manager.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Менеджер для работы с остатками товаров.
|
||||
|
||||
Это заглушка для будущей интеграции с системой складского учёта.
|
||||
В будущем здесь будет реальная логика проверки остатков на складе.
|
||||
"""
|
||||
|
||||
|
||||
class StockManager:
|
||||
"""
|
||||
Менеджер для работы с остатками товаров (заглушка для будущей реализации).
|
||||
|
||||
В будущем этот класс будет интегрирован с реальной системой складского учёта,
|
||||
чтобы проверять фактические остатки товаров на складе.
|
||||
"""
|
||||
|
||||
def check_stock(self, product, quantity):
|
||||
"""
|
||||
Проверяет наличие товара в нужном количестве.
|
||||
|
||||
Args:
|
||||
product: Экземпляр модели Product
|
||||
quantity: Требуемое количество (Decimal)
|
||||
|
||||
Returns:
|
||||
bool: True если товар доступен в нужном количестве, False иначе
|
||||
|
||||
TODO: Интегрировать с реальной системой складского учёта
|
||||
"""
|
||||
# Пока всегда возвращаем True (заглушка)
|
||||
# В будущем здесь будет проверка реальных остатков
|
||||
return True
|
||||
|
||||
def get_available_quantity(self, product):
|
||||
"""
|
||||
Возвращает доступное количество товара на складе.
|
||||
|
||||
Args:
|
||||
product: Экземпляр модели Product
|
||||
|
||||
Returns:
|
||||
Decimal: Доступное количество товара
|
||||
|
||||
TODO: Интегрировать с реальной системой складского учёта
|
||||
"""
|
||||
# Заглушка - возвращаем большое число
|
||||
# В будущем здесь будет запрос к складской системе
|
||||
from decimal import Decimal
|
||||
return Decimal('9999')
|
||||
|
||||
def reserve_stock(self, product, quantity, order_id=None):
|
||||
"""
|
||||
Резервирует товар под заказ.
|
||||
|
||||
Args:
|
||||
product: Экземпляр модели Product
|
||||
quantity: Количество для резервирования (Decimal)
|
||||
order_id: ID заказа (опционально)
|
||||
|
||||
Returns:
|
||||
bool: True если резервирование успешно, False иначе
|
||||
|
||||
TODO: Интегрировать с реальной системой складского учёта
|
||||
"""
|
||||
# Заглушка
|
||||
return True
|
||||
|
||||
def release_stock(self, product, quantity, order_id=None):
|
||||
"""
|
||||
Освобождает зарезервированный товар.
|
||||
|
||||
Args:
|
||||
product: Экземпляр модели Product
|
||||
quantity: Количество для освобождения (Decimal)
|
||||
order_id: ID заказа (опционально)
|
||||
|
||||
Returns:
|
||||
bool: True если освобождение успешно, False иначе
|
||||
|
||||
TODO: Интегрировать с реальной системой складского учёта
|
||||
"""
|
||||
# Заглушка
|
||||
return True
|
||||
1201
myproject/products/views.py
Normal file
1201
myproject/products/views.py
Normal file
File diff suppressed because it is too large
Load Diff
45
myproject/static/admin/css/custom_nested.css
Normal file
45
myproject/static/admin/css/custom_nested.css
Normal file
@@ -0,0 +1,45 @@
|
||||
/* Кастомные стили для nested admin */
|
||||
|
||||
/* Улучшение отображения вложенных inline */
|
||||
.djn-item {
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
|
||||
.djn-item h3 {
|
||||
margin-top: 0;
|
||||
color: #417690;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Приоритеты внутри позиций */
|
||||
.djn-items > .djn-item .djn-items {
|
||||
margin-left: 20px;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.djn-items > .djn-item .djn-items > .djn-item {
|
||||
background-color: #fafafa;
|
||||
padding: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* Кнопки добавления */
|
||||
.djn-add-item {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
/* Заголовки для приоритетов */
|
||||
.djn-items > .djn-item .djn-items > h3 {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
111
myproject/static/css/filter_panel.css
Normal file
111
myproject/static/css/filter_panel.css
Normal file
@@ -0,0 +1,111 @@
|
||||
/* Стили для переиспользуемого компонента filter_panel */
|
||||
|
||||
.filter-panel {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.filter-panel .card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.filter-panel .card-title {
|
||||
color: #495057;
|
||||
font-weight: 600;
|
||||
font-size: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-panel .card-title i {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.filter-panel hr {
|
||||
margin: 1rem 0;
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.filter-panel .form-label {
|
||||
font-weight: 500;
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.filter-panel .form-label i {
|
||||
color: #6c757d;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.filter-panel .form-control,
|
||||
.filter-panel .form-select {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ced4da;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.filter-panel .form-control:focus,
|
||||
.filter-panel .form-select:focus {
|
||||
border-color: #0d6efd;
|
||||
box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.15);
|
||||
}
|
||||
|
||||
.filter-panel .btn-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-panel .btn {
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.filter-panel .btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.filter-panel .btn i {
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-panel .form-check-input:checked {
|
||||
background-color: #0d6efd;
|
||||
border-color: #0d6efd;
|
||||
}
|
||||
|
||||
.filter-panel .form-check-label {
|
||||
font-size: 0.9rem;
|
||||
color: #495057;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.filter-panel .card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.filter-panel .d-flex.justify-content-between {
|
||||
flex-direction: column;
|
||||
align-items: flex-start !important;
|
||||
}
|
||||
|
||||
.filter-panel .btn-toolbar {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.filter-panel .card-title {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
40
myproject/templates/base.html
Normal file
40
myproject/templates/base.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Мой Django Проект{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
|
||||
<style>
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
padding-top: 56px; /* Add space for fixed navbar */
|
||||
}
|
||||
.form-container {
|
||||
max-width: 400px;
|
||||
margin: 50px auto;
|
||||
padding: 30px;
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Include the navbar component -->
|
||||
{% include 'navbar.html' %}
|
||||
|
||||
<!-- Сообщения для залогиненных пользователей отображаются здесь -->
|
||||
{% if user.is_authenticated %}
|
||||
{% include 'components/messages.html' %}
|
||||
{% endif %}
|
||||
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
56
myproject/templates/change_password.html
Normal file
56
myproject/templates/change_password.html
Normal file
@@ -0,0 +1,56 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Смена пароля{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="form-container">
|
||||
<h2 class="text-center mb-4">Смена пароля</h2>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
{% include 'accounts/password_input.html' with field_name=form.old_password.id_for_label field_label='Старый пароль' required=True field_errors=form.old_password.errors %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{% include 'accounts/password_input.html' with field_name=form.new_password1.id_for_label field_label='Новый пароль' required=True field_errors=form.new_password1.errors %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
{% include 'accounts/password_input.html' with field_name=form.new_password2.id_for_label field_label='Подтверждение нового пароля' required=True field_errors=form.new_password2.errors %}
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{% url 'accounts:profile' %}" class="btn btn-outline-secondary me-md-2">
|
||||
<i class="bi bi-arrow-left me-1"></i> Отмена
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-save me-1"></i> Сохранить изменения
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Добавляем обработчик для показа/скрытия пароля
|
||||
document.querySelectorAll('.show-password-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const targetInput = document.getElementById(targetId);
|
||||
const icon = this.querySelector('i');
|
||||
|
||||
if (targetInput.type === 'password') {
|
||||
targetInput.type = 'text';
|
||||
icon.classList.remove('bi-eye');
|
||||
icon.classList.add('bi-eye-slash');
|
||||
} else {
|
||||
targetInput.type = 'password';
|
||||
icon.classList.remove('bi-eye-slash');
|
||||
icon.classList.add('bi-eye');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
142
myproject/templates/components/filter_panel.html
Normal file
142
myproject/templates/components/filter_panel.html
Normal file
@@ -0,0 +1,142 @@
|
||||
{% comment %}
|
||||
Переиспользуемый компонент панели фильтрации
|
||||
|
||||
Параметры:
|
||||
- title: заголовок панели (default: "Фильтры")
|
||||
- filters: объект с данными фильтров
|
||||
- categories: список категорий
|
||||
- tags: список тегов
|
||||
- current: текущие значения фильтров
|
||||
- action_buttons: список кнопок действий
|
||||
- url: ссылка
|
||||
- text: текст кнопки
|
||||
- class: CSS классы (default: 'btn-primary')
|
||||
- icon: иконка Bootstrap Icons (без префикса bi-)
|
||||
- show_search: показать поиск (default: True)
|
||||
- show_category: показать фильтр категорий (default: True)
|
||||
- show_status: показать фильтр статуса (default: True)
|
||||
- show_tags: показать фильтр тегов (default: True)
|
||||
|
||||
Пример использования:
|
||||
{% include 'components/filter_panel.html' with title="Товары" filters=filters action_buttons=action_buttons %}
|
||||
{% endcomment %}
|
||||
|
||||
{% load static %}
|
||||
|
||||
<link rel="stylesheet" href="{% static 'css/filter_panel.css' %}">
|
||||
|
||||
<div class="filter-panel 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> {{ title|default:"Фильтры" }}
|
||||
</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">
|
||||
<!-- Поле поиска -->
|
||||
{% if show_search|default:True %}
|
||||
<div class="col-12 col-md-4">
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
<!-- Фильтр по категории -->
|
||||
{% if show_category|default:True and filters.categories %}
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="category" class="form-label">
|
||||
<i class="bi bi-folder"></i> Категория
|
||||
</label>
|
||||
<select class="form-select" id="category" name="category">
|
||||
<option value="">Все категории</option>
|
||||
{% for cat in filters.categories %}
|
||||
<option value="{{ cat.id }}" {% if filters.current.category|stringformat:"s" == cat.id|stringformat:"s" %}selected{% endif %}>
|
||||
{{ cat.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Фильтр по статусу -->
|
||||
{% if show_status|default:True %}
|
||||
<div class="col-12 col-md-2">
|
||||
<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>
|
||||
{% endif %}
|
||||
|
||||
<!-- Фильтр по тегам -->
|
||||
{% if show_tags|default:True and filters.tags %}
|
||||
<div class="col-12">
|
||||
<label class="form-label">
|
||||
<i class="bi bi-tags"></i> Теги
|
||||
</label>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for tag in filters.tags %}
|
||||
<div class="form-check form-check-inline">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
name="tags"
|
||||
id="tag_{{ tag.id }}"
|
||||
value="{{ tag.id }}"
|
||||
{% if tag.id|stringformat:"s" in filters.current.tags %}checked{% endif %}
|
||||
>
|
||||
<label class="form-check-label" for="tag_{{ tag.id }}">
|
||||
{{ tag.name }}
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Кнопки управления фильтрами -->
|
||||
<div class="col-12">
|
||||
<div class="d-flex gap-2 justify-content-end">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<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>
|
||||
12
myproject/templates/components/messages.html
Normal file
12
myproject/templates/components/messages.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!-- Компонент для отображения Django Messages -->
|
||||
<!-- Использование: include 'components/messages.html' -->
|
||||
{% if messages %}
|
||||
<div class="container mt-3">
|
||||
{% for message in messages %}
|
||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
12
myproject/templates/dashboard.html
Normal file
12
myproject/templates/dashboard.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Панель управления{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="text-center">
|
||||
<h1 class="display-4">Добро пожаловать, {{ user.name|default:user.email }}!</h1>
|
||||
<p class="lead">Вы успешно вошли в систему.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
22
myproject/templates/home.html
Normal file
22
myproject/templates/home.html
Normal file
@@ -0,0 +1,22 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Главная страница{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="text-center">
|
||||
<h1 class="display-4">Добро пожаловать!</h1>
|
||||
<p class="lead">Система аутентификации с подтверждением email</p>
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<p>Рады видеть вас снова, {{ user.name|default:user.email }}!</p>
|
||||
<a href="{% url 'index' %}" class="btn btn-primary">Перейти в панель управления</a>
|
||||
{% else %}
|
||||
<div class="mt-4">
|
||||
<a href="{% url 'accounts:login' %}" class="btn btn-primary me-2">Войти</a>
|
||||
<a href="{% url 'accounts:register' %}" class="btn btn-secondary">Регистрация</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
138
myproject/templates/index.html
Normal file
138
myproject/templates/index.html
Normal file
@@ -0,0 +1,138 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Регистрация / Вход{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Контейнер для сообщений об ошибках - фиксированное место -->
|
||||
<!-- ВАЖНО: На главной странице (регистрация/вход) показываем только ошибки -->
|
||||
<div id="messages-container" style="min-height: 60px;">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
{% if 'danger' in message.tags or 'error' in message.tags %}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 class="text-center mb-4">Добро пожаловать</h2>
|
||||
|
||||
<!-- Вкладки для переключения между регистрацией и входом -->
|
||||
<ul class="nav nav-tabs mb-4">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if not request.GET.tab or request.GET.tab == 'register' %}active{% endif %}" data-bs-toggle="tab" href="#register">Регистрация</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {% if request.GET.tab == 'login' %}active{% endif %}" data-bs-toggle="tab" href="#login">Вход</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Содержимое вкладок -->
|
||||
<div class="tab-content">
|
||||
<!-- Вкладка регистрации -->
|
||||
<div class="tab-pane fade {% if not request.GET.tab or request.GET.tab == 'register' %}show active{% endif %}" id="register">
|
||||
<form method="post" action="{% url 'accounts:register' %}">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.name.id_for_label }}" class="form-label">Имя</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}
|
||||
<div class="text-danger">{{ form.name.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.email.id_for_label }}" class="form-label">Email</label>
|
||||
{{ form.email }}
|
||||
{% if form.email.errors %}
|
||||
<div class="text-danger">{{ form.email.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'accounts/password_input.html' with field_name=form.password1.id_for_label field_label='Пароль' required=True field_errors=form.password1.errors %}
|
||||
{% include 'accounts/password_input.html' with field_name=form.password2.id_for_label field_label='Подтверждение пароля' required=True field_errors=form.password2.errors %}
|
||||
<button type="submit" class="btn btn-primary w-100">Зарегистрироваться</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Вкладка входа -->
|
||||
<div class="tab-pane fade {% if request.GET.tab == 'login' %}show active{% endif %}" id="login">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required>
|
||||
</div>
|
||||
{% include 'accounts/password_input.html' with field_name='password' field_label='Пароль' required=True %}
|
||||
<button type="submit" class="btn btn-primary w-100">Войти</button>
|
||||
|
||||
<!-- Ссылка "Забыли пароль?" -->
|
||||
<div class="text-center mt-3">
|
||||
<a href="#" class="text-decoration-none" data-bs-toggle="modal" data-bs-target="#passwordResetModal">Забыли пароль?</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Модальное окно для восстановления пароля -->
|
||||
<div class="modal fade" id="passwordResetModal" tabindex="-1" aria-labelledby="passwordResetModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="passwordResetModalLabel">Восстановление пароля</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="passwordResetForm" method="post" action="{% url 'accounts:password_reset' %}">
|
||||
{% csrf_token %}
|
||||
<p>Пожалуйста, введите ваш email, и мы отправим вам инструкции по восстановлению пароля.</p>
|
||||
<div class="mb-3">
|
||||
<label for="resetEmail" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="resetEmail" name="email" required>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-primary" form="passwordResetForm">Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Управление вкладками
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const tab = urlParams.get('tab');
|
||||
|
||||
if (tab === 'login') {
|
||||
// Переключаемся на вкладку входа
|
||||
const loginTab = document.querySelector('a[href="#login"]');
|
||||
if(loginTab) {
|
||||
bootstrap.Tab.getOrCreateInstance(loginTab).show();
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем обработчик для показа/скрытия пароля
|
||||
document.querySelectorAll('.show-password-btn').forEach(button => {
|
||||
button.addEventListener('click', function() {
|
||||
const targetId = this.getAttribute('data-target');
|
||||
const targetInput = document.getElementById(targetId);
|
||||
const icon = this.querySelector('i');
|
||||
|
||||
if (targetInput.type === 'password') {
|
||||
targetInput.type = 'text';
|
||||
icon.classList.remove('bi-eye');
|
||||
icon.classList.add('bi-eye-slash');
|
||||
} else {
|
||||
targetInput.type = 'password';
|
||||
icon.classList.remove('bi-eye-slash');
|
||||
icon.classList.add('bi-eye');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
50
myproject/templates/login.html
Normal file
50
myproject/templates/login.html
Normal file
@@ -0,0 +1,50 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Вход{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="form-container">
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="login">
|
||||
<!-- Контейнер для сообщений об ошибках - зарезервированное место -->
|
||||
<!-- ВАЖНО: Показываем только ошибки входа (danger/error), игнорируем success/info и т.д. -->
|
||||
<div id="messages-container" style="min-height: 60px;">
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
{% if 'danger' in message.tags or 'error' in message.tags %}
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2 class="text-center mb-4">Вход</h2>
|
||||
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" required>
|
||||
</div>
|
||||
{% include 'accounts/password_input.html' with field_name='password' field_label='Пароль' required=True %}
|
||||
<button type="submit" class="btn btn-primary w-100">Войти</button>
|
||||
</form>
|
||||
|
||||
<!-- Ссылка на регистрацию -->
|
||||
<div class="text-center mt-3">
|
||||
<a href="{% url 'accounts:register' %}" class="text-decoration-none">Нет аккаунта? Зарегистрируйтесь</a>
|
||||
</div>
|
||||
|
||||
<!-- Ссылка "Забыли пароль?" -->
|
||||
<div class="text-center mt-2">
|
||||
<a href="{% url 'accounts:password_reset' %}" class="text-decoration-none">Забыли пароль?</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
61
myproject/templates/navbar.html
Normal file
61
myproject/templates/navbar.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<!-- navbar.html - Reusable navigation bar component -->
|
||||
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top">
|
||||
<div class="container">
|
||||
<!-- Brand/Logo -->
|
||||
<a class="navbar-brand" href="{% url 'products:product-list' %}">Склад</a>
|
||||
|
||||
<!-- Toggler for mobile view -->
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
|
||||
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<!-- Navbar content -->
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
{% if user.is_authenticated %}
|
||||
<!-- Dropdown menu for Products -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="productsDropdown" role="button"
|
||||
data-bs-toggle="dropdown" aria-expanded="false">
|
||||
Товары
|
||||
</a>
|
||||
<ul class="dropdown-menu" aria-labelledby="productsDropdown">
|
||||
<li><a class="dropdown-item" href="{% url 'products:product-list' %}">Все товары</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:category-list' %}">Категории</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:productkit-list' %}">Комплекты</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:product-create' %}">Создать товар</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'products:category-create' %}">Создать категорию</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav align-items-center">
|
||||
{% if user.is_authenticated %}
|
||||
<!-- Show profile button and logout button for authenticated users -->
|
||||
<li class="nav-item">
|
||||
<a class="btn btn-outline-primary me-2" href="{% url 'accounts:profile' %}">Профиль</a>
|
||||
</li>
|
||||
<li class="nav-item d-flex align-items-center mx-2">
|
||||
<span class="navbar-text mb-0">
|
||||
({{ user.name|default:user.email }})
|
||||
</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="btn btn-outline-secondary ms-2" href="{% url 'accounts:logout' %}">Выйти</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<!-- Show login and register buttons for non-authenticated users -->
|
||||
<li class="nav-item">
|
||||
<a class="btn btn-outline-primary me-2" href="{% url 'accounts:login' %}">Вход</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="btn btn-outline-secondary" href="{% url 'accounts:register' %}">Регистрация</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
103
myproject/templates/profile.html
Normal file
103
myproject/templates/profile.html
Normal file
@@ -0,0 +1,103 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Профиль{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-12 col-lg-10 col-xl-8">
|
||||
<div class="card shadow">
|
||||
<div class="card-header bg-primary text-white text-center py-3 py-md-4">
|
||||
<h3 class="mb-0">
|
||||
<i class="bi bi-person-circle me-2 d-block d-md-inline"></i>
|
||||
Ваш профиль
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-body p-3 p-md-4">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-md-4 text-center">
|
||||
<div class="profile-icon bg-light rounded-circle d-flex align-items-center justify-content-center mx-auto mb-3"
|
||||
style="width: 100px; height: 100px;">
|
||||
<i class="bi bi-person-fill" style="font-size: 3rem; color: #0d6efd;"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<div class="profile-info">
|
||||
<div class="d-flex flex-column flex-md-row mb-3">
|
||||
<div class="me-md-4 mb-2 mb-md-0">
|
||||
<i class="bi bi-person-fill text-primary me-2"></i>
|
||||
<span class="fw-bold">Имя:</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted">{{ user.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column flex-md-row mb-3">
|
||||
<div class="me-md-4 mb-2 mb-md-0">
|
||||
<i class="bi bi-envelope-fill text-primary me-2"></i>
|
||||
<span class="fw-bold">Email:</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted">{{ user.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column flex-md-row mb-3">
|
||||
<div class="me-md-4 mb-2 mb-md-0">
|
||||
<i class="bi bi-shield-check text-primary me-2"></i>
|
||||
<span class="fw-bold">Статус email:</span>
|
||||
</div>
|
||||
<div>
|
||||
{% if user.is_email_confirmed %}
|
||||
<span class="badge bg-success d-inline-flex align-items-center w-auto">
|
||||
<i class="bi bi-check-circle me-1"></i> Подтвержден
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark d-inline-flex align-items-center w-auto">
|
||||
<i class="bi bi-exclamation-circle me-1"></i> Не подтвержден
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column flex-md-row justify-content-end gap-2 mt-4 pt-3 border-top">
|
||||
<a href="{% url 'index' %}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i> Назад
|
||||
</a>
|
||||
|
||||
{% if not user.is_email_confirmed %}
|
||||
<button class="btn btn-outline-warning" type="button" onclick="resendConfirmation()">
|
||||
<i class="bi bi-envelope me-1"></i> Повторить письмо
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<a href="{% url 'accounts:change_password' %}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-key me-1"></i> Сменить пароль
|
||||
</a>
|
||||
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="editProfile()">
|
||||
<i class="bi bi-pencil me-1"></i> Редактировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function resendConfirmation() {
|
||||
// In a real application, this would make an AJAX request to resend the confirmation email
|
||||
alert('Письмо для подтверждения отправлено на ваш email!');
|
||||
}
|
||||
|
||||
function editProfile() {
|
||||
// For now, we'll just show an alert; in a real app this would open an edit form
|
||||
alert('Функция редактирования профиля будет реализована позже.');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
41
myproject/templates/register.html
Normal file
41
myproject/templates/register.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Регистрация{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="form-container">
|
||||
<h2 class="text-center mb-4">Регистрация</h2>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="register">
|
||||
<form method="post" action="{% url 'accounts:register' %}">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.name.id_for_label }}" class="form-label">Имя</label>
|
||||
{{ form.name }}
|
||||
{% if form.name.errors %}
|
||||
<div class="text-danger">{{ form.name.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="{{ form.email.id_for_label }}" class="form-label">Email</label>
|
||||
{{ form.email }}
|
||||
{% if form.email.errors %}
|
||||
<div class="text-danger">{{ form.email.errors }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'accounts/password_input.html' with field_name=form.password1.id_for_label field_label='Пароль' required=True field_errors=form.password1.errors %}
|
||||
{% include 'accounts/password_input.html' with field_name=form.password2.id_for_label field_label='Подтверждение пароля' required=True field_errors=form.password2.errors %}
|
||||
<button type="submit" class="btn btn-primary w-100">Зарегистрироваться</button>
|
||||
</form>
|
||||
|
||||
<!-- Ссылка на вход -->
|
||||
<div class="text-center mt-3">
|
||||
<a href="{% url 'accounts:login' %}" class="text-decoration-none">Уже есть аккаунт? Войти</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
70
myproject/test_category_tree.py
Normal file
70
myproject/test_category_tree.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Test script to create category hierarchy for testing the tree view
|
||||
"""
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from products.models import ProductCategory
|
||||
|
||||
print("Creating test category hierarchy...")
|
||||
print("="*60)
|
||||
|
||||
# Get existing categories
|
||||
existing = list(ProductCategory.objects.all()[:3])
|
||||
|
||||
if len(existing) >= 3:
|
||||
cat1, cat2, cat3 = existing[0], existing[1], existing[2]
|
||||
|
||||
# Make sure they are root categories first
|
||||
cat1.parent = None
|
||||
cat2.parent = None
|
||||
cat3.parent = None
|
||||
cat1.save()
|
||||
cat2.save()
|
||||
cat3.save()
|
||||
|
||||
print(f"Root categories:")
|
||||
print(f" 1. {cat1.name} (id={cat1.pk})")
|
||||
print(f" 2. {cat2.name} (id={cat2.pk})")
|
||||
print(f" 3. {cat3.name} (id={cat3.pk})")
|
||||
|
||||
# Create child for cat2 (if doesn't exist)
|
||||
child1, created = ProductCategory.objects.get_or_create(
|
||||
name="Test Child 1",
|
||||
defaults={'parent': cat2, 'is_active': True}
|
||||
)
|
||||
if not created:
|
||||
child1.parent = cat2
|
||||
child1.save()
|
||||
|
||||
print(f"\nChild category:")
|
||||
print(f" - {child1.name} (id={child1.pk}, parent={cat2.name})")
|
||||
|
||||
# Create grandchild for child1 (if doesn't exist)
|
||||
grandchild1, created = ProductCategory.objects.get_or_create(
|
||||
name="Test Grandchild 1",
|
||||
defaults={'parent': child1, 'is_active': True}
|
||||
)
|
||||
if not created:
|
||||
grandchild1.parent = child1
|
||||
grandchild1.save()
|
||||
|
||||
print(f"\nGrandchild category:")
|
||||
print(f" - {grandchild1.name} (id={grandchild1.pk}, parent={child1.name})")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("Hierarchy created successfully!")
|
||||
print("\nExpected tree structure:")
|
||||
print(f"{cat1.name}")
|
||||
print(f"{cat2.name}")
|
||||
print(f" {child1.name}")
|
||||
print(f" {grandchild1.name}")
|
||||
print(f"{cat3.name}")
|
||||
print("\nVisit http://127.0.0.1:8000/products/categories/ to see the tree view")
|
||||
|
||||
else:
|
||||
print("Not enough categories to create hierarchy")
|
||||
print("Please create at least 3 categories first")
|
||||
186
myproject/test_category_validation.py
Normal file
186
myproject/test_category_validation.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Скрипт для тестирования защиты от циклических ссылок в категориях
|
||||
"""
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from products.models import ProductCategory
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
def test_self_reference():
|
||||
"""Тест 1: Попытка сделать категорию родителем самой себя"""
|
||||
print("\n=== Тест 1: Самоссылка ===")
|
||||
try:
|
||||
cat = ProductCategory.objects.first()
|
||||
if not cat:
|
||||
print("[FAIL] Net kategorij v baze dlya testa")
|
||||
return False
|
||||
|
||||
print(f"Kategoriya: {cat.name} (id={cat.pk})")
|
||||
cat.parent = cat
|
||||
cat.save()
|
||||
print("[FAIL] OSHIBKA: Samossylka ne byla zablokirovana!")
|
||||
return False
|
||||
except ValidationError as e:
|
||||
print("[OK] USPEKH: Samossylka zablokirovana")
|
||||
print(f" Soobshchenie: {e.message_dict.get('parent', [''])[0]}")
|
||||
return True
|
||||
|
||||
|
||||
def test_simple_cycle():
|
||||
"""Тест 2: Попытка создать цикл A → B → A"""
|
||||
print("\n=== Тест 2: Простой цикл A → B → A ===")
|
||||
try:
|
||||
# Получаем две категории
|
||||
categories = list(ProductCategory.objects.all()[:2])
|
||||
if len(categories) < 2:
|
||||
print("❌ Недостаточно категорий в базе (нужно минимум 2)")
|
||||
return False
|
||||
|
||||
cat_a, cat_b = categories
|
||||
print(f"Категория A: {cat_a.name} (id={cat_a.pk})")
|
||||
print(f"Категория B: {cat_b.name} (id={cat_b.pk})")
|
||||
|
||||
# Создаем связь A → B
|
||||
cat_a.parent = None
|
||||
cat_a.save()
|
||||
cat_b.parent = cat_a
|
||||
cat_b.save()
|
||||
print(f"Создана связь: {cat_a.name} → {cat_b.name}")
|
||||
|
||||
# Пытаемся создать цикл: B → A (создаст цикл A → B → A)
|
||||
cat_a.parent = cat_b
|
||||
cat_a.save()
|
||||
|
||||
print("❌ ОШИБКА: Цикл не был заблокирован!")
|
||||
# Откатываем изменения
|
||||
cat_a.parent = None
|
||||
cat_b.parent = None
|
||||
cat_a.save()
|
||||
cat_b.save()
|
||||
return False
|
||||
|
||||
except ValidationError as e:
|
||||
print(f"✅ УСПЕХ: Цикл заблокирован")
|
||||
print(f" Сообщение: {e.message_dict.get('parent', [''])[0]}")
|
||||
# Откатываем изменения
|
||||
cat_a.parent = None
|
||||
cat_b.parent = None
|
||||
cat_a.save()
|
||||
cat_b.save()
|
||||
return True
|
||||
|
||||
|
||||
def test_multi_level_cycle():
|
||||
"""Тест 3: Попытка создать многоуровневый цикл A → B → C → A"""
|
||||
print("\n=== Тест 3: Многоуровневый цикл A → B → C → A ===")
|
||||
try:
|
||||
# Получаем три категории
|
||||
categories = list(ProductCategory.objects.all()[:3])
|
||||
if len(categories) < 3:
|
||||
print("❌ Недостаточно категорий в базе (нужно минимум 3)")
|
||||
return False
|
||||
|
||||
cat_a, cat_b, cat_c = categories
|
||||
print(f"Категория A: {cat_a.name} (id={cat_a.pk})")
|
||||
print(f"Категория B: {cat_b.name} (id={cat_b.pk})")
|
||||
print(f"Категория C: {cat_c.name} (id={cat_c.pk})")
|
||||
|
||||
# Создаем цепочку A → B → C
|
||||
cat_a.parent = None
|
||||
cat_a.save()
|
||||
cat_b.parent = cat_a
|
||||
cat_b.save()
|
||||
cat_c.parent = cat_b
|
||||
cat_c.save()
|
||||
print(f"Создана цепочка: {cat_a.name} → {cat_b.name} → {cat_c.name}")
|
||||
|
||||
# Пытаемся замкнуть цикл: A.parent = C
|
||||
cat_a.parent = cat_c
|
||||
cat_a.save()
|
||||
|
||||
print("❌ ОШИБКА: Многоуровневый цикл не был заблокирован!")
|
||||
# Откатываем изменения
|
||||
cat_a.parent = None
|
||||
cat_b.parent = None
|
||||
cat_c.parent = None
|
||||
cat_a.save()
|
||||
cat_b.save()
|
||||
cat_c.save()
|
||||
return False
|
||||
|
||||
except ValidationError as e:
|
||||
print(f"✅ УСПЕХ: Многоуровневый цикл заблокирован")
|
||||
print(f" Сообщение: {e.message_dict.get('parent', [''])[0]}")
|
||||
# Откатываем изменения
|
||||
cat_a.parent = None
|
||||
cat_b.parent = None
|
||||
cat_c.parent = None
|
||||
cat_a.save()
|
||||
cat_b.save()
|
||||
cat_c.save()
|
||||
return True
|
||||
|
||||
|
||||
def test_normal_operations():
|
||||
"""Тест 4: Нормальные операции должны работать"""
|
||||
print("\n=== Тест 4: Нормальные операции ===")
|
||||
try:
|
||||
categories = list(ProductCategory.objects.all()[:2])
|
||||
if len(categories) < 2:
|
||||
print("❌ Недостаточно категорий в базе")
|
||||
return False
|
||||
|
||||
cat_a, cat_b = categories
|
||||
|
||||
# Сбрасываем родителей
|
||||
cat_a.parent = None
|
||||
cat_b.parent = None
|
||||
cat_a.save()
|
||||
cat_b.save()
|
||||
|
||||
# Создаем нормальную иерархию A → B
|
||||
cat_b.parent = cat_a
|
||||
cat_b.save()
|
||||
print(f"✅ УСПЕХ: Создана нормальная иерархия {cat_a.name} → {cat_b.name}")
|
||||
|
||||
# Откатываем
|
||||
cat_b.parent = None
|
||||
cat_b.save()
|
||||
return True
|
||||
|
||||
except ValidationError as e:
|
||||
print(f"❌ ОШИБКА: Нормальная операция была заблокирована!")
|
||||
print(f" Сообщение: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("=" * 60)
|
||||
print("ТЕСТИРОВАНИЕ ЗАЩИТЫ ОТ ЦИКЛИЧЕСКИХ ССЫЛОК")
|
||||
print("=" * 60)
|
||||
|
||||
results = []
|
||||
results.append(("Тест самоссылки", test_self_reference()))
|
||||
results.append(("Тест простого цикла", test_simple_cycle()))
|
||||
results.append(("Тест многоуровневого цикла", test_multi_level_cycle()))
|
||||
results.append(("Тест нормальных операций", test_normal_operations()))
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("РЕЗУЛЬТАТЫ ТЕСТИРОВАНИЯ")
|
||||
print("=" * 60)
|
||||
for name, result in results:
|
||||
status = "✅ ПРОЙДЕН" if result else "❌ ПРОВАЛЕН"
|
||||
print(f"{name}: {status}")
|
||||
|
||||
all_passed = all(result for _, result in results)
|
||||
print("\n" + "=" * 60)
|
||||
if all_passed:
|
||||
print("🎉 ВСЕ ТЕСТЫ ПРОЙДЕНЫ!")
|
||||
else:
|
||||
print("⚠️ НЕКОТОРЫЕ ТЕСТЫ ПРОВАЛЕНЫ")
|
||||
print("=" * 60)
|
||||
134
myproject/test_cycles.py
Normal file
134
myproject/test_cycles.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Test script for category cycle protection
|
||||
"""
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from products.models import ProductCategory
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
print("="*60)
|
||||
print("TEST 1: Self-reference (A.parent = A)")
|
||||
print("="*60)
|
||||
try:
|
||||
cat = ProductCategory.objects.first()
|
||||
print(f"Category: {cat.name} (id={cat.pk})")
|
||||
cat.parent = cat
|
||||
cat.save()
|
||||
print("[FAIL] Self-reference was NOT blocked!")
|
||||
except ValidationError as e:
|
||||
print("[PASS] Self-reference blocked successfully")
|
||||
print(f"Error: {e.message_dict.get('parent')}")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("TEST 2: Simple cycle (A -> B -> A)")
|
||||
print("="*60)
|
||||
try:
|
||||
cats = list(ProductCategory.objects.all()[:2])
|
||||
cat_a, cat_b = cats[0], cats[1]
|
||||
print(f"Category A: {cat_a.name} (id={cat_a.pk})")
|
||||
print(f"Category B: {cat_b.name} (id={cat_b.pk})")
|
||||
|
||||
# Create A -> B
|
||||
cat_a.parent = None
|
||||
cat_a.save()
|
||||
cat_b.parent = cat_a
|
||||
cat_b.save()
|
||||
print(f"Created: {cat_a.name} -> {cat_b.name}")
|
||||
|
||||
# Try to create cycle: A.parent = B
|
||||
cat_a.parent = cat_b
|
||||
cat_a.save()
|
||||
print("[FAIL] Cycle was NOT blocked!")
|
||||
|
||||
# Cleanup
|
||||
cat_a.parent = None
|
||||
cat_b.parent = None
|
||||
cat_a.save()
|
||||
cat_b.save()
|
||||
|
||||
except ValidationError as e:
|
||||
print("[PASS] Cycle blocked successfully")
|
||||
print(f"Error: {e.message_dict.get('parent')}")
|
||||
# Cleanup
|
||||
cat_a.parent = None
|
||||
cat_b.parent = None
|
||||
cat_a.save()
|
||||
cat_b.save()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("TEST 3: Multi-level cycle (A -> B -> C -> A)")
|
||||
print("="*60)
|
||||
try:
|
||||
cats = list(ProductCategory.objects.all()[:3])
|
||||
cat_a, cat_b, cat_c = cats[0], cats[1], cats[2]
|
||||
print(f"Category A: {cat_a.name} (id={cat_a.pk})")
|
||||
print(f"Category B: {cat_b.name} (id={cat_b.pk})")
|
||||
print(f"Category C: {cat_c.name} (id={cat_c.pk})")
|
||||
|
||||
# Create chain A -> B -> C
|
||||
cat_a.parent = None
|
||||
cat_a.save()
|
||||
cat_b.parent = cat_a
|
||||
cat_b.save()
|
||||
cat_c.parent = cat_b
|
||||
cat_c.save()
|
||||
print(f"Created: {cat_a.name} -> {cat_b.name} -> {cat_c.name}")
|
||||
|
||||
# Try to create cycle: A.parent = C
|
||||
cat_a.parent = cat_c
|
||||
cat_a.save()
|
||||
print("[FAIL] Multi-level cycle was NOT blocked!")
|
||||
|
||||
# Cleanup
|
||||
cat_a.parent = None
|
||||
cat_b.parent = None
|
||||
cat_c.parent = None
|
||||
cat_a.save()
|
||||
cat_b.save()
|
||||
cat_c.save()
|
||||
|
||||
except ValidationError as e:
|
||||
print("[PASS] Multi-level cycle blocked successfully")
|
||||
print(f"Error: {e.message_dict.get('parent')}")
|
||||
# Cleanup
|
||||
cat_a.parent = None
|
||||
cat_b.parent = None
|
||||
cat_c.parent = None
|
||||
cat_a.save()
|
||||
cat_b.save()
|
||||
cat_c.save()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("TEST 4: Normal operations should work")
|
||||
print("="*60)
|
||||
try:
|
||||
cats = list(ProductCategory.objects.all()[:2])
|
||||
cat_a, cat_b = cats[0], cats[1]
|
||||
|
||||
# Reset
|
||||
cat_a.parent = None
|
||||
cat_b.parent = None
|
||||
cat_a.save()
|
||||
cat_b.save()
|
||||
|
||||
# Create normal hierarchy A -> B
|
||||
cat_b.parent = cat_a
|
||||
cat_b.save()
|
||||
print(f"[PASS] Normal hierarchy created: {cat_a.name} -> {cat_b.name}")
|
||||
|
||||
# Cleanup
|
||||
cat_b.parent = None
|
||||
cat_b.save()
|
||||
|
||||
except ValidationError as e:
|
||||
print("[FAIL] Normal operation was blocked!")
|
||||
print(f"Error: {e}")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("ALL TESTS COMPLETED")
|
||||
print("="*60)
|
||||
170
myproject/test_sku_generation.py
Normal file
170
myproject/test_sku_generation.py
Normal file
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Тестовый скрипт для проверки генерации артикулов
|
||||
"""
|
||||
import os
|
||||
import django
|
||||
|
||||
# Настройка Django
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
|
||||
django.setup()
|
||||
|
||||
from products.models import Product, ProductKit, ProductCategory, ProductVariantGroup, SKUCounter
|
||||
|
||||
def test_sku_generation():
|
||||
print("=" * 80)
|
||||
print("ТЕСТИРОВАНИЕ СИСТЕМЫ ГЕНЕРАЦИИ АРТИКУЛОВ")
|
||||
print("=" * 80)
|
||||
|
||||
# Проверяем текущее состояние счетчиков
|
||||
print("\n1. Текущее состояние счетчиков:")
|
||||
print("-" * 80)
|
||||
for counter in SKUCounter.objects.all():
|
||||
print(f" {counter.get_counter_type_display()}: {counter.current_value}")
|
||||
|
||||
if not SKUCounter.objects.exists():
|
||||
print(" Счетчики еще не созданы. Они будут созданы при первом создании товара.")
|
||||
|
||||
# Создаем тестовую категорию
|
||||
print("\n2. Создание тестовой категории:")
|
||||
print("-" * 80)
|
||||
category, created = ProductCategory.objects.get_or_create(
|
||||
name="Розы",
|
||||
defaults={'slug': 'rozy'}
|
||||
)
|
||||
print(f" Категория 'Розы' {'создана' if created else 'уже существует'}")
|
||||
|
||||
# Тест 1: Простой товар без группы вариантов
|
||||
print("\n3. Тест 1: Простой товар без суффикса:")
|
||||
print("-" * 80)
|
||||
product1 = Product(
|
||||
name="Роза красная",
|
||||
category=category,
|
||||
cost_price=100,
|
||||
sale_price=200
|
||||
)
|
||||
product1.save()
|
||||
print(f" Товар: {product1.name}")
|
||||
print(f" Артикул: {product1.sku}")
|
||||
print(f" Суффикс варианта: {product1.variant_suffix or 'нет'}")
|
||||
|
||||
# Тест 2: Товар с размером в названии
|
||||
print("\n4. Тест 2: Товар с размером в названии (автопарсинг):")
|
||||
print("-" * 80)
|
||||
product2 = Product(
|
||||
name="Роза Freedom 50см",
|
||||
category=category,
|
||||
cost_price=150,
|
||||
sale_price=300
|
||||
)
|
||||
product2.save()
|
||||
print(f" Товар: {product2.name}")
|
||||
print(f" Артикул: {product2.sku}")
|
||||
print(f" Суффикс варианта: {product2.variant_suffix or 'нет'}")
|
||||
|
||||
# Тест 3: Товар с другим размером
|
||||
print("\n5. Тест 3: Товар с другим размером:")
|
||||
print("-" * 80)
|
||||
product3 = Product(
|
||||
name="Роза Freedom 60 см",
|
||||
category=category,
|
||||
cost_price=180,
|
||||
sale_price=350
|
||||
)
|
||||
product3.save()
|
||||
print(f" Товар: {product3.name}")
|
||||
print(f" Артикул: {product3.sku}")
|
||||
print(f" Суффикс варианта: {product3.variant_suffix or 'нет'}")
|
||||
|
||||
# Тест 4: Товар с буквенным размером
|
||||
print("\n6. Тест 4: Товар с буквенным размером:")
|
||||
print("-" * 80)
|
||||
product4 = Product(
|
||||
name="Коробка подарочная размер M",
|
||||
category=category,
|
||||
cost_price=50,
|
||||
sale_price=100
|
||||
)
|
||||
product4.save()
|
||||
print(f" Товар: {product4.name}")
|
||||
print(f" Артикул: {product4.sku}")
|
||||
print(f" Суффикс варианта: {product4.variant_suffix or 'нет'}")
|
||||
|
||||
# Тест 5: Товар с ручным указанием суффикса
|
||||
print("\n7. Тест 5: Товар с ручным указанием суффикса:")
|
||||
print("-" * 80)
|
||||
product5 = Product(
|
||||
name="Лента атласная красная",
|
||||
category=category,
|
||||
cost_price=20,
|
||||
sale_price=40,
|
||||
variant_suffix="RED"
|
||||
)
|
||||
product5.save()
|
||||
print(f" Товар: {product5.name}")
|
||||
print(f" Артикул: {product5.sku}")
|
||||
print(f" Суффикс варианта: {product5.variant_suffix or 'нет'}")
|
||||
|
||||
# Тест 6: Комплект
|
||||
print("\n8. Тест 6: Комплект (букет):")
|
||||
print("-" * 80)
|
||||
kit1 = ProductKit(
|
||||
name="Букет Романтика",
|
||||
slug="buket-romantika",
|
||||
category=category,
|
||||
pricing_method='fixed',
|
||||
fixed_price=1500
|
||||
)
|
||||
kit1.save()
|
||||
print(f" Комплект: {kit1.name}")
|
||||
print(f" Артикул: {kit1.sku}")
|
||||
|
||||
# Тест 7: Еще один комплект
|
||||
print("\n9. Тест 7: Еще один комплект:")
|
||||
print("-" * 80)
|
||||
kit2 = ProductKit(
|
||||
name="Букет Весна",
|
||||
slug="buket-vesna",
|
||||
category=category,
|
||||
pricing_method='fixed',
|
||||
fixed_price=2000
|
||||
)
|
||||
kit2.save()
|
||||
print(f" Комплект: {kit2.name}")
|
||||
print(f" Артикул: {kit2.sku}")
|
||||
|
||||
# Проверяем финальное состояние счетчиков
|
||||
print("\n10. Финальное состояние счетчиков:")
|
||||
print("-" * 80)
|
||||
for counter in SKUCounter.objects.all():
|
||||
print(f" {counter.get_counter_type_display()}: {counter.current_value}")
|
||||
|
||||
# Показываем все созданные товары
|
||||
print("\n11. Все созданные тестовые товары:")
|
||||
print("-" * 80)
|
||||
print(f" {'Название':<40} {'Артикул':<20} {'Суффикс':<10}")
|
||||
print(" " + "-" * 70)
|
||||
for p in Product.objects.filter(name__startswith=('Роза', 'Коробка', 'Лента')):
|
||||
print(f" {p.name:<40} {p.sku:<20} {p.variant_suffix or '-':<10}")
|
||||
|
||||
print("\n12. Все созданные тестовые комплекты:")
|
||||
print("-" * 80)
|
||||
print(f" {'Название':<40} {'Артикул':<20}")
|
||||
print(" " + "-" * 60)
|
||||
for k in ProductKit.objects.filter(name__startswith='Букет'):
|
||||
print(f" {k.name:<40} {k.sku:<20}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("ТЕСТИРОВАНИЕ ЗАВЕРШЕНО!")
|
||||
print("=" * 80)
|
||||
|
||||
# Предложение удалить тестовые данные
|
||||
print("\nВнимание: Тестовые данные НЕ удалены автоматически.")
|
||||
print("Чтобы удалить их, выполните:")
|
||||
print(" python manage.py shell")
|
||||
print(" from products.models import Product, ProductKit, ProductCategory")
|
||||
print(" Product.objects.filter(name__startswith=('Роза', 'Коробка', 'Лента')).delete()")
|
||||
print(" ProductKit.objects.filter(name__startswith='Букет').delete()")
|
||||
print(" ProductCategory.objects.filter(name='Розы').delete()")
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_sku_generation()
|
||||
Reference in New Issue
Block a user