Files
octopus/myproject/tenants/services/onboarding.py
Andrey Smakotin 0bddbc08c4 feat: Добавлен флаг is_superuser для platform_support и сохранение credentials в файл
- Platform support пользователь теперь создается с is_superuser=True для полного доступа
- Добавлено сохранение credentials (домен:логин:пароль) в support_credentials.txt
- Добавлен support_credentials.txt в .gitignore для безопасности
- Обновлена документация развертывания на NAS

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-09 00:02:10 +03:00

366 lines
16 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 -*-
"""
Сервис онбординга новых тенантов.
Единственный источник истины для создания тенанта и всех связанных сущностей.
"""
import logging
import secrets
from django.db import connection, transaction
from django.utils import timezone
from django.conf import settings
from django.core.management import call_command
from tenants.models import Client, Domain, TenantRegistration, Subscription
logger = logging.getLogger(__name__)
class TenantOnboardingService:
"""
Сервис онбординга нового тенанта.
Отвечает за полный цикл создания тенанта:
- Создание Client (тенант)
- Создание Domain (домен)
- Создание Subscription (подписка)
- Миграции схемы
- Создание владельца (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. Инициализация данных тенанта (в контексте tenant schema)
# CustomUser теперь в TENANT_APPS - создаётся в схеме тенанта
connection.set_tenant(client)
try:
cls._init_tenant_users(registration)
cls._init_tenant_data()
finally:
connection.set_schema_to_public()
# 8. Отправка 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
from products.services import UnitOfMeasureService
# 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}")
# 6. Единицы измерения
logger.info("Создание единиц измерения...")
if reset:
UnitOfMeasureService.reset_default_units()
else:
UnitOfMeasureService.create_default_units()
logger.info("Единицы измерения созданы")
# ==================== Приватные методы ====================
@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):
"""
Создание пользователей тенанта: владелец + техподдержка платформы.
ВАЖНО: CustomUser в TENANT_APPS - создаётся в схеме тенанта.
Вызывается в контексте tenant schema (connection.set_tenant уже выполнен).
Django Admin доступна только для PlatformAdmin (из public schema).
CustomUser используют только фронтенд.
"""
from accounts.models import CustomUser
# 1. Владелец тенанта
if not CustomUser.objects.filter(email=registration.owner_email).exists():
owner = CustomUser.objects.create_user(
email=registration.owner_email,
name=registration.owner_name,
password=None, # Пароль будет установлен через ссылку
)
owner.is_email_confirmed = True
owner.email_confirmed_at = timezone.now()
owner.is_active = False # Неактивен до установки пароля
owner.save()
logger.info(f"Аккаунт владельца создан: {owner.id}")
# Назначаем роль owner
cls._assign_owner_role(owner)
else:
logger.warning(f"Пользователь {registration.owner_email} уже существует")
# 2. Техподдержка платформы (скрытый аккаунт)
cls._create_platform_support_user(registration)
@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 _create_platform_support_user(cls, registration: TenantRegistration):
"""
Создание скрытого аккаунта техподдержки платформы.
- Email берётся из настроек PLATFORM_SUPPORT_EMAIL
- Пароль генерируется уникальный для каждого тенанта
- Пароль выводится в лог (для владельца платформы)
- Пользователь не виден владельцу тенанта
"""
from accounts.models import CustomUser
from user_roles.services import RoleService
from user_roles.models import Role
support_email = getattr(settings, 'PLATFORM_SUPPORT_EMAIL', None)
if not support_email:
logger.info("PLATFORM_SUPPORT_EMAIL не задан - пропускаем создание техподдержки")
return
if CustomUser.objects.filter(email=support_email).exists():
logger.info(f"Техподдержка {support_email} уже существует в этом тенанте")
return
# Генерируем уникальный пароль для этого тенанта
password = secrets.token_urlsafe(16)
support_user = CustomUser.objects.create_user(
email=support_email,
name='Техподдержка',
password=password,
)
support_user.is_email_confirmed = True
support_user.email_confirmed_at = timezone.now()
support_user.is_active = True
support_user.is_superuser = True
support_user.save()
# Назначаем роль platform_support
RoleService.assign_role_to_user(support_user, Role.PLATFORM_SUPPORT, created_by=None)
# Выводим пароль в лог (безопасно, т.к. логи доступны только владельцу платформы)
logger.info(
f"[PLATFORM_SUPPORT] Тенант: {registration.schema_name} | "
f"Email: {support_email} | Пароль: {password}"
)
# Сохраняем credentials в файл
cls._save_support_credentials(registration, support_email, password)
@classmethod
def _save_support_credentials(cls, registration: TenantRegistration, email: str, password: str):
"""
Сохраняет credentials техподдержки в файл.
Формат: домен:логин:пароль
Файл: support_credentials.txt в корне проекта
"""
import os
from pathlib import Path
# Корень проекта (где manage.py)
project_root = Path(settings.BASE_DIR)
credentials_file = project_root / 'support_credentials.txt'
# Формируем домен тенанта
domain_base = settings.TENANT_DOMAIN_BASE
if ':' in domain_base:
domain_base = domain_base.split(':')[0]
tenant_domain = f"{registration.schema_name}.{domain_base}"
# Добавляем строку в файл
try:
with open(credentials_file, 'a', encoding='utf-8') as f:
f.write(f"{tenant_domain}:{email}:{password}\n")
logger.info(f"Credentials сохранены в {credentials_file}")
except Exception as e:
logger.error(f"Ошибка сохранения credentials: {e}")
@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()
# admin_user теперь должен быть PlatformAdmin
if admin_user is not None:
from platform_admin.models import PlatformAdmin
if isinstance(admin_user, PlatformAdmin):
registration.processed_by = admin_user
else:
logger.warning(f"admin_user не является PlatformAdmin: {type(admin_user)}")
registration.processed_by = None
else:
registration.processed_by = None
registration.tenant = client
registration.save()
logger.info(f"Заявка {registration.id} финализирована")