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

View File

@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
from django.apps import AppConfig
class TenantsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'tenants'
verbose_name = 'Управление тенантами'

115
myproject/tenants/forms.py Normal file
View File

@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
from django import forms
from django.core.exceptions import ValidationError
from .models import TenantRegistration, Client, RESERVED_SCHEMA_NAMES
class TenantRegistrationForm(forms.ModelForm):
"""
Форма регистрации нового тенанта
"""
class Meta:
model = TenantRegistration
fields = ['shop_name', 'schema_name', 'owner_name', 'owner_email', 'phone']
widgets = {
'shop_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Название вашего магазина',
'required': True
}),
'schema_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'myshop',
'required': True,
'pattern': '[a-z0-9][a-z0-9\-]{1,61}[a-z0-9]',
'title': 'Только латинские буквы в нижнем регистре, цифры и дефис (3-63 символа)'
}),
'owner_name': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': 'Иван Иванов',
'required': True
}),
'owner_email': forms.EmailInput(attrs={
'class': 'form-control',
'placeholder': 'your@email.com',
'required': True
}),
'phone': forms.TextInput(attrs={
'class': 'form-control',
'placeholder': '+375291234567',
'required': True
}),
}
labels = {
'shop_name': 'Название магазина',
'schema_name': 'Желаемый поддомен',
'owner_name': 'Ваше имя',
'owner_email': 'Email',
'phone': 'Телефон',
}
help_texts = {
'schema_name': 'Будет использоваться как поддомен: yourshop.inventory.by. '
'Только латинские буквы, цифры и дефис (3-63 символа)',
'owner_email': 'На этот email придет уведомление об активации',
}
def clean_schema_name(self):
"""
Валидация поддомена:
1. Приводим к нижнему регистру
2. Проверяем длину
3. Проверяем, что не зарезервировано
4. Проверяем уникальность
"""
schema_name = self.cleaned_data.get('schema_name', '').lower().strip()
# Проверка длины
if len(schema_name) < 3:
raise ValidationError("Поддомен должен содержать минимум 3 символа.")
if len(schema_name) > 63:
raise ValidationError("Поддомен не может быть длиннее 63 символов.")
# Проверка на зарезервированные имена
if schema_name in RESERVED_SCHEMA_NAMES:
raise ValidationError(f"Поддомен '{schema_name}' зарезервирован системой. Выберите другой.")
# Проверка уникальности в заявках
if TenantRegistration.objects.filter(schema_name=schema_name).exists():
raise ValidationError(f"Поддомен '{schema_name}' уже занят. Выберите другой.")
# Проверка уникальности в существующих тенантах
if Client.objects.filter(schema_name=schema_name).exists():
raise ValidationError(f"Поддомен '{schema_name}' уже занят. Выберите другой.")
return schema_name
def clean_owner_email(self):
"""
Валидация email: проверка на дубликаты для обычных пользователей
(супер-админ может иметь несколько тенантов)
"""
email = self.cleaned_data.get('owner_email', '').lower().strip()
# Проверяем, есть ли активные заявки с таким email
pending_registrations = TenantRegistration.objects.filter(
owner_email=email,
status=TenantRegistration.STATUS_PENDING
).count()
if pending_registrations > 0:
raise ValidationError(
f"У вас уже есть заявка на регистрацию с email {email}, ожидающая проверки. "
"Дождитесь активации или свяжитесь с поддержкой."
)
# Проверяем количество существующих тенантов с таким email
# Если больше 0 - это нормально для админов, но для обычных пользователей показываем предупреждение
existing_tenants_count = Client.objects.filter(owner_email=email).count()
if existing_tenants_count > 0:
# Добавляем предупреждение, но не блокируем (на случай если это супер-админ)
# В реальности здесь можно добавить более сложную логику
pass
return email

View File

View File

@@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
"""
Management команда для ручной активации заявки на регистрацию тенанта.
Использование:
python manage.py activate_registration <schema_name>
Пример:
python manage.py activate_registration mixflowers
"""
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from tenants.models import TenantRegistration, Client, Domain, Subscription
class Command(BaseCommand):
help = 'Активировать заявку на регистрацию тенанта вручную'
def add_arguments(self, parser):
parser.add_argument(
'schema_name',
type=str,
help='Schema name заявки для активации'
)
parser.add_argument(
'--force',
action='store_true',
help='Принудительная активация (пересоздать тенант если уже существует)'
)
def handle(self, *args, **options):
schema_name = options['schema_name']
force = options.get('force', False)
try:
# Ищем заявку
registration = TenantRegistration.objects.get(schema_name=schema_name)
except TenantRegistration.DoesNotExist:
raise CommandError(f'Заявка с schema_name "{schema_name}" не найдена')
self.stdout.write(f'Найдена заявка: {registration.shop_name} ({registration.schema_name})')
self.stdout.write(f'Статус: {registration.get_status_display()}')
self.stdout.write(f'Email: {registration.owner_email}')
self.stdout.write('')
# Проверяем статус
if registration.status == TenantRegistration.STATUS_APPROVED and not force:
self.stdout.write(self.style.WARNING('Эта заявка уже была одобрена!'))
if registration.tenant:
self.stdout.write(f'Связанный тенант: {registration.tenant.name} (ID: {registration.tenant.id})')
else:
self.stdout.write(self.style.WARNING('Но тенант не был создан. Используйте --force для пересоздания'))
if not self._confirm('Продолжить с --force?'):
return
force = True
# Проверяем существование тенанта
existing_client = Client.objects.filter(schema_name=schema_name).first()
if existing_client and not force:
raise CommandError(
f'Тенант с schema_name "{schema_name}" уже существует (ID: {existing_client.id}). '
'Используйте --force для пересоздания (ОПАСНО: удалит все данные!)'
)
# Активируем
self.stdout.write('')
self.stdout.write(self.style.WARNING('НАЧИНАЮ АКТИВАЦИЮ...'))
self.stdout.write('')
try:
with transaction.atomic():
# Если force - удаляем старый тенант
if force and existing_client:
self.stdout.write(self.style.WARNING(f'Удаление существующего тенанта {existing_client.id}...'))
# Удаляем подписку
try:
existing_client.subscription.delete()
except:
pass
# Удаляем домены
Domain.objects.filter(tenant=existing_client).delete()
# Удаляем тенант (это также удалит схему из PostgreSQL)
existing_client.delete()
self.stdout.write(self.style.SUCCESS('Старый тенант удален'))
# Создаем тенант
self.stdout.write(f'Создание тенанта: {registration.schema_name}')
client = Client.objects.create(
schema_name=registration.schema_name,
name=registration.shop_name,
owner_email=registration.owner_email,
owner_name=registration.owner_name,
phone=registration.phone,
is_active=True
)
self.stdout.write(self.style.SUCCESS(f'✓ Тенант создан (ID: {client.id})'))
# Создаем домен
domain_name = f"{registration.schema_name}.localhost"
self.stdout.write(f'Создание домена: {domain_name}')
domain = Domain.objects.create(
domain=domain_name,
tenant=client,
is_primary=True
)
self.stdout.write(self.style.SUCCESS(f'✓ Домен создан (ID: {domain.id})'))
# Создаем триальную подписку
self.stdout.write('Создание триальной подписки на 90 дней')
subscription = Subscription.create_trial(client)
self.stdout.write(self.style.SUCCESS(
f'✓ Подписка создана (ID: {subscription.id}), '
f'истекает: {subscription.expires_at.strftime("%Y-%m-%d")}'
))
# Обновляем заявку
registration.status = TenantRegistration.STATUS_APPROVED
registration.processed_at = timezone.now()
registration.processed_by = None # Активировано через команду
registration.tenant = client
registration.save()
self.stdout.write(self.style.SUCCESS('✓ Заявка обновлена'))
self.stdout.write('')
self.stdout.write(self.style.SUCCESS('=' * 60))
self.stdout.write(self.style.SUCCESS('АКТИВАЦИЯ ЗАВЕРШЕНА УСПЕШНО!'))
self.stdout.write(self.style.SUCCESS('=' * 60))
self.stdout.write('')
self.stdout.write(f'Магазин: {client.name}')
self.stdout.write(f'Schema: {client.schema_name}')
self.stdout.write(f'Домен: http://{domain_name}:8000/')
self.stdout.write(f'Подписка до: {subscription.expires_at.strftime("%Y-%m-%d")} ({subscription.days_left()} дней)')
self.stdout.write('')
except Exception as e:
self.stdout.write(self.style.ERROR(f'ОШИБКА: {str(e)}'))
import traceback
self.stdout.write(traceback.format_exc())
raise CommandError('Активация не удалась')
def _confirm(self, question):
"""Запрос подтверждения у пользователя"""
answer = input(f'{question} (yes/no): ')
return answer.lower() in ['yes', 'y', 'да']

View File

@@ -0,0 +1,158 @@
# -*- coding: utf-8 -*-
"""
Management команда для создания нового тенанта (магазина).
Использование:
python manage.py create_tenant
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from tenants.models import Client, Domain
import re
class Command(BaseCommand):
help = 'Создать нового тенанта (магазин) с собственной схемой БД'
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS('\n=== Создание нового магазина (тенанта) ===\n'))
# Получаем данные от пользователя
name = self.get_shop_name()
schema_name = self.get_schema_name()
domain_name = self.get_domain_name(schema_name)
owner_name = self.get_owner_name()
owner_email = self.get_owner_email()
phone = input('Телефон владельца (опционально): ').strip()
# Подтверждение
self.stdout.write('\n' + '='*60)
self.stdout.write(self.style.WARNING('Проверьте введенные данные:'))
self.stdout.write(f'Название магазина: {name}')
self.stdout.write(f'Схема БД: {schema_name}')
self.stdout.write(f'Домен: {domain_name}')
self.stdout.write(f'Владелец: {owner_name} ({owner_email})')
if phone:
self.stdout.write(f'Телефон: {phone}')
self.stdout.write('='*60 + '\n')
confirm = input('Создать магазин? (yes/no): ').strip().lower()
if confirm not in ['yes', 'y', 'да']:
self.stdout.write(self.style.ERROR('Отменено'))
return
# Создаем тенанта
try:
with transaction.atomic():
# Создаем тенанта
self.stdout.write('Создание тенанта...')
tenant = Client.objects.create(
schema_name=schema_name,
name=name,
owner_name=owner_name,
owner_email=owner_email,
phone=phone if phone else None,
)
self.stdout.write(self.style.SUCCESS(f'✓ Тенант создан: {tenant}'))
# Создаем домен
self.stdout.write(f'Создание домена {domain_name}...')
domain = Domain.objects.create(
domain=domain_name,
tenant=tenant,
is_primary=True
)
self.stdout.write(self.style.SUCCESS(f'✓ Домен создан: {domain}'))
self.stdout.write('\n' + '='*60)
self.stdout.write(self.style.SUCCESS('✓ Магазин успешно создан!'))
self.stdout.write('='*60 + '\n')
self.stdout.write(self.style.WARNING('Следующие шаги:'))
self.stdout.write(f'1. Добавьте в hosts файл: 127.0.0.1 {domain_name}')
self.stdout.write(f'2. Откройте в браузере: http://{domain_name}:8000/admin/')
self.stdout.write(f'3. Схема БД "{schema_name}" создана автоматически')
self.stdout.write(f'4. Все таблицы тенанта созданы в схеме "{schema_name}"')
self.stdout.write('')
except Exception as e:
self.stdout.write(self.style.ERROR(f'\n✗ Ошибка при создании тенанта: {e}'))
raise
def get_shop_name(self):
"""Получить название магазина"""
while True:
name = input('Название магазина: ').strip()
if name:
return name
self.stdout.write(self.style.ERROR('Название не может быть пустым'))
def get_schema_name(self):
"""Получить имя схемы БД"""
while True:
schema = input('Имя схемы БД (латиница, цифры, подчеркивания): ').strip().lower()
# Валидация
if not schema:
self.stdout.write(self.style.ERROR('Имя схемы не может быть пустым'))
continue
if not re.match(r'^[a-z0-9_]+$', schema):
self.stdout.write(self.style.ERROR('Только латинские буквы, цифры и подчеркивания'))
continue
if schema in ['public', 'postgres', 'information_schema', 'pg_catalog']:
self.stdout.write(self.style.ERROR('Это зарезервированное имя схемы'))
continue
# Проверка существования
if Client.objects.filter(schema_name=schema).exists():
self.stdout.write(self.style.ERROR(f'Схема "{schema}" уже существует'))
continue
return schema
def get_domain_name(self, default_subdomain):
"""Получить доменное имя"""
while True:
default_domain = f'{default_subdomain}.localhost'
domain = input(f'Доменное имя [{default_domain}]: ').strip().lower()
if not domain:
domain = default_domain
# Валидация
if not re.match(r'^[a-z0-9.-]+$', domain):
self.stdout.write(self.style.ERROR('Неверный формат домена'))
continue
# Проверка существования
if Domain.objects.filter(domain=domain).exists():
self.stdout.write(self.style.ERROR(f'Домен "{domain}" уже используется'))
continue
return domain
def get_owner_name(self):
"""Получить имя владельца"""
while True:
name = input('Имя владельца: ').strip()
if name:
return name
self.stdout.write(self.style.ERROR('Имя не может быть пустым'))
def get_owner_email(self):
"""Получить email владельца"""
while True:
email = input('Email владельца: ').strip().lower()
# Простая валидация email
if not email:
self.stdout.write(self.style.ERROR('Email не может быть пустым'))
continue
if '@' not in email or '.' not in email:
self.stdout.write(self.style.ERROR('Неверный формат email'))
continue
return email

View File

@@ -0,0 +1,48 @@
# Generated by Django 5.1.4 on 2025-10-26 22:44
import django.db.models.deletion
import django_tenants.postgresql_backend.base
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Client',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('schema_name', models.CharField(db_index=True, max_length=63, unique=True, validators=[django_tenants.postgresql_backend.base._check_schema_name])),
('name', models.CharField(max_length=200, verbose_name='Название магазина')),
('owner_email', models.EmailField(help_text='Контактный email владельца магазина', max_length=254, verbose_name='Email владельца')),
('owner_name', models.CharField(blank=True, max_length=200, null=True, verbose_name='Имя владельца')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('is_active', models.BooleanField(default=True, help_text='Активна ли учетная запись магазина', verbose_name='Активен')),
('phone', models.CharField(blank=True, max_length=20, null=True, verbose_name='Телефон')),
('notes', models.TextField(blank=True, help_text='Внутренние заметки администратора', null=True, verbose_name='Заметки')),
],
options={
'verbose_name': 'Тенант (Магазин)',
'verbose_name_plural': 'Тенанты (Магазины)',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='Domain',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('domain', models.CharField(db_index=True, max_length=253, unique=True)),
('is_primary', models.BooleanField(db_index=True, default=True)),
('tenant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='domains', to='tenants.client')),
],
options={
'verbose_name': 'Домен',
'verbose_name_plural': 'Домены',
},
),
]

View File

@@ -0,0 +1,79 @@
# Generated by Django 5.1.4 on 2025-10-27 09:45
import django.core.validators
import django.db.models.deletion
import phonenumber_field.modelfields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tenants', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='client',
name='is_active',
field=models.BooleanField(default=True, help_text='Активна ли учетная запись магазина (ручная блокировка админом)', verbose_name='Активен'),
),
migrations.AlterField(
model_name='client',
name='name',
field=models.CharField(db_index=True, max_length=200, verbose_name='Название магазина'),
),
migrations.AlterField(
model_name='client',
name='owner_email',
field=models.EmailField(help_text='Контактный email владельца магазина. Один email может использоваться для нескольких магазинов (для супер-админа)', max_length=254, verbose_name='Email владельца'),
),
migrations.AlterField(
model_name='client',
name='phone',
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, verbose_name='Телефон'),
),
migrations.CreateModel(
name='Subscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('plan', models.CharField(choices=[('trial', 'Триальный (90 дней)'), ('monthly', 'Месячный'), ('quarterly', 'Квартальный (3 месяца)'), ('yearly', 'Годовой')], default='trial', max_length=20, verbose_name='План подписки')),
('started_at', models.DateTimeField(verbose_name='Дата начала')),
('expires_at', models.DateTimeField(verbose_name='Дата окончания')),
('is_active', models.BooleanField(default=True, help_text='Активна ли подписка (может быть отключена вручную)', verbose_name='Активна')),
('auto_renew', models.BooleanField(default=False, help_text='Автоматически продлевать подписку', verbose_name='Автопродление')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Дата обновления')),
('client', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='subscription', to='tenants.client', verbose_name='Тенант')),
],
options={
'verbose_name': 'Подписка',
'verbose_name_plural': 'Подписки',
'ordering': ['-expires_at'],
},
),
migrations.CreateModel(
name='TenantRegistration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('shop_name', models.CharField(max_length=200, verbose_name='Название магазина')),
('schema_name', models.CharField(help_text='Например: myshop (будет доступен как myshop.inventory.by)', max_length=63, unique=True, validators=[django.core.validators.RegexValidator(message='Поддомен должен содержать только латинские буквы в нижнем регистре, цифры и дефис. Длина от 3 до 63 символов. Не может начинаться или заканчиваться дефисом.', regex='^[a-z0-9](?:[a-z0-9\\-]{0,61}[a-z0-9])?$')], verbose_name='Желаемый поддомен')),
('owner_email', models.EmailField(max_length=254, verbose_name='Email владельца')),
('owner_name', models.CharField(max_length=200, verbose_name='Имя владельца')),
('phone', phonenumber_field.modelfields.PhoneNumberField(max_length=128, region=None, verbose_name='Телефон')),
('status', models.CharField(choices=[('pending', 'Ожидает проверки'), ('approved', 'Одобрено'), ('rejected', 'Отклонено')], db_index=True, default='pending', max_length=20, verbose_name='Статус')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата подачи заявки')),
('processed_at', models.DateTimeField(blank=True, null=True, verbose_name='Дата обработки')),
('rejection_reason', models.TextField(blank=True, verbose_name='Причина отклонения')),
('processed_by', models.ForeignKey(blank=True, help_text='Администратор, который обработал заявку', null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Обработал')),
('tenant', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='tenants.client', verbose_name='Созданный тенант')),
],
options={
'verbose_name': 'Заявка на регистрацию',
'verbose_name_plural': 'Заявки на регистрацию',
'ordering': ['-created_at'],
},
),
]

View File

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
)

View File

@@ -0,0 +1,70 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Регистрация магазина{% endblock %} - Inventory System</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px 0;
}
.card {
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
border: none;
border-radius: 15px;
}
.card-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 15px 15px 0 0 !important;
padding: 1.5rem;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
}
.btn-primary:hover {
background: linear-gradient(135deg, #764ba2 0%, #667eea 100%);
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3);
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.alert {
border-radius: 10px;
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% block content %}{% endblock %}
</div>
</div>
</div>
<!-- Bootstrap 5 JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,133 @@
{% extends "tenants/base.html" %}
{% block title %}Регистрация нового магазина{% endblock %}
{% block content %}
<div class="card">
<div class="card-header text-center">
<h3 class="mb-0">Регистрация нового магазина</h3>
<p class="mb-0 mt-2">Заполните форму для создания вашего интернет-магазина</p>
</div>
<div class="card-body p-4">
<form method="post" novalidate>
{% csrf_token %}
<!-- Название магазина -->
<div class="mb-3">
<label for="{{ form.shop_name.id_for_label }}" class="form-label">
{{ form.shop_name.label }}
<span class="text-danger">*</span>
</label>
{{ form.shop_name }}
{% if form.shop_name.errors %}
<div class="text-danger small mt-1">
{{ form.shop_name.errors.0 }}
</div>
{% endif %}
{% if form.shop_name.help_text %}
<div class="form-text">{{ form.shop_name.help_text }}</div>
{% endif %}
</div>
<!-- Поддомен -->
<div class="mb-3">
<label for="{{ form.schema_name.id_for_label }}" class="form-label">
{{ form.schema_name.label }}
<span class="text-danger">*</span>
</label>
<div class="input-group">
{{ form.schema_name }}
<span class="input-group-text">.inventory.by</span>
</div>
{% if form.schema_name.errors %}
<div class="text-danger small mt-1">
{{ form.schema_name.errors.0 }}
</div>
{% endif %}
{% if form.schema_name.help_text %}
<div class="form-text">{{ form.schema_name.help_text }}</div>
{% endif %}
</div>
<!-- Имя владельца -->
<div class="mb-3">
<label for="{{ form.owner_name.id_for_label }}" class="form-label">
{{ form.owner_name.label }}
<span class="text-danger">*</span>
</label>
{{ form.owner_name }}
{% if form.owner_name.errors %}
<div class="text-danger small mt-1">
{{ form.owner_name.errors.0 }}
</div>
{% endif %}
</div>
<!-- Email -->
<div class="mb-3">
<label for="{{ form.owner_email.id_for_label }}" class="form-label">
{{ form.owner_email.label }}
<span class="text-danger">*</span>
</label>
{{ form.owner_email }}
{% if form.owner_email.errors %}
<div class="text-danger small mt-1">
{{ form.owner_email.errors.0 }}
</div>
{% endif %}
{% if form.owner_email.help_text %}
<div class="form-text">{{ form.owner_email.help_text }}</div>
{% endif %}
</div>
<!-- Телефон -->
<div class="mb-4">
<label for="{{ form.phone.id_for_label }}" class="form-label">
{{ form.phone.label }}
<span class="text-danger">*</span>
</label>
{{ form.phone }}
{% if form.phone.errors %}
<div class="text-danger small mt-1">
{{ form.phone.errors.0 }}
</div>
{% endif %}
</div>
<!-- Кнопка отправки -->
<div class="d-grid">
<button type="submit" class="btn btn-primary btn-lg">
Отправить заявку
</button>
</div>
<div class="text-center mt-3">
<small class="text-muted">
После отправки заявки ваш магазин будет проверен администратором.<br>
Уведомление придет на указанный email в течение 24 часов.
</small>
</div>
</form>
</div>
</div>
<div class="text-center mt-3">
<a href="/admin/" class="text-white text-decoration-none">
<small>Войти в панель администратора</small>
</a>
</div>
{% endblock %}
{% block extra_js %}
<script>
// Автоматическое преобразование поддомена в lowercase
document.addEventListener('DOMContentLoaded', function() {
const schemaNameInput = document.getElementById('{{ form.schema_name.id_for_label }}');
if (schemaNameInput) {
schemaNameInput.addEventListener('input', function() {
this.value = this.value.toLowerCase();
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,50 @@
{% extends "tenants/base.html" %}
{% block title %}Заявка отправлена{% endblock %}
{% block content %}
<div class="card">
<div class="card-header text-center">
<h3 class="mb-0">Спасибо за регистрацию!</h3>
</div>
<div class="card-body p-5 text-center">
<div class="mb-4">
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" fill="currentColor" class="bi bi-check-circle text-success" viewBox="0 0 16 16">
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14zm0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16z"/>
<path d="M10.97 4.97a.235.235 0 0 0-.02.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-1.071-1.05z"/>
</svg>
</div>
<h4 class="mb-3">Ваша заявка успешно отправлена!</h4>
<p class="lead mb-4">
Мы получили вашу заявку на создание магазина.<br>
Наш администратор проверит данные и активирует ваш магазин.
</p>
<div class="alert alert-info" role="alert">
<h5 class="alert-heading">Что дальше?</h5>
<hr>
<ul class="list-unstyled text-start mb-0">
<li class="mb-2">В течение 24 часов администратор проверит вашу заявку</li>
<li class="mb-2">✓ После активации вы получите письмо на указанный email</li>
<li class="mb-2">В письме будет ссылка на ваш магазин и инструкции</li>
<li>✓ Вам будет предоставлен триальный период на 90 дней</li>
</ul>
</div>
<div class="mt-4">
<a href="{% url 'tenants:register' %}" class="btn btn-outline-primary">
Подать еще одну заявку
</a>
</div>
<div class="mt-4">
<small class="text-muted">
Если у вас возникли вопросы, свяжитесь с нами:<br>
<a href="mailto:support@inventory.by">support@inventory.by</a>
</small>
</div>
</div>
</div>
{% endblock %}

10
myproject/tenants/urls.py Normal file
View File

@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
from django.urls import path
from .views import TenantRegistrationView, RegistrationSuccessView
app_name = 'tenants'
urlpatterns = [
path('register/', TenantRegistrationView.as_view(), name='register'),
path('register/success/', RegistrationSuccessView.as_view(), name='registration_success'),
]

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
from django.shortcuts import render, redirect
from django.views.generic import CreateView, TemplateView
from django.urls import reverse_lazy
from django.contrib import messages
from .forms import TenantRegistrationForm
from .models import TenantRegistration
class TenantRegistrationView(CreateView):
"""
Публичная форма регистрации нового тенанта
"""
model = TenantRegistration
form_class = TenantRegistrationForm
template_name = 'tenants/registration_form.html'
success_url = reverse_lazy('tenants:registration_success')
def form_valid(self, form):
"""
Обработка успешной отправки формы
"""
response = super().form_valid(form)
# Добавляем сообщение об успехе
messages.success(
self.request,
f"Спасибо за регистрацию! Ваша заявка на магазин '{self.object.shop_name}' "
f"отправлена на проверку. Мы свяжемся с вами по email {self.object.owner_email} "
f"в ближайшее время."
)
return response
def form_invalid(self, form):
"""
Обработка ошибок валидации
"""
messages.error(
self.request,
"Пожалуйста, исправьте ошибки в форме."
)
return super().form_invalid(form)
class RegistrationSuccessView(TemplateView):
"""
Страница благодарности после успешной регистрации
"""
template_name = 'tenants/registration_success.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Получаем последнюю заявку текущего пользователя (если есть)
# Здесь можно добавить фильтрацию по сессии или email
context['show_details'] = False
return context