feat: Добавить систему мультитенантности с регистрацией магазинов

Реализована полноценная система мультитенантности на базе django-tenants.
Каждый магазин получает изолированную схему БД и поддомен.

Основные компоненты:

Django-tenants интеграция:
- Модели Client (тенант) и Domain в приложении tenants/
- Разделение на SHARED_APPS и TENANT_APPS
- Public schema для общей админки
- Tenant schemas для изолированных данных магазинов

Система регистрации магазинов:
- Публичная форма регистрации на /register/
- Модель TenantRegistration для заявок со статусами (pending/approved/rejected)
- Валидация schema_name (латиница, 3-63 символа, уникальность)
- Проверка на зарезервированные имена (admin, api, www и т.д.)
- Админ-панель для модерации заявок с кнопками активации/отклонения

Система подписок:
- Модель Subscription с планами (триал 90 дней, месяц, квартал, год)
- Автоматическое создание триальной подписки при активации
- Методы is_expired() и days_left() для проверки статуса
- Цветовая индикация в админке (зеленый/оранжевый/красный)

Приложения:
- tenants/ - управление тенантами, регистрация, подписки
- shops/ - точки магазинов/самовывоза (tenant app)
- Обновлены миграции для всех приложений

Утилиты:
- switch_to_tenant.py - переключение между схемами тенантов
- Обновлены image_processor и image_service

Конфигурация:
- urls_public.py - роуты для public schema (админка + регистрация)
- urls.py - роуты для tenant schemas (магазины)
- requirements.txt - добавлены django-tenants, django-environ, phonenumber-field

Документация:
- DJANGO_TENANTS_SETUP.md - настройка мультитенантности
- TENANT_REGISTRATION_GUIDE.md - руководство по регистрации
- QUICK_START.md - быстрый старт
- START_HERE.md - общая документация

Использование:
1. Пользователь: http://localhost:8000/register/ → заполняет форму
2. Админ: http://localhost:8000/admin/ → активирует заявку
3. Результат: http://{schema_name}.localhost:8000/ - готовый магазин

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-10-27 19:13:10 +03:00
parent 4b44624f86
commit 097d4ea304
43 changed files with 3186 additions and 553 deletions

View File

@@ -1,28 +1,174 @@
# -*- coding: utf-8 -*-
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'
from .models import Order, OrderItem
class OrderItemInline(admin.TabularInline):
"""
Inline для управления позициями заказа прямо в форме заказа.
"""
model = OrderItem
extra = 1
readonly_fields = ('snapshot_name', 'snapshot_sku', 'sale_price', 'cost_price')
fields = ['product', 'product_kit', 'quantity', 'price']
readonly_fields = []
def get_readonly_fields(self, request, obj=None):
"""Делаем цену readonly для существующих позиций"""
if obj and obj.pk:
return ['price']
return []
@admin.register(Order)
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'
"""
Админ-панель для управления заказами.
"""
list_display = [
'order_number',
'customer',
'delivery_type',
'delivery_date',
'status',
'total_amount',
'is_paid',
'created_at',
]
list_filter = [
'status',
'delivery_type',
'is_paid',
'delivery_date',
'created_at',
]
search_fields = [
'order_number',
'customer__name',
'customer__phone',
'customer__email',
'delivery_address__recipient_name',
'delivery_address__street',
]
readonly_fields = [
'order_number',
'created_at',
'updated_at',
'delivery_info',
'delivery_time_window',
]
fieldsets = (
('Основная информация', {
'fields': ('order_number', 'customer', 'status')
}),
('Доставка', {
'fields': (
'delivery_type',
'delivery_address',
'pickup_shop',
'delivery_date',
'delivery_time_start',
'delivery_time_end',
'delivery_cost',
'delivery_info',
'delivery_time_window',
)
}),
('Оплата', {
'fields': ('payment_method', 'is_paid', 'total_amount')
}),
('Дополнительно', {
'fields': ('is_anonymous', 'special_instructions'),
'classes': ('collapse',)
}),
('Системная информация', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
inlines = [OrderItemInline]
actions = [
'mark_as_confirmed',
'mark_as_in_assembly',
'mark_as_in_delivery',
'mark_as_delivered',
'mark_as_paid',
]
admin.site.register(Customer, CustomerAdmin)
admin.site.register(Order, OrderAdmin)
admin.site.register(OrderItem)
def mark_as_confirmed(self, request, queryset):
"""Отметить заказы как подтвержденные"""
updated = queryset.update(status='confirmed')
self.message_user(request, f'{updated} заказ(ов) отмечено как подтвержденные')
mark_as_confirmed.short_description = 'Отметить как подтвержденные'
def mark_as_in_assembly(self, request, queryset):
"""Отметить заказы как в сборке"""
updated = queryset.update(status='in_assembly')
self.message_user(request, f'{updated} заказ(ов) отмечено как в сборке')
mark_as_in_assembly.short_description = 'Отметить как в сборке'
def mark_as_in_delivery(self, request, queryset):
"""Отметить заказы как в доставке"""
updated = queryset.update(status='in_delivery')
self.message_user(request, f'{updated} заказ(ов) отмечено как в доставке')
mark_as_in_delivery.short_description = 'Отметить как в доставке'
def mark_as_delivered(self, request, queryset):
"""Отметить заказы как доставленные"""
updated = queryset.update(status='delivered')
self.message_user(request, f'{updated} заказ(ов) отмечено как доставленные')
mark_as_delivered.short_description = 'Отметить как доставленные'
def mark_as_paid(self, request, queryset):
"""Отметить заказы как оплаченные"""
updated = queryset.update(is_paid=True)
self.message_user(request, f'{updated} заказ(ов) отмечено как оплаченные')
mark_as_paid.short_description = 'Отметить как оплаченные'
@admin.register(OrderItem)
class OrderItemAdmin(admin.ModelAdmin):
"""
Админ-панель для управления позициями заказов.
"""
list_display = [
'order',
'item_name',
'quantity',
'price',
'get_total_price',
]
list_filter = [
'order__status',
'order__created_at',
]
search_fields = [
'order__order_number',
'product__name',
'product_kit__name',
]
readonly_fields = ['created_at', 'get_total_price']
fieldsets = (
('Заказ', {
'fields': ('order',)
}),
('Товар/Комплект', {
'fields': ('product', 'product_kit')
}),
('Информация', {
'fields': ('quantity', 'price', 'get_total_price')
}),
('Системная информация', {
'fields': ('created_at',),
'classes': ('collapse',)
}),
)

View File

@@ -1,7 +1,6 @@
# Generated by Django 5.2.7 on 2025-10-25 13:44
# Generated by Django 5.1.4 on 2025-10-26 22:44
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
@@ -10,80 +9,90 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('customers', '0001_initial'),
('products', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('shops', '0001_initial'),
]
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='Общая сумма')),
('order_number', models.CharField(editable=False, help_text='Уникальный номер заказа для отображения клиенту', max_length=50, unique=True, verbose_name='Номер заказа')),
('delivery_type', models.CharField(choices=[('courier', 'Курьерская доставка'), ('pickup', 'Самовывоз')], default='courier', max_length=20, verbose_name='Тип доставки')),
('delivery_date', models.DateField(verbose_name='Дата доставки/самовывоза')),
('delivery_time_start', models.TimeField(help_text='Начало временного интервала', verbose_name='Время от')),
('delivery_time_end', models.TimeField(help_text='Конец временного интервала', verbose_name='Время до')),
('delivery_cost', models.DecimalField(decimal_places=2, default=0, help_text='0 для самовывоза', max_digits=10, verbose_name='Стоимость доставки')),
('status', models.CharField(choices=[('new', 'Новый'), ('confirmed', 'Подтвержден'), ('in_assembly', 'В сборке'), ('in_delivery', 'В доставке'), ('delivered', 'Доставлен'), ('cancelled', 'Отменен')], default='new', max_length=20, verbose_name='Статус заказа')),
('payment_method', models.CharField(choices=[('cash_to_courier', 'Наличные курьеру'), ('card_to_courier', 'Карта курьеру'), ('online', 'Онлайн оплата'), ('bank_transfer', 'Банковский перевод')], default='cash_to_courier', max_length=20, verbose_name='Способ оплаты')),
('is_paid', models.BooleanField(default=False, verbose_name='Оплачен')),
('total_amount', models.DecimalField(decimal_places=2, default=0, help_text='Общая сумма заказа включая доставку', max_digits=10, verbose_name='Итоговая сумма заказа')),
('is_anonymous', models.BooleanField(default=False, help_text='Не сообщать получателю имя отправителя', verbose_name='Анонимная доставка')),
('special_instructions', models.TextField(blank=True, help_text='Комментарии и пожелания к заказу', null=True, 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='Клиент')),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.customer', verbose_name='Клиент')),
('delivery_address', models.ForeignKey(blank=True, help_text='Обязательно для курьерской доставки', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='orders', to='customers.address', verbose_name='Адрес доставки')),
('pickup_shop', models.ForeignKey(blank=True, help_text='Обязательно для самовывоза', null=True, on_delete=django.db.models.deletion.PROTECT, related_name='pickup_orders', to='shops.shop', verbose_name='Точка самовывоза')),
],
options={
'verbose_name': 'Заказ',
'verbose_name_plural': 'Заказы',
'ordering': ['-created_at'],
},
),
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='Комплект')),
('quantity', models.PositiveIntegerField(default=1, verbose_name='Количество')),
('price', models.DecimalField(decimal_places=2, help_text='Цена на момент создания заказа (фиксируется)', max_digits=10, verbose_name='Цена за единицу')),
('created_at', models.DateTimeField(auto_now_add=True, 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='Товар')),
('product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='products.product', verbose_name='Товар')),
('product_kit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='order_items', to='products.productkit', verbose_name='Комплект товаров')),
],
options={
'verbose_name': 'Позиция заказа',
'verbose_name_plural': 'Позиции заказов',
'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=['status'], name='orders_orde_status_c6dd84_idx'),
),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['delivery_date'], name='orders_orde_deliver_e4274f_idx'),
),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['delivery_type'], name='orders_orde_deliver_f68568_idx'),
),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['created_at'], name='orders_orde_created_0e92de_idx'),
),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['order_number'], name='orders_orde_order_n_f3ada5_idx'),
),
migrations.AddIndex(
model_name='orderitem',
index=models.Index(fields=['order'], name='orders_orde_order_i_5d347b_idx'),
),
migrations.AddIndex(
model_name='orderitem',
index=models.Index(fields=['product'], name='orders_orde_product_32ff41_idx'),
),
migrations.AddIndex(
model_name='orderitem',
index=models.Index(fields=['product_kit'], name='orders_orde_product_925b51_idx'),
),
]

View File

@@ -1,135 +1,334 @@
from django.db import models
from django.core.exceptions import ValidationError
from accounts.models import CustomUser
from customers.models import Customer, Address
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})"
from shops.models import Shop
import uuid
class Order(models.Model):
"""
Заказ клиента.
Заказ клиента для доставки цветов.
"""
# Основная информация
customer = models.ForeignKey(
Customer,
on_delete=models.PROTECT,
related_name='orders',
verbose_name="Клиент"
)
order_number = models.CharField(
max_length=50,
unique=True,
editable=False,
verbose_name="Номер заказа",
help_text="Уникальный номер заказа для отображения клиенту"
)
# Тип доставки
DELIVERY_TYPE_CHOICES = [
('courier', 'Курьерская доставка'),
('pickup', 'Самовывоз'),
]
delivery_type = models.CharField(
max_length=20,
choices=DELIVERY_TYPE_CHOICES,
default='courier',
verbose_name="Тип доставки"
)
# Адрес доставки (для курьерской доставки)
delivery_address = models.ForeignKey(
Address,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='orders',
verbose_name="Адрес доставки",
help_text="Обязательно для курьерской доставки"
)
# Пункт самовывоза (для самовывоза)
pickup_shop = models.ForeignKey(
Shop,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='pickup_orders',
verbose_name="Точка самовывоза",
help_text="Обязательно для самовывоза"
)
# Дата и время доставки/самовывоза
delivery_date = models.DateField(
verbose_name="Дата доставки/самовывоза"
)
delivery_time_start = models.TimeField(
verbose_name="Время от",
help_text="Начало временного интервала"
)
delivery_time_end = models.TimeField(
verbose_name="Время до",
help_text="Конец временного интервала"
)
delivery_cost = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Стоимость доставки",
help_text="0 для самовывоза"
)
# Статус заказа
STATUS_CHOICES = [
('created', 'Создан'),
('new', 'Новый'),
('confirmed', 'Подтвержден'),
('assembled', 'Собран'),
('in_assembly', 'В сборке'),
('in_delivery', 'В доставке'),
('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="Дата обновления")
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default='new',
verbose_name="Статус заказа"
)
# Оплата
PAYMENT_METHOD_CHOICES = [
('cash_to_courier', 'Наличные курьеру'),
('card_to_courier', 'Карта курьеру'),
('online', 'Онлайн оплата'),
('bank_transfer', 'Банковский перевод'),
]
payment_method = models.CharField(
max_length=20,
choices=PAYMENT_METHOD_CHOICES,
default='cash_to_courier',
verbose_name="Способ оплаты"
)
is_paid = models.BooleanField(
default=False,
verbose_name="Оплачен"
)
total_amount = models.DecimalField(
max_digits=10,
decimal_places=2,
default=0,
verbose_name="Итоговая сумма заказа",
help_text="Общая сумма заказа включая доставку"
)
# Дополнительная информация
is_anonymous = models.BooleanField(
default=False,
verbose_name="Анонимная доставка",
help_text="Не сообщать получателю имя отправителя"
)
special_instructions = models.TextField(
blank=True,
null=True,
verbose_name="Особые пожелания",
help_text="Комментарии и пожелания к заказу"
)
# Временные метки
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=['status']),
models.Index(fields=['delivery_date']),
models.Index(fields=['delivery_type']),
models.Index(fields=['created_at']),
models.Index(fields=['order_number']),
]
ordering = ['-created_at']
def __str__(self):
return f"Заказ #{self.id} - {self.customer}"
return f"Заказ #{self.order_number} - {self.customer}"
def save(self, *args, **kwargs):
# Генерируем уникальный номер заказа при создании
if not self.order_number:
self.order_number = self.generate_order_number()
super().save(*args, **kwargs)
def generate_order_number(self):
"""Генерирует уникальный номер заказа"""
# Формат: ORD-YYYYMMDD-XXXX (например: ORD-20250126-A3F2)
from datetime import datetime
date_str = datetime.now().strftime('%Y%m%d')
unique_id = uuid.uuid4().hex[:4].upper()
return f"ORD-{date_str}-{unique_id}"
def clean(self):
"""Валидация модели"""
super().clean()
# Проверка: для курьерской доставки обязателен адрес
if self.delivery_type == 'courier' and not self.delivery_address:
raise ValidationError({
'delivery_address': 'Для курьерской доставки необходимо указать адрес доставки'
})
# Проверка: для самовывоза обязателен пункт самовывоза
if self.delivery_type == 'pickup' and not self.pickup_shop:
raise ValidationError({
'pickup_shop': 'Для самовывоза необходимо выбрать точку самовывоза'
})
# Проверка: время окончания должно быть позже времени начала
if self.delivery_time_start and self.delivery_time_end:
if self.delivery_time_end <= self.delivery_time_start:
raise ValidationError({
'delivery_time_end': 'Время окончания должно быть позже времени начала'
})
def calculate_total(self):
"""Рассчитывает итоговую сумму заказа"""
items_total = sum(item.get_total_price() for item in self.items.all())
self.total_amount = items_total + self.delivery_cost
return self.total_amount
@property
def delivery_info(self):
"""Информация о доставке для отображения"""
if self.delivery_type == 'courier':
return f"Доставка по адресу: {self.delivery_address.full_address}"
elif self.delivery_type == 'pickup':
return f"Самовывоз из: {self.pickup_shop.name}"
return "Не указано"
@property
def delivery_time_window(self):
"""Временное окно доставки"""
return f"{self.delivery_time_start.strftime('%H:%M')} - {self.delivery_time_end.strftime('%H:%M')}"
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="Количество")
order = models.ForeignKey(
Order,
on_delete=models.CASCADE,
related_name='items',
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="Состав комплекта (снапшот)")
# Товар или комплект (один из двух должен быть заполнен)
product = models.ForeignKey(
Product,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='order_items',
verbose_name="Товар"
)
product_kit = models.ForeignKey(
ProductKit,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name='order_items',
verbose_name="Комплект товаров"
)
quantity = models.PositiveIntegerField(
default=1,
verbose_name="Количество"
)
price = models.DecimalField(
max_digits=10,
decimal_places=2,
verbose_name="Цена за единицу",
help_text="Цена на момент создания заказа (фиксируется)"
)
# Временные метки
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата добавления"
)
class Meta:
verbose_name = "Позиция заказа"
verbose_name_plural = "Позиции заказов"
verbose_name_plural = "Позиции заказа"
indexes = [
models.Index(fields=['order']),
models.Index(fields=['product']),
models.Index(fields=['product_kit']),
]
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("Необходимо указать либо товар, либо комплект")
# Заполнение снапшот-полей
def __str__(self):
item_name = ""
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
item_name = self.product.name
elif self.product_kit:
item_name = self.product_kit.name
return f"{item_name} x{self.quantity} в заказе #{self.order.order_number}"
def clean(self):
"""Валидация модели"""
super().clean()
# Проверка: должен быть заполнен либо product, либо product_kit
if not self.product and not self.product_kit:
raise ValidationError(
'Необходимо указать либо товар, либо комплект товаров'
)
# Проверка: не должны быть заполнены оба поля одновременно
if self.product and self.product_kit:
raise ValidationError(
'Нельзя указать одновременно и товар, и комплект'
)
def save(self, *args, **kwargs):
# Автоматически фиксируем цену при создании, если она не указана
if not self.price:
if self.product:
self.price = self.product.price
elif self.product_kit:
self.price = self.product_kit.price
super().save(*args, **kwargs)
def __str__(self):
return f"{self.snapshot_name} x{self.quantity} в заказе #{self.order.id}"
def get_total_price(self):
"""Возвращает общую стоимость позиции"""
return self.price * self.quantity
@property
def item_name(self):
"""Название товара/комплекта"""
if self.product:
return self.product.name
elif self.product_kit:
return self.product_kit.name
return "Не указано"