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:
@@ -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 "Не указано"
|
||||
|
||||
Reference in New Issue
Block a user