Files
octopus/myproject/tenants/models.py
Andrey Smakotin 097d4ea304 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>
2025-10-27 19:13:10 +03:00

285 lines
8.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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
)