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

284
myproject/tenants/models.py Normal file
View File

@@ -0,0 +1,284 @@
# -*- coding: utf-8 -*-
from django.db import models
from django.conf import settings
from django.core.validators import RegexValidator
from django.utils import timezone
from django_tenants.models import TenantMixin, DomainMixin
from phonenumber_field.modelfields import PhoneNumberField
from datetime import timedelta
# Зарезервированные имена схем, которые нельзя использовать
RESERVED_SCHEMA_NAMES = [
'public', 'admin', 'api', 'www', 'mail', 'ftp', 'smtp',
'static', 'media', 'assets', 'cdn', 'app', 'web',
'billing', 'register', 'login', 'logout', 'dashboard',
'test', 'dev', 'staging', 'production', 'demo'
]
class Client(TenantMixin):
"""
Модель тенанта (владельца магазина).
Каждый тенант = отдельная схема в PostgreSQL с изолированными данными.
"""
name = models.CharField(
max_length=200,
db_index=True,
verbose_name="Название магазина"
)
owner_email = models.EmailField(
verbose_name="Email владельца",
help_text="Контактный email владельца магазина. Один email может использоваться для нескольких магазинов (для супер-админа)"
)
owner_name = models.CharField(
max_length=200,
blank=True,
null=True,
verbose_name="Имя владельца"
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата создания"
)
is_active = models.BooleanField(
default=True,
verbose_name="Активен",
help_text="Активна ли учетная запись магазина (ручная блокировка админом)"
)
# Дополнительные поля для будущего расширения
phone = PhoneNumberField(
blank=True,
null=True,
verbose_name="Телефон"
)
notes = models.TextField(
blank=True,
null=True,
verbose_name="Заметки",
help_text="Внутренние заметки администратора"
)
class Meta:
verbose_name = "Тенант (Магазин)"
verbose_name_plural = "Тенанты (Магазины)"
ordering = ['-created_at']
def __str__(self):
return f"{self.name} ({self.schema_name})"
# auto_create_schema наследуется от TenantMixin
# auto_drop_schema наследуется от TenantMixin
auto_create_schema = True # Автоматически создавать схему при создании тенанта
class Domain(DomainMixin):
"""
Модель домена для тенанта.
Связывает поддомен (например shop1.inventory.by) с тенантом.
"""
class Meta:
verbose_name = "Домен"
verbose_name_plural = "Домены"
def __str__(self):
return self.domain
class TenantRegistration(models.Model):
"""
Модель заявки на регистрацию нового тенанта.
Заявки сначала создаются со статусом 'pending', затем админ их активирует.
"""
STATUS_PENDING = 'pending'
STATUS_APPROVED = 'approved'
STATUS_REJECTED = 'rejected'
STATUS_CHOICES = [
(STATUS_PENDING, 'Ожидает проверки'),
(STATUS_APPROVED, 'Одобрено'),
(STATUS_REJECTED, 'Отклонено'),
]
# Данные от пользователя
shop_name = models.CharField(
max_length=200,
verbose_name="Название магазина"
)
schema_name = models.CharField(
max_length=63,
unique=True,
validators=[
RegexValidator(
regex=r'^[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?$',
message='Поддомен должен содержать только латинские буквы в нижнем регистре, цифры и дефис. '
'Длина от 3 до 63 символов. Не может начинаться или заканчиваться дефисом.'
)
],
verbose_name="Желаемый поддомен",
help_text="Например: myshop (будет доступен как myshop.inventory.by)"
)
owner_email = models.EmailField(
verbose_name="Email владельца"
)
owner_name = models.CharField(
max_length=200,
verbose_name="Имя владельца"
)
phone = PhoneNumberField(
verbose_name="Телефон"
)
# Служебные поля
status = models.CharField(
max_length=20,
choices=STATUS_CHOICES,
default=STATUS_PENDING,
db_index=True,
verbose_name="Статус"
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата подачи заявки"
)
processed_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата обработки"
)
processed_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="Обработал",
help_text="Администратор, который обработал заявку"
)
rejection_reason = models.TextField(
blank=True,
verbose_name="Причина отклонения"
)
# Ссылка на созданный тенант (заполняется после активации)
tenant = models.OneToOneField(
Client,
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name="Созданный тенант"
)
class Meta:
verbose_name = "Заявка на регистрацию"
verbose_name_plural = "Заявки на регистрацию"
ordering = ['-created_at']
def __str__(self):
return f"{self.shop_name} ({self.schema_name}) - {self.get_status_display()}"
class Subscription(models.Model):
"""
Модель подписки тенанта.
Определяет план подписки и срок действия.
"""
PLAN_TRIAL = 'trial'
PLAN_MONTHLY = 'monthly'
PLAN_QUARTERLY = 'quarterly'
PLAN_YEARLY = 'yearly'
PLAN_CHOICES = [
(PLAN_TRIAL, 'Триальный (90 дней)'),
(PLAN_MONTHLY, 'Месячный'),
(PLAN_QUARTERLY, 'Квартальный (3 месяца)'),
(PLAN_YEARLY, 'Годовой'),
]
client = models.OneToOneField(
Client,
on_delete=models.CASCADE,
related_name='subscription',
verbose_name="Тенант"
)
plan = models.CharField(
max_length=20,
choices=PLAN_CHOICES,
default=PLAN_TRIAL,
verbose_name="План подписки"
)
started_at = models.DateTimeField(
verbose_name="Дата начала"
)
expires_at = models.DateTimeField(
verbose_name="Дата окончания"
)
is_active = models.BooleanField(
default=True,
verbose_name="Активна",
help_text="Активна ли подписка (может быть отключена вручную)"
)
auto_renew = models.BooleanField(
default=False,
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 = "Подписки"
ordering = ['-expires_at']
def __str__(self):
return f"{self.client.name} - {self.get_plan_display()} (до {self.expires_at.date()})"
def is_expired(self):
"""Проверка истечения подписки"""
return timezone.now() > self.expires_at
def days_left(self):
"""Количество дней до окончания подписки"""
if self.is_expired():
return 0
delta = self.expires_at - timezone.now()
return delta.days
@staticmethod
def create_trial(client):
"""Создать триальную подписку на 90 дней"""
now = timezone.now()
return Subscription.objects.create(
client=client,
plan=Subscription.PLAN_TRIAL,
started_at=now,
expires_at=now + timedelta(days=90),
is_active=True
)