Рефакторинг: вынос логики онбординга тенанта в сервисный слой
Создан 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:
12
myproject/tenants/services/__init__.py
Normal file
12
myproject/tenants/services/__init__.py
Normal 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',
|
||||
]
|
||||
104
myproject/tenants/services/email.py
Normal file
104
myproject/tenants/services/email.py
Normal 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
|
||||
268
myproject/tenants/services/onboarding.py
Normal file
268
myproject/tenants/services/onboarding.py
Normal 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} финализирована")
|
||||
Reference in New Issue
Block a user