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:
145
myproject/tenants/management/commands/activate_registration.py
Normal file
145
myproject/tenants/management/commands/activate_registration.py
Normal 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', 'да']
|
||||
Reference in New Issue
Block a user