Рефакторинг: вынос логики онбординга тенанта в сервисный слой

Создан TenantOnboardingService как единый источник истины для:
- Активации заявки на регистрацию тенанта
- Создания Client, Domain, Subscription
- Инициализации системных данных (Customer, статусы, способы оплаты, склад, витрина)

Новые сервисы:
- TenantOnboardingService (tenants/services/onboarding.py)
- WarehouseService (inventory/services/warehouse_service.py)
- ShowcaseService (inventory/services/showcase_service.py)
- PaymentMethodService (orders/services/payment_method_service.py)

Рефакторинг:
- admin.py: 220 строк → 5 строк (делегирование сервису)
- init_tenant_data.py: 259 строк → 68 строк
- activate_registration.py: использует сервис
- Тесты обновлены для вызова сервиса напрямую

При создании тенанта автоматически создаются склад и витрина по умолчанию.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 14:52:55 +03:00
parent 658cd59511
commit b59ad725cb
13 changed files with 679 additions and 477 deletions

View File

@@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
"""
Сервисы для работы с тенантами.
"""
from .onboarding import TenantOnboardingService
from .email import send_password_setup_email
__all__ = [
'TenantOnboardingService',
'send_password_setup_email',
]

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
"""
Сервис отправки email для тенантов.
"""
import uuid
from django.utils import timezone
from django.conf import settings
def send_password_setup_email(registration):
"""
Отправить email с ссылкой для установки пароля одобренному владельцу тенанта.
Args:
registration: экземпляр TenantRegistration
Returns:
bool: True если письмо успешно "отправлено" (выведено в консоль)
"""
# Генерировать токен
registration.password_setup_token = uuid.uuid4()
registration.password_setup_token_created_at = timezone.now()
# Построить URL динамически в зависимости от окружения
# Локально: http://localhost:8000
# Продакшен: https://mix.smaa.by
protocol = 'https' if settings.USE_HTTPS else 'http'
domain_base = settings.TENANT_DOMAIN_BASE # localhost:8000 или mix.smaa.by
# URL для установки пароля (главный домен)
setup_url = f"{protocol}://{domain_base}/accounts/setup-password/{registration.password_setup_token}/"
# URL тенанта (поддомен)
tenant_url = f"{protocol}://{registration.schema_name}.{domain_base}/"
# Составить письмо
subject = f"Ваш магазин {registration.shop_name} активирован!"
message = f"""
╔══════════════════════════════════════════════════════════════════╗
║ ВАША ЗАЯВКА НА РЕГИСТРАЦИЮ МАГАЗИНА ОДОБРЕНА! ║
╚══════════════════════════════════════════════════════════════════╝
Здравствуйте, {registration.owner_name}!
Отличные новости! Ваша заявка на регистрацию магазина "{registration.shop_name}"
была одобрена администратором.
╔══════════════════════════════════════════════════════════════════╗
║ ДАННЫЕ ДЛЯ ВХОДА ║
╚══════════════════════════════════════════════════════════════════╝
📧 Email для входа: {registration.owner_email}
🏪 Ваш магазин доступен по адресу: {tenant_url}
╔══════════════════════════════════════════════════════════════════╗
║ УСТАНОВИТЕ ПАРОЛЬ (действительна 7 дней) ║
╚══════════════════════════════════════════════════════════════════╝
Для завершения настройки аккаунта, пожалуйста, установите пароль,
перейдя по следующей ссылке:
{setup_url}
⏰ Ссылка действительна в течение 7 дней.
╔══════════════════════════════════════════════════════════════════╗
║ ЧТО ДЕЛАТЬ ДАЛЬШЕ? ║
╚══════════════════════════════════════════════════════════════════╝
1. Нажмите на ссылку выше
2. Придумайте надежный пароль
3. Войдите в свой магазин и начните работу!
Если у вас возникли вопросы, свяжитесь с нами:
📧 support@inventory.by
📞 +375 29 123-45-67
С уважением,
Команда Inventory System
---
Если вы не подавали заявку на регистрацию, проигнорируйте это письмо.
"""
from_email = settings.DEFAULT_FROM_EMAIL
recipient_list = [registration.owner_email]
# Вывести в консоль с красивым форматированием
print("\n" + "="*70)
print("📧 ОТПРАВКА EMAIL (Console Backend)")
print("="*70)
print(f"Тема: {subject}")
print(f"От: {from_email}")
print(f"Кому: {recipient_list[0]}")
print("-"*70)
print(message)
print("="*70 + "\n")
# Обновить registration
registration.owner_notified_at = timezone.now()
registration.save()
return True

View File

@@ -0,0 +1,268 @@
# -*- coding: utf-8 -*-
"""
Сервис онбординга новых тенантов.
Единственный источник истины для создания тенанта и всех связанных сущностей.
"""
import logging
from django.db import connection, transaction
from django.utils import timezone
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.management import call_command
from tenants.models import Client, Domain, TenantRegistration, Subscription
logger = logging.getLogger(__name__)
class TenantOnboardingService:
"""
Сервис онбординга нового тенанта.
Отвечает за полный цикл создания тенанта:
- Создание Client (тенант)
- Создание Domain (домен)
- Создание Subscription (подписка)
- Миграции схемы
- Создание пользователей (admin, owner)
- Инициализация системных данных
"""
@classmethod
@transaction.atomic
def activate_registration(cls, registration: TenantRegistration, admin_user=None) -> Client:
"""
Полная активация заявки на регистрацию тенанта.
Args:
registration: Заявка на регистрацию
admin_user: Администратор, одобривший заявку (опционально)
Returns:
Client: Созданный тенант
Raises:
ValueError: Если тенант уже существует
"""
logger.info(f"Начало активации заявки: {registration.schema_name}")
# 1. Проверка уникальности
if Client.objects.filter(schema_name=registration.schema_name).exists():
raise ValueError(f"Тенант с schema_name '{registration.schema_name}' уже существует!")
# 2. Создание тенанта
client = cls._create_client(registration)
# 3. Создание домена
domain = cls._create_domain(client, registration)
# 4. Применение миграций
cls._run_migrations(client)
# 5. Создание подписки
subscription = cls._create_subscription(client)
# 6. Инициализация данных тенанта (в контексте схемы)
connection.set_tenant(client)
try:
cls._init_tenant_users(registration)
cls._init_tenant_data()
finally:
connection.set_schema_to_public()
# 7. Отправка email владельцу
cls._send_welcome_email(registration)
# 8. Обновление статуса заявки
cls._finalize_registration(registration, client, admin_user)
logger.info(f"Заявка {registration.id} успешно активирована. Тенант: {client.id}")
return client
@classmethod
def init_tenant_data(cls, reset: bool = False):
"""
Инициализация системных данных тенанта.
Вызывается в контексте схемы тенанта (connection.set_tenant уже выполнен).
Args:
reset: Если True, удаляет и пересоздаёт данные
"""
from customers.models import Customer
from orders.services import OrderStatusService, PaymentMethodService
from inventory.services import WarehouseService, ShowcaseService
# 1. Системный клиент
logger.info("Создание системного клиента...")
if reset:
Customer.objects.filter(is_system_customer=True).delete()
customer, created = Customer.get_or_create_system_customer()
logger.info(f"Системный клиент: {'создан' if created else 'уже существует'}")
# 2. Статусы заказов
logger.info("Создание статусов заказов...")
if reset:
from orders.models import OrderStatus
OrderStatus.objects.filter(is_system=True).delete()
OrderStatusService.create_default_statuses()
logger.info("Статусы заказов созданы")
# 3. Способы оплаты
logger.info("Создание способов оплаты...")
if reset:
PaymentMethodService.reset_default_methods()
else:
PaymentMethodService.create_default_methods()
logger.info("Способы оплаты созданы")
# 4. Склад по умолчанию
logger.info("Создание склада по умолчанию...")
if reset:
warehouse = WarehouseService.reset_default()
else:
warehouse, created = WarehouseService.get_or_create_default()
logger.info(f"Склад по умолчанию: {warehouse.name}")
# 5. Витрина по умолчанию
logger.info("Создание витрины по умолчанию...")
if reset:
showcase = ShowcaseService.reset_default(warehouse)
else:
showcase, created = ShowcaseService.get_or_create_default(warehouse)
logger.info(f"Витрина по умолчанию: {showcase.name}")
# ==================== Приватные методы ====================
@classmethod
def _create_client(cls, registration: TenantRegistration) -> Client:
"""Создание тенанта (Client)."""
logger.info(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
)
logger.info(f"Тенант создан: {client.id}")
return client
@classmethod
def _create_domain(cls, client: Client, registration: TenantRegistration) -> Domain:
"""Создание домена для тенанта."""
domain_base = settings.TENANT_DOMAIN_BASE
# Убираем порт из домена (django-tenants ищет по hostname без порта)
if ':' in domain_base:
domain_base = domain_base.split(':')[0]
domain_name = f"{registration.schema_name}.{domain_base}"
logger.info(f"Создание домена: {domain_name}")
domain = Domain.objects.create(
domain=domain_name,
tenant=client,
is_primary=True
)
logger.info(f"Домен создан: {domain.id}")
return domain
@classmethod
def _run_migrations(cls, client: Client):
"""Применение миграций для схемы тенанта."""
logger.info(f"Применение миграций для тенанта: {client.schema_name}")
try:
call_command('migrate_schemas', schema_name=client.schema_name, verbosity=1)
logger.info("Миграции успешно применены")
except Exception as e:
logger.error(f"Ошибка при применении миграций: {e}", exc_info=True)
raise
@classmethod
def _create_subscription(cls, client: Client) -> Subscription:
"""Создание триальной подписки."""
logger.info(f"Создание триальной подписки для тенанта: {client.id}")
subscription = Subscription.create_trial(client)
logger.info(f"Подписка создана: {subscription.id}, истекает: {subscription.expires_at}")
return subscription
@classmethod
def _init_tenant_users(cls, registration: TenantRegistration):
"""Создание пользователей тенанта (admin и owner)."""
User = get_user_model()
# Суперпользователь (системный admin)
if not User.objects.filter(email=settings.TENANT_ADMIN_EMAIL).exists():
superuser = User.objects.create_superuser(
email=settings.TENANT_ADMIN_EMAIL,
name=settings.TENANT_ADMIN_NAME,
password=settings.TENANT_ADMIN_PASSWORD
)
logger.info(f"Суперпользователь создан: {superuser.id}")
else:
logger.warning(f"Пользователь {settings.TENANT_ADMIN_EMAIL} уже существует")
# Владелец тенанта
if not User.objects.filter(email=registration.owner_email).exists():
owner = User.objects.create_user(
email=registration.owner_email,
name=registration.owner_name,
password=None, # Пароль будет установлен через ссылку
is_staff=False,
is_superuser=False
)
owner.is_email_confirmed = True
owner.email_confirmed_at = timezone.now()
owner.is_active = False # Неактивен до установки пароля
owner.save()
logger.info(f"Аккаунт владельца создан: {owner.id}")
# Роли
cls._assign_owner_role(owner)
else:
logger.warning(f"Пользователь {registration.owner_email} уже существует")
@classmethod
def _assign_owner_role(cls, owner):
"""Назначение роли owner владельцу тенанта."""
try:
from user_roles.services import RoleService
from user_roles.models import Role
RoleService.create_default_roles()
RoleService.assign_role_to_user(owner, Role.OWNER, created_by=None)
logger.info(f"Роль owner назначена: {owner.email}")
except Exception as e:
logger.error(f"Ошибка при назначении роли: {e}", exc_info=True)
@classmethod
def _init_tenant_data(cls):
"""Инициализация системных данных тенанта."""
try:
cls.init_tenant_data(reset=False)
except Exception as e:
logger.error(f"Ошибка при инициализации данных: {e}", exc_info=True)
# Не прерываем процесс
@classmethod
def _send_welcome_email(cls, registration: TenantRegistration):
"""Отправка приветственного письма владельцу."""
try:
from tenants.services.email import send_password_setup_email
send_password_setup_email(registration)
logger.info(f"Письмо отправлено: {registration.owner_email}")
except Exception as e:
logger.error(f"Ошибка при отправке письма: {e}", exc_info=True)
# Не прерываем процесс
@classmethod
def _finalize_registration(cls, registration: TenantRegistration, client: Client, admin_user):
"""Финализация заявки на регистрацию."""
registration.status = TenantRegistration.STATUS_APPROVED
registration.processed_at = timezone.now()
registration.processed_by = admin_user
registration.tenant = client
registration.save()
logger.info(f"Заявка {registration.id} финализирована")