From d78c43d9a9c518d59878627121667c0f9e3382b5 Mon Sep 17 00:00:00 2001 From: Andrey Smakotin Date: Wed, 22 Oct 2025 01:11:06 +0300 Subject: [PATCH] Initial commit: Django inventory system --- .claude/settings.local.json | 9 + .gitignore | 58 + myproject/SKU_SYSTEM_README.md | 205 +++ myproject/accounts/__init__.py | 0 myproject/accounts/admin.py | 38 + myproject/accounts/apps.py | 6 + myproject/accounts/forms.py | 49 + myproject/accounts/management/__init__.py | 0 .../accounts/management/commands/__init__.py | 0 .../management/commands/confirm_email.py | 30 + myproject/accounts/migrations/0001_initial.py | 46 + myproject/accounts/migrations/__init__.py | 0 myproject/accounts/models.py | 81 ++ .../templates/accounts/password_input.html | 29 + .../accounts/password_reset_confirm.html | 48 + .../accounts/templates/accounts/register.html | 9 + myproject/accounts/tests.py | 3 + myproject/accounts/urls.py | 15 + myproject/accounts/views.py | 201 +++ myproject/docs/README_VARIANTS.md | 160 +++ myproject/docs/example_usage.py | 259 ++++ myproject/docs/product_variants_guide.md | 310 +++++ myproject/inventory/__init__.py | 0 myproject/inventory/admin.py | 19 + myproject/inventory/apps.py | 6 + .../inventory/migrations/0001_initial.py | 48 + myproject/inventory/migrations/__init__.py | 0 myproject/inventory/models.py | 61 + myproject/inventory/tests.py | 3 + myproject/inventory/views.py | 3 + myproject/manage.py | 22 + myproject/myproject/__init__.py | 0 myproject/myproject/asgi.py | 16 + myproject/myproject/settings.py | 144 ++ myproject/myproject/urls.py | 33 + myproject/myproject/views.py | 12 + myproject/myproject/wsgi.py | 16 + myproject/orders/__init__.py | 0 myproject/orders/admin.py | 28 + myproject/orders/apps.py | 6 + myproject/orders/migrations/0001_initial.py | 89 ++ myproject/orders/migrations/__init__.py | 0 myproject/orders/models.py | 135 ++ myproject/orders/tests.py | 3 + myproject/orders/views.py | 3 + myproject/products/__init__.py | 0 myproject/products/admin.py | 310 +++++ myproject/products/apps.py | 6 + myproject/products/forms.py | 259 ++++ myproject/products/management/__init__.py | 0 .../products/management/commands/__init__.py | 0 .../management/commands/demo_variants.py | 245 ++++ .../management/commands/fix_category_slugs.py | 34 + myproject/products/migrations/0001_initial.py | 206 +++ ...egory_sku_alter_skucounter_counter_type.py | 23 + .../0003_alter_productcategory_slug.py | 18 + myproject/products/migrations/__init__.py | 0 myproject/products/models.py | 618 +++++++++ .../products/category_confirm_delete.html | 46 + .../templates/products/category_detail.html | 105 ++ .../templates/products/category_form.html | 191 +++ .../templates/products/category_list.html | 214 +++ .../products/product_confirm_delete.html | 30 + .../templates/products/product_detail.html | 261 ++++ .../templates/products/product_form.html | 270 ++++ .../templates/products/product_list.html | 110 ++ .../products/productkit_confirm_delete.html | 81 ++ .../templates/products/productkit_detail.html | 239 ++++ .../templates/products/productkit_form.html | 664 +++++++++ .../templates/products/productkit_list.html | 127 ++ myproject/products/tests.py | 3 + myproject/products/urls.py | 48 + myproject/products/utils/__init__.py | 4 + myproject/products/utils/sku_generator.py | 212 +++ myproject/products/utils/stock_manager.py | 83 ++ myproject/products/views.py | 1201 +++++++++++++++++ myproject/static/admin/css/custom_nested.css | 45 + myproject/static/css/filter_panel.css | 111 ++ myproject/templates/base.html | 40 + myproject/templates/change_password.html | 56 + .../templates/components/filter_panel.html | 142 ++ myproject/templates/components/messages.html | 12 + myproject/templates/dashboard.html | 12 + myproject/templates/home.html | 22 + myproject/templates/index.html | 138 ++ myproject/templates/login.html | 50 + myproject/templates/navbar.html | 61 + myproject/templates/profile.html | 103 ++ myproject/templates/register.html | 41 + myproject/test_category_tree.py | 70 + myproject/test_category_validation.py | 186 +++ myproject/test_cycles.py | 134 ++ myproject/test_sku_generation.py | 170 +++ 93 files changed, 9204 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .gitignore create mode 100644 myproject/SKU_SYSTEM_README.md create mode 100644 myproject/accounts/__init__.py create mode 100644 myproject/accounts/admin.py create mode 100644 myproject/accounts/apps.py create mode 100644 myproject/accounts/forms.py create mode 100644 myproject/accounts/management/__init__.py create mode 100644 myproject/accounts/management/commands/__init__.py create mode 100644 myproject/accounts/management/commands/confirm_email.py create mode 100644 myproject/accounts/migrations/0001_initial.py create mode 100644 myproject/accounts/migrations/__init__.py create mode 100644 myproject/accounts/models.py create mode 100644 myproject/accounts/templates/accounts/password_input.html create mode 100644 myproject/accounts/templates/accounts/password_reset_confirm.html create mode 100644 myproject/accounts/templates/accounts/register.html create mode 100644 myproject/accounts/tests.py create mode 100644 myproject/accounts/urls.py create mode 100644 myproject/accounts/views.py create mode 100644 myproject/docs/README_VARIANTS.md create mode 100644 myproject/docs/example_usage.py create mode 100644 myproject/docs/product_variants_guide.md create mode 100644 myproject/inventory/__init__.py create mode 100644 myproject/inventory/admin.py create mode 100644 myproject/inventory/apps.py create mode 100644 myproject/inventory/migrations/0001_initial.py create mode 100644 myproject/inventory/migrations/__init__.py create mode 100644 myproject/inventory/models.py create mode 100644 myproject/inventory/tests.py create mode 100644 myproject/inventory/views.py create mode 100644 myproject/manage.py create mode 100644 myproject/myproject/__init__.py create mode 100644 myproject/myproject/asgi.py create mode 100644 myproject/myproject/settings.py create mode 100644 myproject/myproject/urls.py create mode 100644 myproject/myproject/views.py create mode 100644 myproject/myproject/wsgi.py create mode 100644 myproject/orders/__init__.py create mode 100644 myproject/orders/admin.py create mode 100644 myproject/orders/apps.py create mode 100644 myproject/orders/migrations/0001_initial.py create mode 100644 myproject/orders/migrations/__init__.py create mode 100644 myproject/orders/models.py create mode 100644 myproject/orders/tests.py create mode 100644 myproject/orders/views.py create mode 100644 myproject/products/__init__.py create mode 100644 myproject/products/admin.py create mode 100644 myproject/products/apps.py create mode 100644 myproject/products/forms.py create mode 100644 myproject/products/management/__init__.py create mode 100644 myproject/products/management/commands/__init__.py create mode 100644 myproject/products/management/commands/demo_variants.py create mode 100644 myproject/products/management/commands/fix_category_slugs.py create mode 100644 myproject/products/migrations/0001_initial.py create mode 100644 myproject/products/migrations/0002_productcategory_sku_alter_skucounter_counter_type.py create mode 100644 myproject/products/migrations/0003_alter_productcategory_slug.py create mode 100644 myproject/products/migrations/__init__.py create mode 100644 myproject/products/models.py create mode 100644 myproject/products/templates/products/category_confirm_delete.html create mode 100644 myproject/products/templates/products/category_detail.html create mode 100644 myproject/products/templates/products/category_form.html create mode 100644 myproject/products/templates/products/category_list.html create mode 100644 myproject/products/templates/products/product_confirm_delete.html create mode 100644 myproject/products/templates/products/product_detail.html create mode 100644 myproject/products/templates/products/product_form.html create mode 100644 myproject/products/templates/products/product_list.html create mode 100644 myproject/products/templates/products/productkit_confirm_delete.html create mode 100644 myproject/products/templates/products/productkit_detail.html create mode 100644 myproject/products/templates/products/productkit_form.html create mode 100644 myproject/products/templates/products/productkit_list.html create mode 100644 myproject/products/tests.py create mode 100644 myproject/products/urls.py create mode 100644 myproject/products/utils/__init__.py create mode 100644 myproject/products/utils/sku_generator.py create mode 100644 myproject/products/utils/stock_manager.py create mode 100644 myproject/products/views.py create mode 100644 myproject/static/admin/css/custom_nested.css create mode 100644 myproject/static/css/filter_panel.css create mode 100644 myproject/templates/base.html create mode 100644 myproject/templates/change_password.html create mode 100644 myproject/templates/components/filter_panel.html create mode 100644 myproject/templates/components/messages.html create mode 100644 myproject/templates/dashboard.html create mode 100644 myproject/templates/home.html create mode 100644 myproject/templates/index.html create mode 100644 myproject/templates/login.html create mode 100644 myproject/templates/navbar.html create mode 100644 myproject/templates/profile.html create mode 100644 myproject/templates/register.html create mode 100644 myproject/test_category_tree.py create mode 100644 myproject/test_category_validation.py create mode 100644 myproject/test_cycles.py create mode 100644 myproject/test_sku_generation.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..8a2c18c --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(dir /b /s settings.py)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f1dee5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Python +venv/ +env/ +ENV/ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg +*.egg-info/ +dist/ +build/ + +# Django +*.log +db.sqlite3 +db.sqlite3-journal +media/ +staticfiles/ + +# Environment variables +.env +.env.local +*.env + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db +*.bak + +# Testing +.coverage +htmlcov/ +.pytest_cache/ +.tox/ + +# MyPy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Jupyter Notebook +.ipynb_checkpoints + +# Temporary files +*.tmp +nul + +# Personal notes +CLAUDE_NOTES.md diff --git a/myproject/SKU_SYSTEM_README.md b/myproject/SKU_SYSTEM_README.md new file mode 100644 index 0000000..2739624 --- /dev/null +++ b/myproject/SKU_SYSTEM_README.md @@ -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. + +## Поддержка + +При возникновении проблем или вопросов обратитесь к разработчикам проекта. diff --git a/myproject/accounts/__init__.py b/myproject/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/accounts/admin.py b/myproject/accounts/admin.py new file mode 100644 index 0000000..6dc3d5d --- /dev/null +++ b/myproject/accounts/admin.py @@ -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) + diff --git a/myproject/accounts/apps.py b/myproject/accounts/apps.py new file mode 100644 index 0000000..3e3c765 --- /dev/null +++ b/myproject/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'accounts' diff --git a/myproject/accounts/forms.py b/myproject/accounts/forms.py new file mode 100644 index 0000000..5b1f70d --- /dev/null +++ b/myproject/accounts/forms.py @@ -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' + }) + ) \ No newline at end of file diff --git a/myproject/accounts/management/__init__.py b/myproject/accounts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/accounts/management/commands/__init__.py b/myproject/accounts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/accounts/management/commands/confirm_email.py b/myproject/accounts/management/commands/confirm_email.py new file mode 100644 index 0000000..83cff04 --- /dev/null +++ b/myproject/accounts/management/commands/confirm_email.py @@ -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} не найден.') + ) diff --git a/myproject/accounts/migrations/0001_initial.py b/myproject/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..5cc1f09 --- /dev/null +++ b/myproject/accounts/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/myproject/accounts/migrations/__init__.py b/myproject/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/accounts/models.py b/myproject/accounts/models.py new file mode 100644 index 0000000..7d070cc --- /dev/null +++ b/myproject/accounts/models.py @@ -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() diff --git a/myproject/accounts/templates/accounts/password_input.html b/myproject/accounts/templates/accounts/password_input.html new file mode 100644 index 0000000..7188241 --- /dev/null +++ b/myproject/accounts/templates/accounts/password_input.html @@ -0,0 +1,29 @@ + +{% comment %} + Использование: + {% include 'accounts/password_input.html' with field_name='password1' field_label='Пароль' required=True %} +{% endcomment %} + +
+ +
+ + +
+ {% if field_errors %} +
{{ field_errors }}
+ {% endif %} +
\ No newline at end of file diff --git a/myproject/accounts/templates/accounts/password_reset_confirm.html b/myproject/accounts/templates/accounts/password_reset_confirm.html new file mode 100644 index 0000000..18f5cc7 --- /dev/null +++ b/myproject/accounts/templates/accounts/password_reset_confirm.html @@ -0,0 +1,48 @@ +{% extends 'base.html' %} + +{% block title %}Сброс пароля{% endblock %} + +{% block content %} +
+
+

Сброс пароля

+ +
+
+
+ {% 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 %} + +
+ + + +
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/myproject/accounts/templates/accounts/register.html b/myproject/accounts/templates/accounts/register.html new file mode 100644 index 0000000..6bb93fe --- /dev/null +++ b/myproject/accounts/templates/accounts/register.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} + +{% block title %}Регистрация{% endblock %} + +{% block content %} +

Регистрация

+

Форма регистрации доступна на главной странице.

+Перейти на главную +{% endblock %} \ No newline at end of file diff --git a/myproject/accounts/tests.py b/myproject/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/myproject/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/myproject/accounts/urls.py b/myproject/accounts/urls.py new file mode 100644 index 0000000..73cc46b --- /dev/null +++ b/myproject/accounts/urls.py @@ -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//', views.confirm_email, name='confirm_email'), + path('password-reset/', views.password_reset_request, name='password_reset'), + path('password-reset//', views.password_reset_confirm, name='password_reset_confirm'), +] \ No newline at end of file diff --git a/myproject/accounts/views.py b/myproject/accounts/views.py new file mode 100644 index 0000000..9e3a3f8 --- /dev/null +++ b/myproject/accounts/views.py @@ -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}) \ No newline at end of file diff --git a/myproject/docs/README_VARIANTS.md b/myproject/docs/README_VARIANTS.md new file mode 100644 index 0000000..7542329 --- /dev/null +++ b/myproject/docs/README_VARIANTS.md @@ -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 diff --git a/myproject/docs/example_usage.py b/myproject/docs/example_usage.py new file mode 100644 index 0000000..0eb3416 --- /dev/null +++ b/myproject/docs/example_usage.py @@ -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() diff --git a/myproject/docs/product_variants_guide.md b/myproject/docs/product_variants_guide.md new file mode 100644 index 0000000..b351203 --- /dev/null +++ b/myproject/docs/product_variants_guide.md @@ -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 diff --git a/myproject/inventory/__init__.py b/myproject/inventory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/inventory/admin.py b/myproject/inventory/admin.py new file mode 100644 index 0000000..f8568c8 --- /dev/null +++ b/myproject/inventory/admin.py @@ -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) diff --git a/myproject/inventory/apps.py b/myproject/inventory/apps.py new file mode 100644 index 0000000..905749f --- /dev/null +++ b/myproject/inventory/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class InventoryConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'inventory' diff --git a/myproject/inventory/migrations/0001_initial.py b/myproject/inventory/migrations/0001_initial.py new file mode 100644 index 0000000..f412d91 --- /dev/null +++ b/myproject/inventory/migrations/0001_initial.py @@ -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')], + }, + ), + ] diff --git a/myproject/inventory/migrations/__init__.py b/myproject/inventory/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/inventory/models.py b/myproject/inventory/models.py new file mode 100644 index 0000000..95b4dad --- /dev/null +++ b/myproject/inventory/models.py @@ -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})" diff --git a/myproject/inventory/tests.py b/myproject/inventory/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/myproject/inventory/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/myproject/inventory/views.py b/myproject/inventory/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/myproject/inventory/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/myproject/manage.py b/myproject/manage.py new file mode 100644 index 0000000..92bb9a3 --- /dev/null +++ b/myproject/manage.py @@ -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() diff --git a/myproject/myproject/__init__.py b/myproject/myproject/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/myproject/asgi.py b/myproject/myproject/asgi.py new file mode 100644 index 0000000..18346a3 --- /dev/null +++ b/myproject/myproject/asgi.py @@ -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() diff --git a/myproject/myproject/settings.py b/myproject/myproject/settings.py new file mode 100644 index 0000000..c3c7625 --- /dev/null +++ b/myproject/myproject/settings.py @@ -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' diff --git a/myproject/myproject/urls.py b/myproject/myproject/urls.py new file mode 100644 index 0000000..de51aa8 --- /dev/null +++ b/myproject/myproject/urls.py @@ -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) diff --git a/myproject/myproject/views.py b/myproject/myproject/views.py new file mode 100644 index 0000000..2d042e6 --- /dev/null +++ b/myproject/myproject/views.py @@ -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') \ No newline at end of file diff --git a/myproject/myproject/wsgi.py b/myproject/myproject/wsgi.py new file mode 100644 index 0000000..7c6fdd6 --- /dev/null +++ b/myproject/myproject/wsgi.py @@ -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() diff --git a/myproject/orders/__init__.py b/myproject/orders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/orders/admin.py b/myproject/orders/admin.py new file mode 100644 index 0000000..5caebdf --- /dev/null +++ b/myproject/orders/admin.py @@ -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) diff --git a/myproject/orders/apps.py b/myproject/orders/apps.py new file mode 100644 index 0000000..8ae0375 --- /dev/null +++ b/myproject/orders/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OrdersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'orders' diff --git a/myproject/orders/migrations/0001_initial.py b/myproject/orders/migrations/0001_initial.py new file mode 100644 index 0000000..4154a62 --- /dev/null +++ b/myproject/orders/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/myproject/orders/migrations/__init__.py b/myproject/orders/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/orders/models.py b/myproject/orders/models.py new file mode 100644 index 0000000..7a64c3b --- /dev/null +++ b/myproject/orders/models.py @@ -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}" diff --git a/myproject/orders/tests.py b/myproject/orders/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/myproject/orders/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/myproject/orders/views.py b/myproject/orders/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/myproject/orders/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/myproject/products/__init__.py b/myproject/products/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/products/admin.py b/myproject/products/admin.py new file mode 100644 index 0000000..d04b8ae --- /dev/null +++ b/myproject/products/admin.py @@ -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( + '', + 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( + '', + 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( + '', + 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( + '', + 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( + '', + 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( + '', + 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( + '', + 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( + '', + 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( + '', + 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('Группа: {}', 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('PROD-{:06d}', next_val) + elif obj.counter_type == 'kit': + return format_html('KIT-{:06d}', next_val) + elif obj.counter_type == 'category': + return format_html('CAT-{:04d}', 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) diff --git a/myproject/products/apps.py b/myproject/products/apps.py new file mode 100644 index 0000000..145a2ac --- /dev/null +++ b/myproject/products/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ProductsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'products' diff --git a/myproject/products/forms.py b/myproject/products/forms.py new file mode 100644 index 0000000..354650d --- /dev/null +++ b/myproject/products/forms.py @@ -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 diff --git a/myproject/products/management/__init__.py b/myproject/products/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/products/management/commands/__init__.py b/myproject/products/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/products/management/commands/demo_variants.py b/myproject/products/management/commands/demo_variants.py new file mode 100644 index 0000000..5b13e37 --- /dev/null +++ b/myproject/products/management/commands/demo_variants.py @@ -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] Нет доступных товаров")) diff --git a/myproject/products/management/commands/fix_category_slugs.py b/myproject/products/management/commands/fix_category_slugs.py new file mode 100644 index 0000000..d18abbb --- /dev/null +++ b/myproject/products/management/commands/fix_category_slugs.py @@ -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') + ) diff --git a/myproject/products/migrations/0001_initial.py b/myproject/products/migrations/0001_initial.py new file mode 100644 index 0000000..5997224 --- /dev/null +++ b/myproject/products/migrations/0001_initial.py @@ -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'), + ), + ] diff --git a/myproject/products/migrations/0002_productcategory_sku_alter_skucounter_counter_type.py b/myproject/products/migrations/0002_productcategory_sku_alter_skucounter_counter_type.py new file mode 100644 index 0000000..8fa86dc --- /dev/null +++ b/myproject/products/migrations/0002_productcategory_sku_alter_skucounter_counter_type.py @@ -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='Тип счетчика'), + ), + ] diff --git a/myproject/products/migrations/0003_alter_productcategory_slug.py b/myproject/products/migrations/0003_alter_productcategory_slug.py new file mode 100644 index 0000000..a9a98dc --- /dev/null +++ b/myproject/products/migrations/0003_alter_productcategory_slug.py @@ -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-идентификатор'), + ), + ] diff --git a/myproject/products/migrations/__init__.py b/myproject/products/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/myproject/products/models.py b/myproject/products/models.py new file mode 100644 index 0000000..6306374 --- /dev/null +++ b/myproject/products/models.py @@ -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}" diff --git a/myproject/products/templates/products/category_confirm_delete.html b/myproject/products/templates/products/category_confirm_delete.html new file mode 100644 index 0000000..ce40f1a --- /dev/null +++ b/myproject/products/templates/products/category_confirm_delete.html @@ -0,0 +1,46 @@ +{% extends 'base.html' %} + +{% block title %}Удалить категорию{% endblock %} + +{% block content %} +
+
+
+
+
+

Подтверждение удаления

+
+
+

Вы уверены, что хотите удалить категорию "{{ category.name }}"?

+ + {% if products_count > 0 %} +
+ Внимание! В этой категории есть {{ products_count }} товар(ов). +
Удаление невозможно. Сначала удалите или переместите товары. +
+ {% endif %} + + {% if children_count > 0 %} +
+ Внимание! У этой категории есть {{ children_count }} подкатегорий. +
Удаление невозможно. Сначала удалите или переместите подкатегории. +
+ {% endif %} + +
+ {% csrf_token %} +
+ Отмена + {% if products_count == 0 and children_count == 0 %} + + {% else %} + Вернуться к категории + {% endif %} +
+
+
+
+
+
+
+{% endblock %} diff --git a/myproject/products/templates/products/category_detail.html b/myproject/products/templates/products/category_detail.html new file mode 100644 index 0000000..53ee3ba --- /dev/null +++ b/myproject/products/templates/products/category_detail.html @@ -0,0 +1,105 @@ +{% extends 'base.html' %} + +{% block title %}{{ category.name }}{% endblock %} + +{% block content %} +
+
+
+
+
+

{{ category.name }}

+
+ {% if category.is_active %} + Активна + {% else %} + Неактивна + {% endif %} +
+
+
+
+
Артикул:
+
{{ category.sku|default:"—" }}
+
+
+
URL-идентификатор:
+
{{ category.slug }}
+
+
+
Родительская категория:
+
+ {% if category.parent %} + {{ category.parent.name }} + {% else %} + Корневая категория + {% endif %} +
+
+ + + {% if category_photos %} +
+
Фотографии ({{ photos_count }})
+
+ {% for photo in category_photos %} +
+
+ Фото категории + {% if photo.order == 0 %} +
+ Главное +
+ {% endif %} +
+
+ {% endfor %} +
+
+ {% endif %} + + + {% if children_categories %} +
+
Подкатегории ({{ children_categories.count }})
+
    + {% for child in children_categories %} +
  • + {{ child.name }} + {{ child.sku|default:"—" }} +
  • + {% endfor %} +
+
+ {% endif %} + + + {% if products %} +
+
Товары в категории ({{ products_count }})
+
+ {% for product in products %} + + {{ product.name }} ({{ product.sku }}) + + {% endfor %} + {% if products_count > 20 %} +
+ ... и еще {{ products_count|add:"-20" }} товар(ов) +
+ {% endif %} +
+
+ {% endif %} +
+
+ + +
+
+
+{% endblock %} diff --git a/myproject/products/templates/products/category_form.html b/myproject/products/templates/products/category_form.html new file mode 100644 index 0000000..0f3bb06 --- /dev/null +++ b/myproject/products/templates/products/category_form.html @@ -0,0 +1,191 @@ +{% extends 'base.html' %} + +{% block title %}{% if object %}Редактировать категорию{% else %}Создать категорию{% endif %}{% endblock %} + +{% block content %} +
+
+
+
+
+
+ {% csrf_token %} + + {% if form.non_field_errors %} + + {% endif %} + + +
+ +
+ + {{ form.name }} + {% if form.name.help_text %} + {{ form.name.help_text }} + {% endif %} + {% if form.name.errors %} +
{{ form.name.errors }}
+ {% endif %} +
+ + +
+
+ {{ form.sku.label_tag }} + {{ form.sku }} + {% if form.sku.help_text %} + {{ form.sku.help_text }} + {% endif %} + {% if form.sku.errors %} +
{{ form.sku.errors }}
+ {% endif %} +
+
+ {{ form.slug.label_tag }} + {{ form.slug }} + {% if form.slug.help_text %} + {{ form.slug.help_text }} + {% endif %} + {% if form.slug.errors %} +
{{ form.slug.errors }}
+ {% endif %} +
+
+ + +
+
+ {{ form.parent.label_tag }} + {{ form.parent }} + {% if form.parent.help_text %} + {{ form.parent.help_text }} + {% endif %} + {% if form.parent.errors %} +
{{ form.parent.errors }}
+ {% endif %} +
+
+
+ {{ form.is_active }} + {{ form.is_active.label_tag }} +
+ {% if form.is_active.help_text %} + {{ form.is_active.help_text }} + {% endif %} + {% if form.is_active.errors %} +
{{ form.is_active.errors }}
+ {% endif %} +
+
+
+ +
+ + +
+
Фотографии
+ + + {% if object and category_photos %} +
+
Текущие фотографии ({{ photos_count }})
+
+ {% for photo in category_photos %} +
+
+
+ Фото категории +
+
+ {% if photo.order == 0 %} +
Главное
+ {% else %} + + Главным + + {% endif %} + + + + + Удалить + + + Позиция: {{ photo.order }} +
+
+
+ + + + {% endfor %} +
+
+ {% endif %} + + +
+ + + + Выберите фото для категории (можно выбрать несколько, до 10 штук) + +
+
+ +
+ Отмена + +
+
+
+
+
+
+
+{% endblock %} diff --git a/myproject/products/templates/products/category_list.html b/myproject/products/templates/products/category_list.html new file mode 100644 index 0000000..e748ace --- /dev/null +++ b/myproject/products/templates/products/category_list.html @@ -0,0 +1,214 @@ +{% extends 'base.html' %} + +{% block title %}Список категорий{% endblock %} + +{% block content %} +
+

Категории товаров

+ + + {% include 'components/filter_panel.html' with title="Категории" filters=filters action_buttons=action_buttons %} + + +
+ + +
+ + {% if category_tree %} +
+ + + + + + + + + + + + {% for item in category_tree %} + 0 %}style="display: none;"{% endif %} + class="category-row"> + + + + + + + + + + + + + + + + + {% endfor %} + +
НазваниеАртикулЦенаСтатусДействия
+ {% if item.item_type == 'category' %} + {% if item.has_children %} + + {% else %} + + {% endif %} + {{ item.name }} + {% if item.has_children %} + {{ item.obj.children.count }} подкат. + {% endif %} + + {% elif item.item_type == 'product' %} + + {{ item.name }} + + {% elif item.item_type == 'kit' %} + + {{ item.name }} + {% endif %} + + {% if item.sku %} + {{ item.sku }} + {% else %} + + {% endif %} + + {% if item.price %} + {{ item.price|floatformat:0 }} ₽ + {% else %} + + {% endif %} + + {% if item.item_type == 'category' %} + {% if item.obj.is_active %} + Активна + {% else %} + Неактивна + {% endif %} + {% else %} + + {% endif %} + + {% if item.item_type == 'category' %} + Изменить + Удалить + {% elif item.item_type == 'product' %} + Изменить + {% elif item.item_type == 'kit' %} + Изменить + {% endif %} +
+
+ {% else %} +
+

Категории не найдены.

+ Создать первую категорию +
+ {% endif %} +
+ + +{% endblock %} diff --git a/myproject/products/templates/products/product_confirm_delete.html b/myproject/products/templates/products/product_confirm_delete.html new file mode 100644 index 0000000..9af5815 --- /dev/null +++ b/myproject/products/templates/products/product_confirm_delete.html @@ -0,0 +1,30 @@ +{% extends 'base.html' %} + +{% block title %}Удалить товар{% endblock %} + +{% block content %} +
+
+
+
+
+

Подтверждение удаления

+
+
+

Вы уверены, что хотите удалить товар "{{ product.name }}"?

+

Артикул: {{ product.sku }}

+

Внимание! Это действие нельзя будет отменить.

+ +
+ {% csrf_token %} +
+ Отмена + +
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/myproject/products/templates/products/product_detail.html b/myproject/products/templates/products/product_detail.html new file mode 100644 index 0000000..4b2f236 --- /dev/null +++ b/myproject/products/templates/products/product_detail.html @@ -0,0 +1,261 @@ +{% extends 'base.html' %} + +{% block title %}{{ product.name }}{% endblock %} + +{% block content %} +
+
+
+
+
+
+

{{ product.name }}

+
+ {% if perms.products.change_product %} + Редактировать + {% endif %} + {% if perms.products.delete_product %} + Удалить + {% endif %} +
+
+
+
+ + {% if product_photos %} +
+
Фотографии товара ({{ photos_count }})
+
+ {% for photo in product_photos %} +
+
+ +
+ Фото товара +
+
+ {% if photo.order == 0 %} +
Главное
+ {% else %} + Позиция: {{ photo.order }} + {% endif %} +
+
+
+ {% endfor %} +
+
+ + + + +
+ {% endif %} + + +
Основная информация
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Артикул:{{ product.sku }}
Описание:{{ product.description|default:"-" }}
Категории: + {% if product.categories.all %} + {% for category in product.categories.all %} + {{ category.name }} + {% endfor %} + {% else %} + - + {% endif %} +
Теги: + {% if product.tags.all %} + {% for tag in product.tags.all %} + {{ tag.name }} + {% endfor %} + {% else %} + - + {% endif %} +
Единица измерения:{{ product.unit }}
Себестоимость:{{ product.cost_price }} руб.
Цена продажи:{{ product.sale_price }} руб.
Статус: + {% if product.is_active %} + Активен + {% else %} + Неактивен + {% endif %} +
Дата создания:{{ product.created_at }}
Дата обновления:{{ product.updated_at }}
+
+
+
+ +
+
+
+

Действия

+
+ +
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} \ No newline at end of file diff --git a/myproject/products/templates/products/product_form.html b/myproject/products/templates/products/product_form.html new file mode 100644 index 0000000..a229d8e --- /dev/null +++ b/myproject/products/templates/products/product_form.html @@ -0,0 +1,270 @@ +{% extends 'base.html' %} + +{% block title %}{% if object %}Редактировать товар{% else %}Создать товар{% endif %}{% endblock %} + +{% block content %} +
+
+
+
+
+
+ {% csrf_token %} + + {% if form.non_field_errors %} + + {% endif %} + + +
+ +
+ + {{ form.name }} + {% if form.name.help_text %} + {{ form.name.help_text }} + {% endif %} + {% if form.name.errors %} +
{{ form.name.errors }}
+ {% endif %} +
+ + +
+ {{ form.sku.label_tag }} + {{ form.sku }} + {% if form.sku.help_text %} + {{ form.sku.help_text }} + {% endif %} + {% if form.sku.errors %} +
{{ form.sku.errors }}
+ {% endif %} +
+ + +
+ {{ form.description.label_tag }} + {{ form.description }} + {% if form.description.help_text %} + {{ form.description.help_text }} + {% endif %} + {% if form.description.errors %} +
{{ form.description.errors }}
+ {% endif %} +
+ + +
+
+ {{ form.unit.label_tag }} + {{ form.unit }} + {% if form.unit.help_text %} + {{ form.unit.help_text }} + {% endif %} + {% if form.unit.errors %} +
{{ form.unit.errors }}
+ {% endif %} +
+
+
+ {{ form.is_active }} + {{ form.is_active.label_tag }} +
+ {% if form.is_active.help_text %} + {{ form.is_active.help_text }} + {% endif %} + {% if form.is_active.errors %} +
{{ form.is_active.errors }}
+ {% endif %} +
+
+
+ +
+ + +
+
Ценообразование
+ +
+
+ {{ form.cost_price.label_tag }} + {{ form.cost_price }} + {% if form.cost_price.help_text %} + {{ form.cost_price.help_text }} + {% endif %} + {% if form.cost_price.errors %} +
{{ form.cost_price.errors }}
+ {% endif %} +
+
+ {{ form.sale_price.label_tag }} + {{ form.sale_price }} + {% if form.sale_price.help_text %} + {{ form.sale_price.help_text }} + {% endif %} + {% if form.sale_price.errors %} +
{{ form.sale_price.errors }}
+ {% endif %} +
+
+
+ +
+ + +
+
Фотографии
+ + + {% if object and product_photos %} +
+
Текущие фотографии ({{ photos_count }})
+
+ {% for photo in product_photos %} +
+
+ +
+ Фото товара +
+
+ {% if photo.order == 0 %} +
⭐ Главное
+ {% else %} + + ⭐ Главным + + {% endif %} + + + + + 🗑️ Удалить + + + Позиция: {{ photo.order }} +
+
+
+ + + + {% endfor %} +
+
+ {% endif %} + + +
+ + + + {% if object %} + Выберите фото для добавления к товару (можно выбрать несколько, до 10 штук всего) + {% else %} + Выберите фото для товара (можно выбрать несколько, до 10 штук) + {% endif %} + +
+
+ +
+ + +
+
Классификация
+ + +
+ {{ form.categories.label_tag }} +
+ {{ form.categories }} +
+ {% if form.categories.help_text %} + {{ form.categories.help_text }} + {% endif %} + {% if form.categories.errors %} +
{{ form.categories.errors }}
+ {% endif %} +
+ + +
+ {{ form.tags.label_tag }} +
+ {{ form.tags }} +
+ {% if form.tags.help_text %} + {{ form.tags.help_text }} + {% endif %} + {% if form.tags.errors %} +
{{ form.tags.errors }}
+ {% endif %} +
+
+ +
+
+ Отмена + {% if perms.products.add_productkit %} + + Создать комплект + + {% endif %} +
+ +
+
+
+
+
+
+
+{% endblock %} diff --git a/myproject/products/templates/products/product_list.html b/myproject/products/templates/products/product_list.html new file mode 100644 index 0000000..0d10085 --- /dev/null +++ b/myproject/products/templates/products/product_list.html @@ -0,0 +1,110 @@ +{% extends 'base.html' %} + +{% block title %}Список товаров{% endblock %} + +{% block content %} +
+

Список товаров

+ + + {% include 'components/filter_panel.html' with title="Товары" filters=filters action_buttons=action_buttons %} + + {% if products %} +
+ + + + + + + + + + + + + + {% for product in products %} + + + + + + + + + + {% endfor %} + +
ФотоНазваниеАртикулКатегорияЦена продажиСтатусДействия
+ {% if product.photos.all %} + {% with photo=product.photos.first %} + {{ product.name }} + {% endwith %} + {% else %} + Нет фото + {% endif %} + + {{ product.name }} + {{ product.sku }} + {% if product.categories.all %} + {% for category in product.categories.all %} + {{ category.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} + {% else %} + - + {% endif %} + {{ product.sale_price }} руб. + {% if product.is_active %} + Активен + {% else %} + Неактивен + {% endif %} + + {% if perms.products.change_product %} + Изменить + {% endif %} + {% if perms.products.delete_product %} + Удалить + {% endif %} +
+
+ + + {% if is_paginated %} + + {% endif %} + {% else %} +
+

Товары не найдены.

+ {% if perms.products.add_product %} + Создать первый товар + {% endif %} +
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/myproject/products/templates/products/productkit_confirm_delete.html b/myproject/products/templates/products/productkit_confirm_delete.html new file mode 100644 index 0000000..30298c0 --- /dev/null +++ b/myproject/products/templates/products/productkit_confirm_delete.html @@ -0,0 +1,81 @@ +{% extends 'base.html' %} + +{% block title %}Удалить комплект - {{ kit.name }}{% endblock %} + +{% block content %} +
+
+
+
+
+

Подтверждение удаления

+
+
+

Вы действительно хотите удалить комплект?

+ +
+ {{ kit.name }} +
+ Артикул: {{ kit.sku }} +
+ +
+ + Внимание! Это действие нельзя отменить. Комплект будет удален с все его компоненты и фотографии. +
+ +
+ {% csrf_token %} + + Отмена + + +
+
+
+ + +
+
+
Информация о комплекте
+
+
+
+
Название:
+
{{ kit.name }}
+ +
Артикул:
+
{{ kit.sku }}
+ + {% if kit.categories.all %} +
Категории:
+
+ {% for category in kit.categories.all %} + {{ category.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
+ {% endif %} + +
Компонентов:
+
{{ kit.get_total_components_count }}
+ +
Статус:
+
+ {% if kit.is_active %} + Активен + {% else %} + Неактивен + {% endif %} +
+ +
Создан:
+
{{ kit.created_at|date:"d.m.Y" }}
+
+
+
+
+
+
+{% endblock %} diff --git a/myproject/products/templates/products/productkit_detail.html b/myproject/products/templates/products/productkit_detail.html new file mode 100644 index 0000000..4d8700d --- /dev/null +++ b/myproject/products/templates/products/productkit_detail.html @@ -0,0 +1,239 @@ +{% extends 'base.html' %} + +{% block title %}{{ kit.name }} - Комплект{% endblock %} + +{% block content %} +
+
+
+

{{ kit.name }}

+

Артикул: {{ kit.sku }}

+
+
+ {% if perms.products.change_productkit %} + + Редактировать + + {% endif %} + {% if perms.products.delete_productkit %} + + Удалить + + {% endif %} +
+
+ +
+ +
+
+
+
Основная информация
+
+
+
+
Название:
+
{{ kit.name }}
+ +
Артикул:
+
{{ kit.sku }}
+ + {% if kit.categories.all %} +
Категории:
+
+ {% for category in kit.categories.all %} + {{ category.name }} + {% endfor %} +
+ {% endif %} + +
Цена продажи:
+
+ {{ kit.get_sale_price|floatformat:2 }} ₽ +
+ +
Ценообразование:
+
+ {{ kit.get_pricing_method_display }} +
+ + {% if kit.fixed_price %} +
Фиксированная цена:
+
{{ kit.fixed_price }} ₽
+ {% endif %} + + {% if kit.markup_percent %} +
Процент наценки:
+
{{ kit.markup_percent }}%
+ {% endif %} + + {% if kit.markup_amount %} +
Фиксированная наценка:
+
{{ kit.markup_amount }} ₽
+ {% endif %} + +
Статус:
+
+ {% if kit.is_active %} + Активен + {% else %} + Неактивен + {% endif %} +
+
+ + {% if kit.description %} +
+
Описание:
+

{{ kit.description|linebreaks }}

+
+ {% endif %} + + {% if kit.tags.all %} +
+
Теги:
+
+ {% for tag in kit.tags.all %} + {{ tag.name }} + {% endfor %} +
+
+ {% endif %} +
+
+ + +
+
+
Состав комплекта ({{ kit_items.count }} компонентов)
+
+
+ {% if kit_items %} +
+ + + + + + + + + + + + {% for item in kit_items %} + + + + + + + + {% endfor %} + +
#КомпонентТипКоличествоПримечание
{{ forloop.counter }} + {% if item.product %} + + {{ item.product.name }} + +
+ Артикул: {{ item.product.sku }} + {% else %} + {{ item.variant_group.name }} +
+ Варианты: {{ item.variant_group.get_products_count }} товаров + {% endif %} +
+ {% if item.product %} + Товар + {% else %} + Варианты + {% endif %} + {{ item.quantity }} + {% if item.notes %} + {{ item.notes }} + {% else %} + + {% endif %} +
+
+ {% else %} +

Нет компонентов в этом комплекте

+ {% endif %} +
+
+
+ + +
+ +
+
+
Фотографии ({{ photos_count }})
+
+
+ {% if productkit_photos %} +
+ {% for photo in productkit_photos %} +
+
+ {{ kit.name }} + {% if photo.order == 0 %} + + {% endif %} +
+
+ + + + {% endfor %} +
+ {% else %} +

Нет фотографий

+ {% endif %} +
+
+ + +
+
+
Информация
+
+
+
+
Создан:
+
{{ kit.created_at|date:"d.m.Y H:i" }}
+ +
Обновлен:
+
{{ kit.updated_at|date:"d.m.Y H:i" }}
+
+
+
+
+
+ + + +
+{% endblock %} diff --git a/myproject/products/templates/products/productkit_form.html b/myproject/products/templates/products/productkit_form.html new file mode 100644 index 0000000..6f68637 --- /dev/null +++ b/myproject/products/templates/products/productkit_form.html @@ -0,0 +1,664 @@ +{% extends 'base.html' %} + +{% block title %}{% if object %}Редактировать комплект{% else %}Создать комплект{% endif %}{% endblock %} + +{% block content %} +
+
+
+
+
+

{% if object %}Редактировать комплект{% else %}Создать комплект{% endif %}

+
+
+
+ {% csrf_token %} + + {% if form.non_field_errors %} + + {% endif %} + + + {% if object %} +
+
Управление фотографиями
+ + + {% if productkit_photos %} +
+
Текущие фотографии ({{ photos_count }})
+
+ {% for photo in productkit_photos %} +
+
+ +
+ Фото комплекта +
+
+ {% if photo.order == 0 %} +
⭐ Главное
+ {% else %} + + ⭐ Главным + + {% endif %} + + + + + 🗑️ Удалить + + + Позиция: {{ photo.order }} +
+
+
+ + + + {% endfor %} +
+
+ {% endif %} + + +
+ + + + {% if object %} + Выберите фото для добавления к комплекту (можно выбрать несколько, до 10 штук всего) + {% else %} + Выберите фото для комплекта (можно выбрать несколько, до 10 штук) + {% endif %} + +
+
+ +
+ {% endif %} + + +
Основная информация
+ + {% for field in form %} + {% if field.name == 'fixed_price' %} + + + {% endif %} + +
+ {{ field.label_tag }} + + {% if field.name == 'tags' %} +
+ {{ field }} +
+ {% else %} + {{ field }} + {% endif %} + + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + + {% if field.errors %} +
{{ field.errors }}
+ {% endif %} +
+ {% endfor %} + +
+ + +
Состав комплекта
+ +
+ + Подсказка: Добавьте компоненты комплекта ниже. Каждый компонент может быть либо конкретным товаром, либо группой вариантов (например, розы разных размеров). +
+ + +
+
+
+ Поиск товара для добавления +
+
+
+
+ + +
+ + Используйте поиск для быстрого нахождения товара и добавления его в комплект +
+
+ + + {{ kititem_formset.management_form }} + + + {% if kititem_formset.non_form_errors %} +
+ {% for error in kititem_formset.non_form_errors %} + {{ error }} + {% endfor %} +
+ {% endif %} + + +
+ {% for kititem_form in kititem_formset %} +
+ + {{ kititem_form.id }} + +
+ Компонент #{{ forloop.counter }} + {% if kititem_form.DELETE %} +
+ {{ kititem_form.DELETE }} + +
+ {% endif %} +
+ +
+ {% if kititem_form.non_field_errors %} +
+ {% for error in kititem_form.non_field_errors %} + {{ error }} + {% endfor %} +
+ {% endif %} + +
+
+ + {{ kititem_form.product }} + {% if kititem_form.product.errors %} +
{{ kititem_form.product.errors }}
+ {% endif %} + Конкретный товар (если выбран, группа вариантов не нужна) +
+ +
+ + {{ kititem_form.variant_group }} + {% if kititem_form.variant_group.errors %} +
{{ kititem_form.variant_group.errors }}
+ {% endif %} + Группа вариантов (если выбрана, конкретный товар не нужен) +
+
+ +
+
+ + {{ kititem_form.quantity }} + {% if kititem_form.quantity.errors %} +
{{ kititem_form.quantity.errors }}
+ {% endif %} +
+ +
+ + {{ kititem_form.notes }} + {% if kititem_form.notes.errors %} +
{{ kititem_form.notes.errors }}
+ {% endif %} +
+
+
+
+ {% endfor %} +
+ + +
+ Отмена +
+ + +
+
+
+
+
+
+
+
+ + +{% endblock %} diff --git a/myproject/products/templates/products/productkit_list.html b/myproject/products/templates/products/productkit_list.html new file mode 100644 index 0000000..520ad40 --- /dev/null +++ b/myproject/products/templates/products/productkit_list.html @@ -0,0 +1,127 @@ +{% extends 'base.html' %} + +{% block title %}Список комплектов (букетов){% endblock %} + +{% block content %} +
+

Список комплектов (букетов)

+ + + {% include 'components/filter_panel.html' with title="Комплекты" filters=filters action_buttons=action_buttons %} + + {% if kits %} +
+ + + + + + + + + + + + + + + {% for kit in kits %} + + + + + + + + + + + {% endfor %} + +
ФотоНазваниеАртикулКатегорияЦена продажиКомпонентовСтатусДействия
+ {% if kit.photos.all %} + {% with photo=kit.photos.first %} + {{ kit.name }} + {% endwith %} + {% else %} + Нет фото + {% endif %} + + {{ kit.name }} + {{ kit.sku }} + {% if kit.categories.all %} + {% for category in kit.categories.all %} + {{ category.name }}{% if not forloop.last %}, {% endif %} + {% endfor %} + {% else %} + - + {% endif %} + {{ kit.get_sale_price|floatformat:2 }} руб. + {{ kit.get_total_components_count }} шт + {% if kit.get_components_with_variants_count > 0 %} + + {{ kit.get_components_with_variants_count }} + + {% endif %} + + {% if kit.is_active %} + Активен + {% else %} + Неактивен + {% endif %} + + +
+
+ + + {% if is_paginated %} + + {% endif %} + {% else %} +
+

Комплекты не найдены

+

В данный момент нет комплектов, соответствующих выбранным фильтрам.

+ {% if perms.products.add_productkit %} + + Создать первый комплект + + {% endif %} +
+ {% endif %} +
+{% endblock %} diff --git a/myproject/products/tests.py b/myproject/products/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/myproject/products/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/myproject/products/urls.py b/myproject/products/urls.py new file mode 100644 index 0000000..4c1c26c --- /dev/null +++ b/myproject/products/urls.py @@ -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('/', views.ProductDetailView.as_view(), name='product-detail'), + path('/update/', views.ProductUpdateView.as_view(), name='product-update'), + path('/delete/', views.ProductDeleteView.as_view(), name='product-delete'), + + # Photo management + path('photo//delete/', views.product_photo_delete, name='product-photo-delete'), + path('photo//set-main/', views.product_photo_set_main, name='product-photo-set-main'), + path('photo//move-up/', views.product_photo_move_up, name='product-photo-move-up'), + path('photo//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//', views.ProductKitDetailView.as_view(), name='productkit-detail'), + path('kits//update/', views.ProductKitUpdateView.as_view(), name='productkit-update'), + path('kits//delete/', views.ProductKitDeleteView.as_view(), name='productkit-delete'), + + # Photo management for ProductKit + path('kits/photo//delete/', views.productkit_photo_delete, name='productkit-photo-delete'), + path('kits/photo//set-main/', views.productkit_photo_set_main, name='productkit-photo-set-main'), + path('kits/photo//move-up/', views.productkit_photo_move_up, name='productkit-photo-move-up'), + path('kits/photo//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//', views.ProductCategoryDetailView.as_view(), name='category-detail'), + path('categories//update/', views.ProductCategoryUpdateView.as_view(), name='category-update'), + path('categories//delete/', views.ProductCategoryDeleteView.as_view(), name='category-delete'), + + # Category photo management + path('categories/photo//delete/', views.category_photo_delete, name='category-photo-delete'), + path('categories/photo//set-main/', views.category_photo_set_main, name='category-photo-set-main'), + path('categories/photo//move-up/', views.category_photo_move_up, name='category-photo-move-up'), + path('categories/photo//move-down/', views.category_photo_move_down, name='category-photo-move-down'), +] \ No newline at end of file diff --git a/myproject/products/utils/__init__.py b/myproject/products/utils/__init__.py new file mode 100644 index 0000000..d3af291 --- /dev/null +++ b/myproject/products/utils/__init__.py @@ -0,0 +1,4 @@ +""" +Utility package for the products app. +Contains various helper functions and utilities. +""" \ No newline at end of file diff --git a/myproject/products/utils/sku_generator.py b/myproject/products/utils/sku_generator.py new file mode 100644 index 0000000..0d5a0b8 --- /dev/null +++ b/myproject/products/utils/sku_generator.py @@ -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 diff --git a/myproject/products/utils/stock_manager.py b/myproject/products/utils/stock_manager.py new file mode 100644 index 0000000..5b93d52 --- /dev/null +++ b/myproject/products/utils/stock_manager.py @@ -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 diff --git a/myproject/products/views.py b/myproject/products/views.py new file mode 100644 index 0000000..d5af497 --- /dev/null +++ b/myproject/products/views.py @@ -0,0 +1,1201 @@ +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin +from django.views.generic import ListView, CreateView, DetailView, UpdateView, DeleteView +from django.urls import reverse_lazy, reverse +from django.shortcuts import redirect, get_object_or_404 +from django.http import JsonResponse +from django.db import models, transaction +from .models import Product, ProductPhoto, ProductKit, ProductKitPhoto, ProductCategory, ProductCategoryPhoto, ProductTag, ProductVariantGroup +from .forms import ProductForm, ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate, ProductCategoryForm +import os +import json + + +def validate_photo(photo): + """ + Валидация загружаемого фото. + Возвращает (True, None) если валидно, или (False, error_message) если ошибка. + """ + max_size = 5 * 1024 * 1024 # 5MB + allowed_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'] + + if photo.size > max_size: + return False, f'Размер файла {photo.name} превышает 5MB.' + + ext = os.path.splitext(photo.name)[1].lower() + if ext not in allowed_extensions: + return False, f'Формат файла {ext} не поддерживается. Разрешены: {", ".join(allowed_extensions)}' + + return True, None + + +def handle_product_photos(request, product): + """ + Обработка загружаемых фото для товара. + Возвращает список сообщений об ошибках (пустой список если все ок). + """ + errors = [] + photos = request.FILES.getlist('photos') + + if not photos: + return errors + + if len(photos) > 10: + errors.append('Можно загрузить не более 10 фотографий.') + return errors + + # Получаем максимальный order для этого товара + max_order = ProductPhoto.objects.filter(product=product).aggregate( + models.Max('order') + )['order__max'] + + # Если фото нет, начинаем с 0, иначе с max_order + 1 + next_order = 0 if max_order is None else max_order + 1 + + # Валидация и сохранение фото + for photo in photos: + is_valid, error_msg = validate_photo(photo) + if not is_valid: + errors.append(error_msg) + else: + ProductPhoto.objects.create(product=product, image=photo, order=next_order) + next_order += 1 + + return errors + + +def handle_productkit_photos(request, productkit): + """ + Обработка загружаемых фото для комплекта. + Возвращает список сообщений об ошибках (пустой список если все ок). + """ + errors = [] + photos = request.FILES.getlist('photos') + + if not photos: + return errors + + if len(photos) > 10: + errors.append('Можно загрузить не более 10 фотографий.') + return errors + + # Получаем максимальный order для этого комплекта + max_order = ProductKitPhoto.objects.filter(kit=productkit).aggregate( + models.Max('order') + )['order__max'] + + # Если фото нет, начинаем с 0, иначе с max_order + 1 + next_order = 0 if max_order is None else max_order + 1 + + # Валидация и сохранение фото + for photo in photos: + is_valid, error_msg = validate_photo(photo) + if not is_valid: + errors.append(error_msg) + else: + ProductKitPhoto.objects.create(kit=productkit, image=photo, order=next_order) + next_order += 1 + + return errors + + +# CRUD Views for Product +class ProductListView(LoginRequiredMixin, PermissionRequiredMixin, ListView): + model = Product + template_name = 'products/product_list.html' + context_object_name = 'products' + permission_required = 'products.view_product' + paginate_by = 10 + + def get_queryset(self): + from django.db.models import Q + + queryset = super().get_queryset() + # Добавляем prefetch_related для оптимизации запросов к категориям + queryset = queryset.prefetch_related('categories', 'photos', 'tags') + + # Улучшенный поиск по нескольким полям + search_query = self.request.GET.get('search') + if search_query: + # Ищем по названию, артикулу, описанию, категориям и ключевым словам + queryset = queryset.filter( + Q(name__icontains=search_query) | + Q(sku__icontains=search_query) | + Q(description__icontains=search_query) | + Q(categories__name__icontains=search_query) | + Q(search_keywords__icontains=search_query) + ).distinct() + + # Фильтр по категории + category_id = self.request.GET.get('category') + if category_id: + queryset = queryset.filter(categories__id=category_id) + + # Фильтр по статусу + is_active = self.request.GET.get('is_active') + if is_active == '1': + queryset = queryset.filter(is_active=True) + elif is_active == '0': + queryset = queryset.filter(is_active=False) + + # Фильтр по тегам + tags = self.request.GET.getlist('tags') + if tags: + queryset = queryset.filter(tags__id__in=tags).distinct() + + return queryset.order_by('-created_at') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + from .models import ProductCategory, ProductTag + + # Данные для фильтров + context['filters'] = { + 'categories': ProductCategory.objects.filter(is_active=True), + 'tags': ProductTag.objects.all(), + 'current': { + 'search': self.request.GET.get('search', ''), + 'category': self.request.GET.get('category', ''), + 'is_active': self.request.GET.get('is_active', ''), + 'tags': self.request.GET.getlist('tags'), + } + } + + # Кнопки действий + action_buttons = [] + + if self.request.user.has_perm('products.add_product'): + action_buttons.append({ + 'url': reverse_lazy('products:product-create'), + 'text': 'Создать товар', + 'class': 'btn-primary', + 'icon': 'plus-circle' + }) + + if self.request.user.has_perm('products.add_productkit'): + action_buttons.append({ + 'url': reverse_lazy('products:productkit-create'), + 'text': 'Создать комплект', + 'class': 'btn-outline-primary', + 'icon': 'box-seam' + }) + + action_buttons.append({ + 'url': reverse_lazy('products:productkit-list'), + 'text': 'К списку комплектов', + 'class': 'btn-outline-secondary', + 'icon': 'list' + }) + + context['action_buttons'] = action_buttons + + return context + + +class ProductCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView): + model = Product + form_class = ProductForm + template_name = 'products/product_form.html' + permission_required = 'products.add_product' + + def get_success_url(self): + return reverse_lazy('products:product-list') + + def form_valid(self, form): + response = super().form_valid(form) + + # Handle photo uploads + photo_errors = handle_product_photos(self.request, self.object) + if photo_errors: + for error in photo_errors: + messages.error(self.request, error) + + messages.success(self.request, f'Товар "{form.instance.name}" успешно создан!') + return response + + +class ProductDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView): + model = Product + template_name = 'products/product_detail.html' + context_object_name = 'product' + permission_required = 'products.view_product' + + def get_queryset(self): + # Prefetch photos to avoid N+1 queries + return super().get_queryset().prefetch_related('photos') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Добавляем фотографии товара в контекст + context['product_photos'] = self.object.photos.all().order_by('order', 'created_at') + context['photos_count'] = self.object.photos.count() + return context + + +class ProductUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView): + model = Product + form_class = ProductForm + template_name = 'products/product_form.html' + permission_required = 'products.change_product' + + def get_success_url(self): + return reverse_lazy('products:product-list') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Добавляем фотографии товара в контекст + context['product_photos'] = self.object.photos.all().order_by('order', 'created_at') + context['photos_count'] = self.object.photos.count() + return context + + def form_valid(self, form): + response = super().form_valid(form) + + # Handle photo uploads + photo_errors = handle_product_photos(self.request, self.object) + if photo_errors: + for error in photo_errors: + messages.error(self.request, error) + + messages.success(self.request, f'Товар "{form.instance.name}" успешно обновлен!') + return response + + +class ProductDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView): + model = Product + template_name = 'products/product_confirm_delete.html' + context_object_name = 'product' + permission_required = 'products.delete_product' + + def get_success_url(self): + messages.success(self.request, f'Товар "{self.object.name}" успешно удален!') + return reverse_lazy('products:product-list') + + +def product_photo_delete(request, pk): + """ + Удаление фотографии товара + """ + photo = get_object_or_404(ProductPhoto, pk=pk) + product_id = photo.product.id + + # Проверка прав доступа + if not request.user.has_perm('products.change_product'): + messages.error(request, 'У вас нет прав для удаления фотографий.') + return redirect('products:product-update', pk=product_id) + + photo.delete() + messages.success(request, 'Фото успешно удалено!') + + return redirect('products:product-update', pk=product_id) + + +def product_photo_set_main(request, pk): + """ + Установка фото как главного (order = 0) + """ + photo = get_object_or_404(ProductPhoto, pk=pk) + product_id = photo.product.id + + # Проверка прав доступа + if not request.user.has_perm('products.change_product'): + messages.error(request, 'У вас нет прав для изменения порядка фотографий.') + return redirect('products:product-update', pk=product_id) + + # Получаем все фото этого товара + photos = ProductPhoto.objects.filter(product_id=product_id).order_by('order') + + # Если это уже главное фото, ничего не делаем + if photo.order == 0: + messages.info(request, 'Это фото уже установлено как главное.') + return redirect('products:product-update', pk=product_id) + + # Меняем порядок: текущее главное фото становится вторым + old_order = photo.order + for p in photos: + if p.pk == photo.pk: + p.order = 0 + p.save() + elif p.order == 0: + p.order = old_order + p.save() + + messages.success(request, 'Фото установлено как главное!') + return redirect('products:product-update', pk=product_id) + + +def product_photo_move_up(request, pk): + """ + Переместить фото вверх (уменьшить order) + """ + photo = get_object_or_404(ProductPhoto, pk=pk) + product_id = photo.product.id + + # Проверка прав доступа + if not request.user.has_perm('products.change_product'): + messages.error(request, 'У вас нет прав для изменения порядка фотографий.') + return redirect('products:product-update', pk=product_id) + + # Если это уже первое фото + if photo.order == 0: + messages.info(request, 'Это фото уже первое в списке.') + return redirect('products:product-update', pk=product_id) + + # Находим предыдущее фото + prev_photo = ProductPhoto.objects.filter( + product_id=product_id, + order__lt=photo.order + ).order_by('-order').first() + + if prev_photo: + # Меняем местами + photo.order, prev_photo.order = prev_photo.order, photo.order + photo.save() + prev_photo.save() + messages.success(request, 'Фото перемещено вверх!') + + return redirect('products:product-update', pk=product_id) + + +def product_photo_move_down(request, pk): + """ + Переместить фото вниз (увеличить order) + """ + photo = get_object_or_404(ProductPhoto, pk=pk) + product_id = photo.product.id + + # Проверка прав доступа + if not request.user.has_perm('products.change_product'): + messages.error(request, 'У вас нет прав для изменения порядка фотографий.') + return redirect('products:product-update', pk=product_id) + + # Находим следующее фото + next_photo = ProductPhoto.objects.filter( + product_id=product_id, + order__gt=photo.order + ).order_by('order').first() + + if next_photo: + # Меняем местами + photo.order, next_photo.order = next_photo.order, photo.order + photo.save() + next_photo.save() + messages.success(request, 'Фото перемещено вниз!') + else: + messages.info(request, 'Это фото уже последнее в списке.') + + return redirect('products:product-update', pk=product_id) + + +# CRUD Views for ProductKit +# Временный файл для добавления в views.py + +class ProductKitListView(LoginRequiredMixin, PermissionRequiredMixin, ListView): + model = ProductKit + template_name = 'products/productkit_list.html' + context_object_name = 'kits' + permission_required = 'products.view_productkit' + paginate_by = 10 + + def get_queryset(self): + queryset = super().get_queryset() + queryset = queryset.prefetch_related('categories', 'photos', 'kit_items', 'tags') + + # Поиск по названию + search_query = self.request.GET.get('search') + if search_query: + queryset = queryset.filter(name__icontains=search_query) + + # Фильтр по категории + category_id = self.request.GET.get('category') + if category_id: + queryset = queryset.filter(categories__id=category_id) + + # Фильтр по статусу + is_active = self.request.GET.get('is_active') + if is_active == '1': + queryset = queryset.filter(is_active=True) + elif is_active == '0': + queryset = queryset.filter(is_active=False) + + return queryset.order_by('-created_at') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Данные для фильтров + context['filters'] = { + 'categories': ProductCategory.objects.filter(is_active=True), + 'tags': ProductTag.objects.all(), + 'current': { + 'search': self.request.GET.get('search', ''), + 'category': self.request.GET.get('category', ''), + 'is_active': self.request.GET.get('is_active', ''), + 'tags': self.request.GET.getlist('tags'), + } + } + + # Кнопки действий + action_buttons = [] + + if self.request.user.has_perm('products.add_productkit'): + action_buttons.append({ + 'url': reverse_lazy('products:productkit-create'), + 'text': 'Создать комплект', + 'class': 'btn-primary', + 'icon': 'plus-circle' + }) + + action_buttons.append({ + 'url': reverse_lazy('products:product-list'), + 'text': 'К товарам', + 'class': 'btn-outline-primary', + 'icon': 'box' + }) + + context['action_buttons'] = action_buttons + + return context + + +class ProductKitCreateView(LoginRequiredMixin, PermissionRequiredMixin, CreateView): + """ + View для создания нового комплекта с компонентами. + """ + model = ProductKit + form_class = ProductKitForm + template_name = 'products/productkit_form.html' + permission_required = 'products.add_productkit' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + if self.request.POST: + context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, instance=self.object) + else: + context['kititem_formset'] = KitItemFormSetCreate(instance=self.object) + + return context + + def form_valid(self, form): + # Получаем формсет из POST + kititem_formset = KitItemFormSetCreate(self.request.POST, instance=self.object) + + # Проверяем валидность формсета + if kititem_formset.is_valid(): + try: + with transaction.atomic(): + # Сохраняем основную форму + self.object = form.save() + + # Сохраняем компоненты + kititem_formset.instance = self.object + kititem_formset.save() + + # Обработка фотографий + handle_productkit_photos(self.request, self.object) + + messages.success(self.request, f'Комплект "{self.object.name}" успешно создан!') + + # Проверяем, какую кнопку нажали + if self.request.POST.get('action') == 'continue': + return redirect('products:productkit-update', pk=self.object.pk) + else: + return redirect('products:productkit-list') + except Exception as e: + messages.error(self.request, f'Ошибка при сохранении: {str(e)}') + return self.form_invalid(form) + else: + # Если формсет невалиден, показываем форму с ошибками + messages.error(self.request, 'Пожалуйста, исправьте ошибки в компонентах комплекта.') + return self.form_invalid(form) + + def form_invalid(self, form): + # Получаем формсет для отображения ошибок + context = self.get_context_data(form=form) + return self.render_to_response(context) + + def get_success_url(self): + return reverse_lazy('products:productkit-list') + + +class ProductKitUpdateView(LoginRequiredMixin, PermissionRequiredMixin, UpdateView): + """ + View для редактирования существующего комплекта. + """ + model = ProductKit + form_class = ProductKitForm + template_name = 'products/productkit_form.html' + permission_required = 'products.change_productkit' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + if self.request.POST: + context['kititem_formset'] = KitItemFormSetUpdate(self.request.POST, instance=self.object) + else: + context['kititem_formset'] = KitItemFormSetUpdate(instance=self.object) + + context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at') + context['photos_count'] = self.object.photos.count() + + return context + + def form_valid(self, form): + # Получаем формсет из POST + kititem_formset = KitItemFormSetUpdate(self.request.POST, instance=self.object) + + # Проверяем валидность формсета + if kititem_formset.is_valid(): + try: + with transaction.atomic(): + # Сохраняем основную форму + self.object = form.save() + + # Сохраняем компоненты + kititem_formset.instance = self.object + kititem_formset.save() + + # Обработка фотографий + handle_productkit_photos(self.request, self.object) + + messages.success(self.request, f'Комплект "{self.object.name}" успешно обновлен!') + + # Проверяем, какую кнопку нажали + if self.request.POST.get('action') == 'continue': + return redirect('products:productkit-update', pk=self.object.pk) + else: + return redirect('products:productkit-list') + except Exception as e: + messages.error(self.request, f'Ошибка при сохранении: {str(e)}') + return self.form_invalid(form) + else: + # Если формсет невалиден, показываем форму с ошибками + messages.error(self.request, 'Пожалуйста, исправьте ошибки в компонентах комплекта.') + return self.form_invalid(form) + + def form_invalid(self, form): + # Получаем формсет для отображения ошибок + context = self.get_context_data(form=form) + return self.render_to_response(context) + + def get_success_url(self): + return reverse_lazy('products:productkit-list') + + +class ProductKitDetailView(LoginRequiredMixin, PermissionRequiredMixin, DetailView): + """ + View для просмотра деталей комплекта. + Показывает все компоненты, цены, фотографии. + """ + model = ProductKit + template_name = 'products/productkit_detail.html' + context_object_name = 'kit' + permission_required = 'products.view_productkit' + + def get_queryset(self): + # Prefetch для оптимизации запросов + return super().get_queryset().prefetch_related( + 'photos', + 'kit_items__product', + 'kit_items__variant_group', + 'tags' + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Добавляем фотографии комплекта в контекст + context['productkit_photos'] = self.object.photos.all().order_by('order', 'created_at') + context['photos_count'] = self.object.photos.count() + # Добавляем компоненты + context['kit_items'] = self.object.kit_items.all().select_related('product', 'variant_group') + return context + + +class ProductKitDeleteView(LoginRequiredMixin, PermissionRequiredMixin, DeleteView): + """ + View для удаления комплекта. + """ + model = ProductKit + template_name = 'products/productkit_confirm_delete.html' + context_object_name = 'kit' + permission_required = 'products.delete_productkit' + + def get_success_url(self): + messages.success(self.request, f'Комплект "{self.object.name}" успешно удален!') + return reverse_lazy('products:productkit-list') + + +# Функции для управления фотографиями комплектов +def productkit_photo_delete(request, pk): + """ + Удаление фотографии комплекта + """ + photo = get_object_or_404(ProductKitPhoto, pk=pk) + kit_id = photo.kit.id + + # Проверка прав доступа + if not request.user.has_perm('products.change_productkit'): + messages.error(request, 'У вас нет прав для удаления фотографий.') + return redirect('products:productkit-update', pk=kit_id) + + photo.delete() + messages.success(request, 'Фото успешно удалено!') + + return redirect('products:productkit-update', pk=kit_id) + + +def productkit_photo_set_main(request, pk): + """ + Установка фото как главного (order = 0) + """ + photo = get_object_or_404(ProductKitPhoto, pk=pk) + kit_id = photo.kit.id + + # Проверка прав доступа + if not request.user.has_perm('products.change_productkit'): + messages.error(request, 'У вас нет прав для изменения порядка фотографий.') + return redirect('products:productkit-update', pk=kit_id) + + # Получаем все фото этого комплекта + photos = ProductKitPhoto.objects.filter(kit_id=kit_id).order_by('order') + + # Если это уже главное фото, ничего не делаем + if photo.order == 0: + messages.info(request, 'Это фото уже установлено как главное.') + return redirect('products:productkit-update', pk=kit_id) + + # Меняем порядок: текущее главное фото становится вторым + old_order = photo.order + for p in photos: + if p.pk == photo.pk: + p.order = 0 + p.save() + elif p.order == 0: + p.order = old_order + p.save() + + messages.success(request, 'Фото установлено как главное!') + return redirect('products:productkit-update', pk=kit_id) + + +def productkit_photo_move_up(request, pk): + """ + Переместить фото вверх (уменьшить order) + """ + photo = get_object_or_404(ProductKitPhoto, pk=pk) + kit_id = photo.kit.id + + # Проверка прав доступа + if not request.user.has_perm('products.change_productkit'): + messages.error(request, 'У вас нет прав для изменения порядка фотографий.') + return redirect('products:productkit-update', pk=kit_id) + + # Если это уже первое фото + if photo.order == 0: + messages.info(request, 'Это фото уже первое в списке.') + return redirect('products:productkit-update', pk=kit_id) + + # Находим предыдущее фото + prev_photo = ProductKitPhoto.objects.filter( + kit_id=kit_id, + order__lt=photo.order + ).order_by('-order').first() + + if prev_photo: + # Меняем местами + photo.order, prev_photo.order = prev_photo.order, photo.order + photo.save() + prev_photo.save() + messages.success(request, 'Фото перемещено вверх!') + + return redirect('products:productkit-update', pk=kit_id) + + +def productkit_photo_move_down(request, pk): + """ + Переместить фото вниз (увеличить order) + """ + photo = get_object_or_404(ProductKitPhoto, pk=pk) + kit_id = photo.kit.id + + # Проверка прав доступа + if not request.user.has_perm('products.change_productkit'): + messages.error(request, 'У вас нет прав для изменения порядка фотографий.') + return redirect('products:productkit-update', pk=kit_id) + + # Находим следующее фото + next_photo = ProductKitPhoto.objects.filter( + kit_id=kit_id, + order__gt=photo.order + ).order_by('order').first() + + if next_photo: + # Меняем местами + photo.order, next_photo.order = next_photo.order, photo.order + photo.save() + next_photo.save() + messages.success(request, 'Фото перемещено вниз!') + else: + messages.info(request, 'Это фото уже последнее в списке.') + + return redirect('products:productkit-update', pk=kit_id) + + +# API view для поиска товаров и групп вариантов +def search_products_and_variants(request): + """ + API endpoint для поиска товаров и групп вариантов. + Используется для автокомплита при добавлении компонентов в комплект. + + Параметры GET: + - q: строка поиска + - type: 'product' или 'variant' (опционально, если не указано - поиск по обоим) + + Возвращает JSON список: + [ + { + "id": 1, + "name": "Роза красная Freedom 50см", + "sku": "PROD-000001", + "type": "product", + "price": "150.00" + }, + { + "id": 1, + "name": "Роза красная Freedom", + "type": "variant", + "count": 3 + } + ] + """ + query = request.GET.get('q', '').strip() + search_type = request.GET.get('type', 'all') + + if not query or len(query) < 2: + return JsonResponse({'results': []}) + + results = [] + + # Поиск товаров + if search_type in ['all', 'product']: + products = Product.objects.filter( + models.Q(name__icontains=query) | + models.Q(sku__icontains=query) | + models.Q(description__icontains=query), + is_active=True + ).values('id', 'name', 'sku', 'sale_price')[:10] + + for product in products: + results.append({ + 'id': product['id'], + 'name': f"{product['name']} ({product['sku']})", + 'sku': product['sku'], + 'type': 'product', + 'price': str(product['sale_price']) + }) + + # Поиск групп вариантов + if search_type in ['all', 'variant']: + variants = ProductVariantGroup.objects.filter( + models.Q(name__icontains=query) | + models.Q(description__icontains=query) + ).prefetch_related('products')[:10] + + for variant in variants: + count = variant.products.filter(is_active=True).count() + results.append({ + 'id': variant.id, + 'name': f"{variant.name} ({count} вариантов)", + 'type': 'variant', + 'count': count + }) + + return JsonResponse({'results': results}) + + +# ======================================== +# Category Photo Management Functions +# ======================================== + +def handle_category_photos(request, category): + """ + Обработка загружаемых фото для категории. + Возвращает список сообщений об ошибках (пустой список если все ок). + """ + errors = [] + photos = request.FILES.getlist('photos') + + if not photos: + return errors + + if len(photos) > 10: + errors.append('Можно загрузить не более 10 фотографий.') + return errors + + # Получаем максимальный order для этой категории + max_order = ProductCategoryPhoto.objects.filter(category=category).aggregate( + models.Max('order') + )['order__max'] + + # Если фото нет, начинаем с 0, иначе с max_order + 1 + next_order = 0 if max_order is None else max_order + 1 + + # Валидация и сохранение фото + for photo in photos: + is_valid, error_msg = validate_photo(photo) + if not is_valid: + errors.append(error_msg) + else: + ProductCategoryPhoto.objects.create(category=category, image=photo, order=next_order) + next_order += 1 + + return errors + + +def category_photo_delete(request, pk): + """Удаление фото категории""" + photo = get_object_or_404(ProductCategoryPhoto, pk=pk) + category = photo.category + photo.delete() + messages.success(request, 'Фото удалено.') + return redirect('products:category-update', pk=category.pk) + + +def category_photo_set_main(request, pk): + """Установка фото категории как главного (order=0)""" + photo = get_object_or_404(ProductCategoryPhoto, pk=pk) + category = photo.category + + # Находим текущее главное фото (order=0) + current_main = ProductCategoryPhoto.objects.filter(category=category, order=0).first() + + if current_main and current_main.pk != photo.pk: + # Меняем местами order + current_main.order = photo.order + photo.order = 0 + current_main.save() + photo.save() + messages.success(request, 'Главное фото обновлено.') + else: + messages.info(request, 'Это фото уже является главным.') + + return redirect('products:category-update', pk=category.pk) + + +def category_photo_move_up(request, pk): + """Переместить фото категории вверх (уменьшить order)""" + photo = get_object_or_404(ProductCategoryPhoto, pk=pk) + category = photo.category + + # Находим фото с меньшим order + prev_photo = ProductCategoryPhoto.objects.filter( + category=category, + order__lt=photo.order + ).order_by('-order').first() + + if prev_photo: + # Меняем местами order + photo.order, prev_photo.order = prev_photo.order, photo.order + photo.save() + prev_photo.save() + messages.success(request, 'Фото перемещено вверх.') + else: + messages.info(request, 'Фото уже первое в списке.') + + return redirect('products:category-update', pk=category.pk) + + +def category_photo_move_down(request, pk): + """Переместить фото категории вниз (увеличить order)""" + photo = get_object_or_404(ProductCategoryPhoto, pk=pk) + category = photo.category + + # Находим фото с большим order + next_photo = ProductCategoryPhoto.objects.filter( + category=category, + order__gt=photo.order + ).order_by('order').first() + + if next_photo: + # Меняем местами order + photo.order, next_photo.order = next_photo.order, photo.order + photo.save() + next_photo.save() + messages.success(request, 'Фото перемещено вниз.') + else: + messages.info(request, 'Фото уже последнее в списке.') + + return redirect('products:category-update', pk=category.pk) + + +# ======================================== +# CRUD Views for ProductCategory +# ======================================== + +class TreeItem: + """ + Универсальный элемент дерева категорий. + Может представлять категорию, товар или набор. + """ + def __init__(self, obj, item_type, depth, parent_category_id=None): + self.obj = obj + self.item_type = item_type # 'category', 'product', 'kit' + self.depth = depth + self.pk = obj.pk + self.name = obj.name + self.sku = getattr(obj, 'sku', None) + + if item_type == 'category': + self.has_children = obj.children.exists() + self.parent_id = obj.parent.pk if obj.parent else None + else: + # Товары и наборы не имеют детей + self.has_children = False + # Виртуальный parent_id = ID категории (для JavaScript) + self.parent_id = parent_category_id + + # Получение цены + if item_type == 'product': + self.price = obj.sale_price + elif item_type == 'kit': + self.price = obj.get_sale_price() + else: + self.price = None + + +class ProductCategoryListView(LoginRequiredMixin, ListView): + model = ProductCategory + template_name = 'products/category_list.html' + context_object_name = 'categories' + paginate_by = None # Отключаем пагинацию для иерархического отображения + + def get_queryset(self): + from django.db.models import Q + + queryset = super().get_queryset() + # Добавляем prefetch_related для оптимизации запросов к фото + queryset = queryset.prefetch_related('photos', 'children') + + # Поиск по названию и артикулу + search_query = self.request.GET.get('search') + if search_query: + queryset = queryset.filter( + Q(name__icontains=search_query) | + Q(sku__icontains=search_query) | + Q(slug__icontains=search_query) + ) + + # Фильтр по статусу + is_active = self.request.GET.get('is_active') + if is_active == '1': + queryset = queryset.filter(is_active=True) + elif is_active == '0': + queryset = queryset.filter(is_active=False) + + return queryset + + def build_category_tree(self, queryset): + """ + Строит иерархическое дерево категорий с товарами и наборами. + Возвращает плоский список TreeItem объектов. + """ + # Получаем все категории из queryset с prefetch для товаров и наборов + all_categories = list(queryset.select_related('parent') + .prefetch_related('photos', 'children', + 'products', 'kits')) + + # Создаем словарь для быстрого доступа по ID + categories_dict = {cat.pk: cat for cat in all_categories} + + # Добавляем служебные поля + for cat in all_categories: + cat.depth = 0 + cat.has_children = cat.children.exists() + + # Находим корневые категории (parent=None или parent не в нашем queryset) + queryset_ids = set(categories_dict.keys()) + root_categories = [ + cat for cat in all_categories + if cat.parent is None or cat.parent.pk not in queryset_ids + ] + + # Сортируем корневые по имени + root_categories.sort(key=lambda x: x.name.lower()) + + # Рекурсивно строим дерево + result = [] + + def add_category_and_children(category, depth=0): + # 1. Добавляем категорию как TreeItem + tree_item = TreeItem(category, 'category', depth) + result.append(tree_item) + + # 2. Добавляем активные товары этой категории (отсортированные по имени) + products = category.products.filter(is_active=True).order_by('name') + for product in products: + product_item = TreeItem(product, 'product', depth + 1, category.pk) + result.append(product_item) + + # 3. Добавляем активные наборы этой категории (отсортированные по имени) + kits = category.kits.filter(is_active=True).order_by('name') + for kit in kits: + kit_item = TreeItem(kit, 'kit', depth + 1, category.pk) + result.append(kit_item) + + # 4. Рекурсивно обрабатываем дочерние категории + children = [ + cat for cat in all_categories + if cat.parent and cat.parent.pk == category.pk + ] + # Сортируем детей по имени + children.sort(key=lambda x: x.name.lower()) + + # Рекурсивно добавляем детей + for child in children: + add_category_and_children(child, depth + 1) + + # Обходим все корневые категории + for root in root_categories: + add_category_and_children(root, 0) + + return result + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # Строим иерархическое дерево + queryset = self.get_queryset() + category_tree = self.build_category_tree(queryset) + context['category_tree'] = category_tree + + # Флаг для автоматического раскрытия при поиске + context['has_search'] = bool(self.request.GET.get('search')) + + # Фильтры для панели фильтрации + context['filters'] = { + 'search': self.request.GET.get('search', ''), + 'is_active': self.request.GET.get('is_active', ''), + 'current': { + 'search': self.request.GET.get('search', ''), + 'is_active': self.request.GET.get('is_active', ''), + } + } + # Кнопки действий + context['action_buttons'] = [ + {'url': reverse('products:category-create'), 'text': 'Создать категорию', 'icon': 'plus-circle'} + ] + return context + + +class ProductCategoryCreateView(LoginRequiredMixin, CreateView): + model = ProductCategory + form_class = ProductCategoryForm + template_name = 'products/category_form.html' + success_url = reverse_lazy('products:category-list') + + def form_valid(self, form): + # Сохраняем категорию + self.object = form.save() + messages.success(self.request, f'Категория "{self.object.name}" создана успешно.') + + # Обработка загрузки фотографий + errors = handle_category_photos(self.request, self.object) + if errors: + for error in errors: + messages.warning(self.request, error) + + return redirect(self.get_success_url()) + + def form_invalid(self, form): + messages.error(self.request, 'Пожалуйста, исправьте ошибки в форме.') + return super().form_invalid(form) + + +class ProductCategoryDetailView(LoginRequiredMixin, DetailView): + model = ProductCategory + template_name = 'products/category_detail.html' + context_object_name = 'category' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Получаем фотографии категории + context['category_photos'] = self.object.photos.all().order_by('order') + context['photos_count'] = context['category_photos'].count() + # Получаем дочерние категории + context['children_categories'] = self.object.children.filter(is_active=True) + # Получаем товары в категории + context['products'] = self.object.products.filter(is_active=True)[:20] + context['products_count'] = self.object.products.filter(is_active=True).count() + return context + + +class ProductCategoryUpdateView(LoginRequiredMixin, UpdateView): + model = ProductCategory + form_class = ProductCategoryForm + template_name = 'products/category_form.html' + success_url = reverse_lazy('products:category-list') + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Добавляем фотографии для отображения в форме + context['category_photos'] = self.object.photos.all().order_by('order') + context['photos_count'] = context['category_photos'].count() + return context + + def form_valid(self, form): + # Сохраняем категорию + self.object = form.save() + messages.success(self.request, f'Категория "{self.object.name}" обновлена успешно.') + + # Обработка загрузки новых фотографий + errors = handle_category_photos(self.request, self.object) + if errors: + for error in errors: + messages.warning(self.request, error) + + return redirect(self.get_success_url()) + + def form_invalid(self, form): + messages.error(self.request, 'Пожалуйста, исправьте ошибки в форме.') + return super().form_invalid(form) + + +class ProductCategoryDeleteView(LoginRequiredMixin, DeleteView): + model = ProductCategory + template_name = 'products/category_confirm_delete.html' + success_url = reverse_lazy('products:category-list') + context_object_name = 'category' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + # Проверяем, есть ли товары в категории + context['products_count'] = self.object.products.count() + context['children_count'] = self.object.children.count() + return context + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + + # Проверяем, есть ли товары в категории + if self.object.products.exists(): + messages.error( + request, + f'Невозможно удалить категорию "{self.object.name}", ' + f'так как в ней есть товары ({self.object.products.count()} шт.). ' + f'Сначала удалите или переместите товары.' + ) + return redirect('products:category-detail', pk=self.object.pk) + + # Проверяем, есть ли дочерние категории + if self.object.children.exists(): + messages.error( + request, + f'Невозможно удалить категорию "{self.object.name}", ' + f'так как у неё есть подкатегории ({self.object.children.count()} шт.). ' + f'Сначала удалите или переместите подкатегории.' + ) + return redirect('products:category-detail', pk=self.object.pk) + + messages.success(request, f'Категория "{self.object.name}" удалена успешно.') + return super().post(request, *args, **kwargs) diff --git a/myproject/static/admin/css/custom_nested.css b/myproject/static/admin/css/custom_nested.css new file mode 100644 index 0000000..6790c1a --- /dev/null +++ b/myproject/static/admin/css/custom_nested.css @@ -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; +} diff --git a/myproject/static/css/filter_panel.css b/myproject/static/css/filter_panel.css new file mode 100644 index 0000000..05997b6 --- /dev/null +++ b/myproject/static/css/filter_panel.css @@ -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; + } +} diff --git a/myproject/templates/base.html b/myproject/templates/base.html new file mode 100644 index 0000000..0af1dc2 --- /dev/null +++ b/myproject/templates/base.html @@ -0,0 +1,40 @@ + + + + + + {% block title %}Мой Django Проект{% endblock %} + + + + + + + {% include 'navbar.html' %} + + + {% if user.is_authenticated %} + {% include 'components/messages.html' %} + {% endif %} + +
+ {% block content %}{% endblock %} +
+ + + {% block extra_js %}{% endblock %} + + \ No newline at end of file diff --git a/myproject/templates/change_password.html b/myproject/templates/change_password.html new file mode 100644 index 0000000..275b7eb --- /dev/null +++ b/myproject/templates/change_password.html @@ -0,0 +1,56 @@ +{% extends 'base.html' %} + +{% block title %}Смена пароля{% endblock %} + +{% block content %} +
+
+

Смена пароля

+ +
+ {% csrf_token %} +
+ {% include 'accounts/password_input.html' with field_name=form.old_password.id_for_label field_label='Старый пароль' required=True field_errors=form.old_password.errors %} +
+ +
+ {% include 'accounts/password_input.html' with field_name=form.new_password1.id_for_label field_label='Новый пароль' required=True field_errors=form.new_password1.errors %} +
+ +
+ {% include 'accounts/password_input.html' with field_name=form.new_password2.id_for_label field_label='Подтверждение нового пароля' required=True field_errors=form.new_password2.errors %} +
+ +
+ + Отмена + + +
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/myproject/templates/components/filter_panel.html b/myproject/templates/components/filter_panel.html new file mode 100644 index 0000000..a84da4b --- /dev/null +++ b/myproject/templates/components/filter_panel.html @@ -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 %} + + + +
+
+ +
+
+ {{ title|default:"Фильтры" }} +
+ + + {% if action_buttons %} + + {% endif %} +
+ +
+ + +
+
+ + {% if show_search|default:True %} +
+ + +
+ {% endif %} + + + {% if show_category|default:True and filters.categories %} +
+ + +
+ {% endif %} + + + {% if show_status|default:True %} +
+ + +
+ {% endif %} + + + {% if show_tags|default:True and filters.tags %} +
+ +
+ {% for tag in filters.tags %} +
+ + +
+ {% endfor %} +
+
+ {% endif %} + + +
+
+ + + Сбросить + +
+
+
+
+
+
diff --git a/myproject/templates/components/messages.html b/myproject/templates/components/messages.html new file mode 100644 index 0000000..365184f --- /dev/null +++ b/myproject/templates/components/messages.html @@ -0,0 +1,12 @@ + + +{% if messages %} +
+ {% for message in messages %} + + {% endfor %} +
+{% endif %} diff --git a/myproject/templates/dashboard.html b/myproject/templates/dashboard.html new file mode 100644 index 0000000..7fee8b2 --- /dev/null +++ b/myproject/templates/dashboard.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} + +{% block title %}Панель управления{% endblock %} + +{% block content %} +
+
+

Добро пожаловать, {{ user.name|default:user.email }}!

+

Вы успешно вошли в систему.

+
+
+{% endblock %} \ No newline at end of file diff --git a/myproject/templates/home.html b/myproject/templates/home.html new file mode 100644 index 0000000..871eef5 --- /dev/null +++ b/myproject/templates/home.html @@ -0,0 +1,22 @@ +{% extends 'base.html' %} + +{% block title %}Главная страница{% endblock %} + +{% block content %} +
+
+

Добро пожаловать!

+

Система аутентификации с подтверждением email

+ + {% if user.is_authenticated %} +

Рады видеть вас снова, {{ user.name|default:user.email }}!

+ Перейти в панель управления + {% else %} + + {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/myproject/templates/index.html b/myproject/templates/index.html new file mode 100644 index 0000000..9c6d83f --- /dev/null +++ b/myproject/templates/index.html @@ -0,0 +1,138 @@ +{% extends 'base.html' %} + +{% block title %}Регистрация / Вход{% endblock %} + +{% block content %} + + +
+ {% if messages %} + {% for message in messages %} + {% if 'danger' in message.tags or 'error' in message.tags %} + + {% endif %} + {% endfor %} + {% endif %} +
+ +

Добро пожаловать

+ + + + + +
+ +
+
+ {% csrf_token %} +
+ + {{ form.name }} + {% if form.name.errors %} +
{{ form.name.errors }}
+ {% endif %} +
+
+ + {{ form.email }} + {% if form.email.errors %} +
{{ form.email.errors }}
+ {% endif %} +
+ {% 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 %} + +
+
+ + +
+
+ {% csrf_token %} +
+ + +
+ {% include 'accounts/password_input.html' with field_name='password' field_label='Пароль' required=True %} + + + + +
+
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/myproject/templates/login.html b/myproject/templates/login.html new file mode 100644 index 0000000..2a17afd --- /dev/null +++ b/myproject/templates/login.html @@ -0,0 +1,50 @@ +{% extends 'base.html' %} + +{% block title %}Вход{% endblock %} + +{% block content %} +
+
+
+
+ + +
+ {% if messages %} + {% for message in messages %} + {% if 'danger' in message.tags or 'error' in message.tags %} + + {% endif %} + {% endfor %} + {% endif %} +
+ +

Вход

+ +
+ {% csrf_token %} +
+ + +
+ {% include 'accounts/password_input.html' with field_name='password' field_label='Пароль' required=True %} + +
+ + + + + + +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/myproject/templates/navbar.html b/myproject/templates/navbar.html new file mode 100644 index 0000000..a99a076 --- /dev/null +++ b/myproject/templates/navbar.html @@ -0,0 +1,61 @@ + + \ No newline at end of file diff --git a/myproject/templates/profile.html b/myproject/templates/profile.html new file mode 100644 index 0000000..e1e2d17 --- /dev/null +++ b/myproject/templates/profile.html @@ -0,0 +1,103 @@ +{% extends 'base.html' %} + +{% block title %}Профиль{% endblock %} + +{% block content %} +
+
+
+
+
+

+ + Ваш профиль +

+
+
+
+
+
+ +
+
+
+
+
+
+ + Имя: +
+
+ {{ user.name }} +
+
+ +
+
+ + Email: +
+
+ {{ user.email }} +
+
+ +
+
+ + Статус email: +
+
+ {% if user.is_email_confirmed %} + + Подтвержден + + {% else %} + + Не подтвержден + + {% endif %} +
+
+
+
+
+ +
+ + Назад + + + {% if not user.is_email_confirmed %} + + {% endif %} + + + Сменить пароль + + + +
+
+
+
+
+
+ + +{% endblock %} \ No newline at end of file diff --git a/myproject/templates/register.html b/myproject/templates/register.html new file mode 100644 index 0000000..5335f67 --- /dev/null +++ b/myproject/templates/register.html @@ -0,0 +1,41 @@ +{% extends 'base.html' %} + +{% block title %}Регистрация{% endblock %} + +{% block content %} +
+
+

Регистрация

+ +
+
+
+ {% csrf_token %} +
+ + {{ form.name }} + {% if form.name.errors %} +
{{ form.name.errors }}
+ {% endif %} +
+
+ + {{ form.email }} + {% if form.email.errors %} +
{{ form.email.errors }}
+ {% endif %} +
+ {% 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 %} + +
+ + + +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/myproject/test_category_tree.py b/myproject/test_category_tree.py new file mode 100644 index 0000000..db7c2f7 --- /dev/null +++ b/myproject/test_category_tree.py @@ -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") diff --git a/myproject/test_category_validation.py b/myproject/test_category_validation.py new file mode 100644 index 0000000..5684399 --- /dev/null +++ b/myproject/test_category_validation.py @@ -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) diff --git a/myproject/test_cycles.py b/myproject/test_cycles.py new file mode 100644 index 0000000..66c5d13 --- /dev/null +++ b/myproject/test_cycles.py @@ -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) diff --git a/myproject/test_sku_generation.py b/myproject/test_sku_generation.py new file mode 100644 index 0000000..4a75eb6 --- /dev/null +++ b/myproject/test_sku_generation.py @@ -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()