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

Создан 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

@@ -6,10 +6,14 @@ from .batch_manager import StockBatchManager
from .sale_processor import SaleProcessor from .sale_processor import SaleProcessor
from .inventory_processor import InventoryProcessor from .inventory_processor import InventoryProcessor
from .showcase_manager import ShowcaseManager from .showcase_manager import ShowcaseManager
from .warehouse_service import WarehouseService
from .showcase_service import ShowcaseService
__all__ = [ __all__ = [
'StockBatchManager', 'StockBatchManager',
'SaleProcessor', 'SaleProcessor',
'InventoryProcessor', 'InventoryProcessor',
'ShowcaseManager', 'ShowcaseManager',
'WarehouseService',
'ShowcaseService',
] ]

View File

@@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""
Сервис для работы с витринами.
"""
from inventory.models import Showcase, Warehouse
from inventory.services.warehouse_service import WarehouseService
class ShowcaseService:
"""Сервис управления витринами"""
# Константы для витрины по умолчанию
DEFAULT_NAME = 'Основная витрина'
DEFAULT_DESCRIPTION = 'Витрина по умолчанию, созданная автоматически при регистрации'
@classmethod
def get_default(cls) -> Showcase | None:
"""
Получить витрину по умолчанию.
Returns:
Showcase или None если не найдена
"""
return Showcase.objects.filter(is_default=True, is_active=True).first()
@classmethod
def get_or_create_default(cls, warehouse: Warehouse = None) -> tuple[Showcase, bool]:
"""
Получить или создать витрину по умолчанию.
Args:
warehouse: Склад для витрины. Если не указан, используется склад по умолчанию.
Returns:
tuple (Showcase, created: bool)
Raises:
ValueError: если склад не найден и не может быть создан
"""
if warehouse is None:
warehouse, _ = WarehouseService.get_or_create_default()
return Showcase.objects.get_or_create(
warehouse=warehouse,
is_default=True,
defaults={
'name': cls.DEFAULT_NAME,
'description': cls.DEFAULT_DESCRIPTION,
'is_active': True,
}
)
@classmethod
def reset_default(cls, warehouse: Warehouse = None) -> Showcase:
"""
Удалить и пересоздать витрину по умолчанию.
Используется при --reset в init_tenant_data.
Args:
warehouse: Склад для витрины
Returns:
Новый Showcase
"""
if warehouse is None:
warehouse, _ = WarehouseService.get_or_create_default()
Showcase.objects.filter(warehouse=warehouse, is_default=True).delete()
showcase, _ = cls.get_or_create_default(warehouse)
return showcase

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
"""
Сервис для работы со складами.
"""
from inventory.models import Warehouse
class WarehouseService:
"""Сервис управления складами"""
# Константы для склада по умолчанию
DEFAULT_NAME = 'Основной склад'
DEFAULT_DESCRIPTION = 'Склад по умолчанию, созданный автоматически при регистрации'
@classmethod
def get_default(cls) -> Warehouse | None:
"""
Получить склад по умолчанию.
Returns:
Warehouse или None если не найден
"""
return Warehouse.objects.filter(is_default=True, is_active=True).first()
@classmethod
def get_or_create_default(cls) -> tuple[Warehouse, bool]:
"""
Получить или создать склад по умолчанию.
Returns:
tuple (Warehouse, created: bool)
"""
return Warehouse.objects.get_or_create(
is_default=True,
defaults={
'name': cls.DEFAULT_NAME,
'description': cls.DEFAULT_DESCRIPTION,
'is_active': True,
'is_pickup_point': True,
}
)
@classmethod
def reset_default(cls) -> Warehouse:
"""
Удалить и пересоздать склад по умолчанию.
Используется при --reset в init_tenant_data.
Returns:
Новый Warehouse
"""
Warehouse.objects.filter(is_default=True).delete()
warehouse, _ = cls.get_or_create_default()
return warehouse

View File

@@ -1,56 +1,19 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""
Management команда для создания стандартных способов оплаты.
"""
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from orders.models import PaymentMethod from orders.services import PaymentMethodService
class Command(BaseCommand): class Command(BaseCommand):
help = 'Создаёт стандартные способы оплаты для цветочного магазина' help = 'Создаёт стандартные способы оплаты для цветочного магазина'
def handle(self, *args, **options): def handle(self, *args, **options):
payment_methods = [ results = PaymentMethodService.create_default_methods()
{
'code': 'account_balance',
'name': 'С баланса счёта',
'description': 'Оплата из кошелька клиента',
'is_system': True,
'order': 0
},
{
'code': 'cash',
'name': 'Наличными',
'description': 'Оплата наличными деньгами',
'is_system': True,
'order': 1
},
{
'code': 'card',
'name': 'Картой',
'description': 'Оплата банковской картой',
'is_system': True,
'order': 2
},
{
'code': 'online',
'name': 'Онлайн',
'description': 'Онлайн оплата через платежную систему',
'is_system': True,
'order': 3
},
{
'code': 'legal_entity',
'name': 'Безнал от ЮРЛИЦ',
'description': 'Безналичный расчёт от юридических лиц',
'is_system': True,
'order': 4
},
]
created_count = 0 created_count = 0
for method_data in payment_methods: for method, created in results:
method, created = PaymentMethod.objects.get_or_create(
code=method_data['code'],
defaults=method_data
)
if created: if created:
created_count += 1 created_count += 1
self.stdout.write( self.stdout.write(

View File

@@ -2,4 +2,10 @@
Сервисный слой для приложения orders. Сервисный слой для приложения orders.
""" """
__all__ = [] from .order_status_service import OrderStatusService
from .payment_method_service import PaymentMethodService
__all__ = [
'OrderStatusService',
'PaymentMethodService',
]

View File

@@ -0,0 +1,90 @@
# -*- coding: utf-8 -*-
"""
Сервис для работы со способами оплаты.
"""
from orders.models import PaymentMethod
class PaymentMethodService:
"""Сервис управления способами оплаты"""
# Системные способы оплаты
DEFAULT_METHODS = [
{
'code': 'account_balance',
'name': 'С баланса счёта',
'description': 'Оплата из кошелька клиента',
'is_system': True,
'order': 0
},
{
'code': 'cash',
'name': 'Наличными',
'description': 'Оплата наличными деньгами',
'is_system': True,
'order': 1
},
{
'code': 'card',
'name': 'Картой',
'description': 'Оплата банковской картой',
'is_system': True,
'order': 2
},
{
'code': 'online',
'name': 'Онлайн',
'description': 'Онлайн оплата через платежную систему',
'is_system': True,
'order': 3
},
{
'code': 'legal_entity',
'name': 'Безнал от ЮРЛИЦ',
'description': 'Безналичный расчёт от юридических лиц',
'is_system': True,
'order': 4
},
]
@classmethod
def get_by_code(cls, code: str) -> PaymentMethod | None:
"""
Получить способ оплаты по коду.
Args:
code: Код способа оплаты
Returns:
PaymentMethod или None
"""
return PaymentMethod.objects.filter(code=code).first()
@classmethod
def create_default_methods(cls) -> list[tuple[PaymentMethod, bool]]:
"""
Создать системные способы оплаты.
Returns:
Список кортежей (PaymentMethod, created)
"""
results = []
for method_data in cls.DEFAULT_METHODS:
method, created = PaymentMethod.objects.get_or_create(
code=method_data['code'],
defaults=method_data
)
results.append((method, created))
return results
@classmethod
def reset_default_methods(cls) -> list[PaymentMethod]:
"""
Удалить и пересоздать системные способы оплаты.
Returns:
Список созданных PaymentMethod
"""
PaymentMethod.objects.filter(is_system=True).delete()
results = cls.create_default_methods()
return [method for method, _ in results]

View File

@@ -4,7 +4,6 @@ from django.contrib import messages
from django.utils import timezone from django.utils import timezone
from django.utils.html import format_html from django.utils.html import format_html
from django.urls import reverse from django.urls import reverse
from django.db import transaction
from .models import Client, Domain, TenantRegistration, Subscription from .models import Client, Domain, TenantRegistration, Subscription
@@ -263,188 +262,12 @@ class TenantRegistrationAdmin(admin.ModelAdmin):
return super().changeform_view(request, object_id, form_url, extra_context) return super().changeform_view(request, object_id, form_url, extra_context)
@transaction.atomic
def _approve_registration(self, registration, admin_user): def _approve_registration(self, registration, admin_user):
""" """
Активация заявки: создание тенанта, домена и триальной подписки Активация заявки: делегирует всю работу TenantOnboardingService.
""" """
import logging from tenants.services import TenantOnboardingService
logger = logging.getLogger(__name__) return TenantOnboardingService.activate_registration(registration, admin_user)
try:
# Проверяем, не создан ли уже тенант
if Client.objects.filter(schema_name=registration.schema_name).exists():
raise ValueError(f"Тенант с schema_name '{registration.schema_name}' уже существует!")
# Создаем тенант
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}")
# Создаем домен (динамически определяется из настроек)
# Локально: schema_name.localhost (без порта!)
# Продакшен: schema_name.mix.smaa.by
from django.conf import settings
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}")
# Применяем миграции для нового тенанта
logger.info(f"Применение миграций для тенанта: {registration.schema_name}")
from django.core.management import call_command
try:
call_command('migrate_schemas', schema_name=registration.schema_name, verbosity=1)
logger.info(f"Миграции успешно применены для тенанта: {registration.schema_name}")
except Exception as e:
logger.error(f"Ошибка при применении миграций: {str(e)}", exc_info=True)
raise
# Создаем триальную подписку на 90 дней
logger.info(f"Создание триальной подписки для тенанта: {client.id}")
subscription = Subscription.create_trial(client)
logger.info(f"Подписка создана: {subscription.id}, истекает: {subscription.expires_at}")
# Автоматически создаем суперпользователя для тенанта
logger.info(f"Создание суперпользователя для тенанта: {client.id}")
from django.db import connection
from django.contrib.auth import get_user_model
from django.conf import settings
# Переключаемся на схему тенанта
connection.set_tenant(client)
User = get_user_model()
# Проверяем, не существует ли уже пользователь с таким email
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} ({superuser.email})")
else:
logger.warning(f"Пользователь с email {settings.TENANT_ADMIN_EMAIL} уже существует в тенанте")
# Создаем аккаунт владельца тенанта
logger.info(f"Создание аккаунта владельца для тенанта: {client.id}")
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, # SECURITY: Владелец НЕ может входить в админку
is_superuser=False # SECURITY: Владелец НЕ суперпользователь
)
# Помечаем email как подтвержденный, так как владелец регистрировался с ним
owner.is_email_confirmed = True
owner.email_confirmed_at = timezone.now()
owner.is_active = False # Неактивен до установки пароля
owner.save()
logger.info(f"Аккаунт владельца создан: {owner.id} ({owner.email})")
# Создаем системные роли пользователей (ПЕРЕД назначением роли владельцу)
logger.info(f"Создание системных ролей для тенанта: {client.id}")
from user_roles.services import RoleService
try:
RoleService.create_default_roles()
logger.info("Системные роли успешно созданы")
except Exception as e:
logger.error(f"Ошибка при создании ролей: {e}", exc_info=True)
# Не прерываем процесс, т.к. это не критично
# Назначаем роль owner владельцу
try:
from user_roles.models import Role
RoleService.assign_role_to_user(owner, Role.OWNER, created_by=None)
logger.info(f"Роль owner назначена владельцу {owner.email}")
except Exception as e:
logger.error(f"Ошибка при назначении роли owner: {e}", exc_info=True)
# Не прерываем процесс
else:
logger.warning(f"Пользователь с email {registration.owner_email} уже существует в тенанте")
# Создаем системного клиента для анонимных продаж
logger.info(f"Создание системного клиента для тенанта: {client.id}")
from customers.models import Customer
try:
system_customer, created = Customer.get_or_create_system_customer()
if created:
logger.info(f"Системный клиент создан: {system_customer.id} ({system_customer.name})")
else:
logger.info(f"Системный клиент уже существует: {system_customer.id} ({system_customer.name})")
except Exception as e:
logger.error(f"Ошибка при создании системного клиента: {e}", exc_info=True)
# Не прерываем процесс, т.к. это не критично
# Создаем системные статусы заказов
logger.info(f"Создание системных статусов заказов для тенанта: {client.id}")
from orders.services.order_status_service import OrderStatusService
try:
OrderStatusService.create_default_statuses()
logger.info("Системные статусы заказов успешно созданы")
except Exception as e:
logger.error(f"Ошибка при создании статусов заказов: {e}", exc_info=True)
# Не прерываем процесс, т.к. это не критично
# Создаем системные способы оплаты
logger.info(f"Создание системных способов оплаты для тенанта: {client.id}")
from django.core.management import call_command
try:
# Вызываем команду создания способов оплаты
# Это единственный источник истины для списка способов оплаты
call_command('create_payment_methods')
logger.info("Системные способы оплаты успешно созданы")
except Exception as e:
logger.error(f"Ошибка при создании способов оплаты: {e}", exc_info=True)
# Не прерываем процесс, т.к. это не критично
# Возвращаемся в public схему
connection.set_schema_to_public()
# Отправляем письмо владельцу с ссылкой для установки пароля
logger.info(f"Отправка письма владельцу с ссылкой установки пароля: {registration.owner_email}")
try:
from tenants.services 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)
# Не прерываем процесс одобрения, если письмо не отправилось
# Админ может повторно отправить письмо через действие в админке
# Обновляем статус заявки
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} успешно активирована")
return client
except Exception as e:
logger.error(f"Ошибка при активации заявки {registration.id}: {str(e)}", exc_info=True)
raise # Перебрасываем исключение для отображения в админке
def _redirect_to_changelist(self): def _redirect_to_changelist(self):
"""Редирект на список заявок""" """Редирект на список заявок"""

View File

@@ -9,9 +9,8 @@ Management команда для ручной активации заявки н
python manage.py activate_registration mixflowers python manage.py activate_registration mixflowers
""" """
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.db import transaction from django.conf import settings
from django.utils import timezone from tenants.models import TenantRegistration, Client, Domain
from tenants.models import TenantRegistration, Client, Domain, Subscription
class Command(BaseCommand): class Command(BaseCommand):
@@ -33,22 +32,20 @@ class Command(BaseCommand):
schema_name = options['schema_name'] schema_name = options['schema_name']
force = options.get('force', False) force = options.get('force', False)
# Ищем заявку
try: try:
# Ищем заявку
registration = TenantRegistration.objects.get(schema_name=schema_name) registration = TenantRegistration.objects.get(schema_name=schema_name)
except TenantRegistration.DoesNotExist: except TenantRegistration.DoesNotExist:
raise CommandError(f'Заявка с schema_name "{schema_name}" не найдена') raise CommandError(f'Заявка с schema_name "{schema_name}" не найдена')
self.stdout.write(f'Найдена заявка: {registration.shop_name} ({registration.schema_name})') self._print_registration_info(registration)
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: if registration.status == TenantRegistration.STATUS_APPROVED and not force:
self.stdout.write(self.style.WARNING('Эта заявка уже была одобрена!')) self.stdout.write(self.style.WARNING('Эта заявка уже была одобрена!'))
if registration.tenant: if registration.tenant:
self.stdout.write(f'Связанный тенант: {registration.tenant.name} (ID: {registration.tenant.id})') self.stdout.write(f'Связанный тенант: {registration.tenant.name} (ID: {registration.tenant.id})')
return
else: else:
self.stdout.write(self.style.WARNING('Но тенант не был создан. Используйте --force для пересоздания')) self.stdout.write(self.style.WARNING('Но тенант не был создан. Используйте --force для пересоздания'))
if not self._confirm('Продолжить с --force?'): if not self._confirm('Продолжить с --force?'):
@@ -63,74 +60,47 @@ class Command(BaseCommand):
'Используйте --force для пересоздания (ОПАСНО: удалит все данные!)' 'Используйте --force для пересоздания (ОПАСНО: удалит все данные!)'
) )
# Активируем # Если force - удаляем старый тенант
if force and existing_client:
self._delete_existing_tenant(existing_client)
# Активация через сервис
self._activate(registration)
def _print_registration_info(self, registration):
"""Вывод информации о заявке."""
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('')
def _delete_existing_tenant(self, client):
"""Удаление существующего тенанта."""
self.stdout.write(self.style.WARNING(f'Удаление существующего тенанта {client.id}...'))
try:
client.subscription.delete()
except Exception:
pass
Domain.objects.filter(tenant=client).delete()
client.delete()
self.stdout.write(self.style.SUCCESS('Старый тенант удален'))
def _activate(self, registration):
"""Активация заявки через сервис."""
self.stdout.write('') self.stdout.write('')
self.stdout.write(self.style.WARNING('НАЧИНАЮ АКТИВАЦИЮ...')) self.stdout.write(self.style.WARNING('НАЧИНАЮ АКТИВАЦИЮ...'))
self.stdout.write('') self.stdout.write('')
try: try:
with transaction.atomic(): from tenants.services import TenantOnboardingService
# Если force - удаляем старый тенант client = TenantOnboardingService.activate_registration(registration, admin_user=None)
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}') domain = Domain.objects.filter(tenant=client, is_primary=True).first()
client = Client.objects.create( domain_base = settings.TENANT_DOMAIN_BASE
schema_name=registration.schema_name, if ':' in domain_base:
name=registration.shop_name, domain_base = domain_base.split(':')[0]
owner_email=registration.owner_email, domain_name = f"{registration.schema_name}.{domain_base}"
owner_name=registration.owner_name,
phone=registration.phone,
is_active=True
)
self.stdout.write(self.style.SUCCESS(f'✓ Тенант создан (ID: {client.id})'))
# Создаем домен
domain_base = getattr(settings, 'TENANT_DOMAIN_BASE', 'localhost')
# Убираем порт из домена (django-tenants ищет по hostname без порта)
if ':' in domain_base:
domain_base = domain_base.split(':')[0]
domain_name = f"{registration.schema_name}.{domain_base}"
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")}'
))
# Инициализация системных данных
self.stdout.write('Инициализация системных данных...')
from django.core.management import call_command
call_command('init_tenant_data', schema=client.schema_name)
self.stdout.write(self.style.SUCCESS('✓ Системные данные созданы'))
# Обновляем заявку
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.stdout.write(self.style.SUCCESS('=' * 60)) self.stdout.write(self.style.SUCCESS('=' * 60))
@@ -140,7 +110,7 @@ class Command(BaseCommand):
self.stdout.write(f'Магазин: {client.name}') self.stdout.write(f'Магазин: {client.name}')
self.stdout.write(f'Schema: {client.schema_name}') self.stdout.write(f'Schema: {client.schema_name}')
self.stdout.write(f'Домен: http://{domain_name}:8000/') 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(f'Подписка до: {client.subscription.expires_at.strftime("%Y-%m-%d")} ({client.subscription.days_left()} дней)')
self.stdout.write('') self.stdout.write('')
except Exception as e: except Exception as e:
@@ -150,6 +120,6 @@ class Command(BaseCommand):
raise CommandError('Активация не удалась') raise CommandError('Активация не удалась')
def _confirm(self, question): def _confirm(self, question):
"""Запрос подтверждения у пользователя""" """Запрос подтверждения у пользователя."""
answer = input(f'{question} (yes/no): ') answer = input(f'{question} (yes/no): ')
return answer.lower() in ['yes', 'y', 'да'] return answer.lower() in ['yes', 'y', 'да']

View File

@@ -6,6 +6,8 @@ Management команда для инициализации всех систе
- Системного клиента (анонимный покупатель для POS) - Системного клиента (анонимный покупатель для POS)
- Системные статусы заказов - Системные статусы заказов
- Системные способы оплаты - Системные способы оплаты
- Склад по умолчанию
- Витрину по умолчанию
Использование: Использование:
# Инициализация для конкретного тенанта # Инициализация для конкретного тенанта
@@ -15,12 +17,11 @@ Management команда для инициализации всех систе
python manage.py init_tenant_data --schema=anatol --reset python manage.py init_tenant_data --schema=anatol --reset
""" """
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.db import connection
from django_tenants.utils import get_tenant_model, schema_context from django_tenants.utils import get_tenant_model, schema_context
class Command(BaseCommand): class Command(BaseCommand):
help = 'Инициализация всех системных данных тенанта (клиент, статусы, способы оплаты)' help = 'Инициализация всех системных данных тенанта (клиент, статусы, способы оплаты, склад, витрина)'
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument( parser.add_argument(
@@ -48,135 +49,16 @@ class Command(BaseCommand):
return return
self.stdout.write(self.style.SUCCESS('\n=== Инициализация системных данных тенанта ===\n')) self.stdout.write(self.style.SUCCESS('\n=== Инициализация системных данных тенанта ===\n'))
self.stdout.write(f'Тенант: {tenant.name} ({schema_name})\n') self.stdout.write(f'Тенант: {tenant.name} ({schema_name})')
if reset:
self.stdout.write(self.style.WARNING('Режим: RESET (пересоздание данных)\n'))
else:
self.stdout.write('')
# Переключаемся на схему тенанта # Переключаемся на схему тенанта и вызываем сервис
with schema_context(schema_name): with schema_context(schema_name):
# 1. Создаём системного клиента from tenants.services import TenantOnboardingService
self.stdout.write('\n' + '='*70) TenantOnboardingService.init_tenant_data(reset=reset)
self.stdout.write('[1] Создание системного клиента...')
self.stdout.write('='*70)
from customers.models import Customer
if reset:
# Удаляем существующего системного клиента
system_customers = Customer.objects.filter(email="system@pos.customer")
if system_customers.exists():
count = system_customers.count()
system_customers.delete()
self.stdout.write(self.style.WARNING(f' Удалено системных клиентов: {count}'))
try:
system_customer, created = Customer.get_or_create_system_customer()
if created:
self.stdout.write(self.style.SUCCESS(f' ✓ Системный клиент создан: {system_customer.name}'))
self.stdout.write(f' Email: {system_customer.email}')
self.stdout.write(f' ID: {system_customer.id}')
else:
self.stdout.write(self.style.WARNING(f' • Системный клиент уже существует: {system_customer.name}'))
self.stdout.write(f' ID: {system_customer.id}')
except Exception as e:
self.stdout.write(self.style.ERROR(f' ✗ Ошибка при создании системного клиента: {e}'))
# 2. Создаём системные статусы заказов
self.stdout.write('\n' + '='*70)
self.stdout.write('[2] Создание системных статусов заказов...')
self.stdout.write('='*70)
from orders.models import OrderStatus
from orders.services.order_status_service import OrderStatusService
if reset:
count = OrderStatus.objects.filter(is_system=True).count()
if count > 0:
OrderStatus.objects.filter(is_system=True).delete()
self.stdout.write(self.style.WARNING(f' Удалено системных статусов: {count}'))
try:
OrderStatusService.create_default_statuses()
statuses = OrderStatus.objects.filter(is_system=True).order_by('order')
self.stdout.write(self.style.SUCCESS(f' ✓ Создано системных статусов: {statuses.count()}'))
for status in statuses:
end_type = ''
if status.is_positive_end:
end_type = ' [Успешный]'
elif status.is_negative_end:
end_type = ' [Отрицательный]'
self.stdout.write(f' - {status.name:<20} ({status.code:<15}){end_type}')
except Exception as e:
self.stdout.write(self.style.ERROR(f' ✗ Ошибка при создании статусов: {e}'))
# 3. Создаём системные способы оплаты
self.stdout.write('\n' + '='*70)
self.stdout.write('[3] Создание системных способов оплаты...')
self.stdout.write('='*70)
from orders.models import PaymentMethod
if reset:
count = PaymentMethod.objects.filter(is_system=True).count()
if count > 0:
PaymentMethod.objects.filter(is_system=True).delete()
self.stdout.write(self.style.WARNING(f' Удалено системных способов оплаты: {count}'))
payment_methods = [
{
'code': 'account_balance',
'name': 'С баланса счёта',
'description': 'Оплата из кошелька клиента',
'is_system': True,
'order': 0
},
{
'code': 'cash',
'name': 'Наличными',
'description': 'Оплата наличными деньгами',
'is_system': True,
'order': 1
},
{
'code': 'card',
'name': 'Картой',
'description': 'Оплата банковской картой',
'is_system': True,
'order': 2
},
{
'code': 'online',
'name': 'Онлайн',
'description': 'Онлайн оплата через платежную систему',
'is_system': True,
'order': 3
},
{
'code': 'legal_entity',
'name': 'Безнал от ЮРЛИЦ',
'description': 'Безналичный расчёт от юридических лиц',
'is_system': True,
'order': 4
},
]
created_count = 0
try:
for method_data in payment_methods:
method, created = PaymentMethod.objects.get_or_create(
code=method_data['code'],
defaults=method_data
)
if created:
created_count += 1
self.stdout.write(self.style.SUCCESS(f' ✓ Создан способ оплаты: {method.name}'))
else:
self.stdout.write(self.style.WARNING(f' • Уже существует: {method.name}'))
self.stdout.write(self.style.SUCCESS(f'\n Готово! Создано {created_count} новых способов оплаты.'))
except Exception as e:
self.stdout.write(self.style.ERROR(f' ✗ Ошибка при создании способов оплаты: {e}'))
# Итоговое сообщение # Итоговое сообщение
self.stdout.write('\n' + '='*70) self.stdout.write('\n' + '='*70)

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

@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
Сервисы для работы с тенантами Сервис отправки email для тенантов.
""" """
import uuid import uuid
from django.utils import timezone from django.utils import timezone

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} финализирована")

View File

@@ -9,6 +9,8 @@
4. Создание способов оплаты 4. Создание способов оплаты
5. Создание системных статусов заказов 5. Создание системных статусов заказов
6. Создание системного клиента 6. Создание системного клиента
7. Создание склада по умолчанию
8. Создание витрины по умолчанию
""" """
from django.test import TestCase, TransactionTestCase from django.test import TestCase, TransactionTestCase
from django.db import connection from django.db import connection
@@ -19,6 +21,7 @@ from django_tenants.utils import schema_context
from tenants.models import Client, Domain, TenantRegistration from tenants.models import Client, Domain, TenantRegistration
from orders.models import PaymentMethod, OrderStatus from orders.models import PaymentMethod, OrderStatus
from customers.models import Customer from customers.models import Customer
from inventory.models import Warehouse, Showcase
User = get_user_model() User = get_user_model()
@@ -77,12 +80,9 @@ class TenantCreationIntegrationTest(TransactionTestCase):
status=TenantRegistration.STATUS_PENDING status=TenantRegistration.STATUS_PENDING
) )
# 2. Активируем заявку (как в админке) # 2. Активируем заявку через сервис
from tenants.admin import TenantRegistrationAdmin from tenants.services import TenantOnboardingService
from django.contrib.admin.sites import AdminSite tenant = TenantOnboardingService.activate_registration(registration, self.admin_user)
admin = TenantRegistrationAdmin(TenantRegistration, AdminSite())
tenant = admin._approve_registration(registration, self.admin_user)
# 3. Проверяем что тенант создан # 3. Проверяем что тенант создан
self.assertIsNotNone(tenant) self.assertIsNotNone(tenant)
@@ -145,11 +145,8 @@ class TenantCreationIntegrationTest(TransactionTestCase):
status=TenantRegistration.STATUS_PENDING status=TenantRegistration.STATUS_PENDING
) )
from tenants.admin import TenantRegistrationAdmin from tenants.services import TenantOnboardingService
from django.contrib.admin.sites import AdminSite tenant = TenantOnboardingService.activate_registration(registration, self.admin_user)
admin = TenantRegistrationAdmin(TenantRegistration, AdminSite())
tenant = admin._approve_registration(registration, self.admin_user)
# Проверяем статусы заказов # Проверяем статусы заказов
with schema_context('test_shop_statuses'): with schema_context('test_shop_statuses'):
@@ -224,11 +221,8 @@ class TenantCreationIntegrationTest(TransactionTestCase):
status=TenantRegistration.STATUS_PENDING status=TenantRegistration.STATUS_PENDING
) )
from tenants.admin import TenantRegistrationAdmin from tenants.services import TenantOnboardingService
from django.contrib.admin.sites import AdminSite tenant = TenantOnboardingService.activate_registration(registration, self.admin_user)
admin = TenantRegistrationAdmin(TenantRegistration, AdminSite())
tenant = admin._approve_registration(registration, self.admin_user)
# Проверяем системного клиента # Проверяем системного клиента
with schema_context('test_shop_customers'): with schema_context('test_shop_customers'):
@@ -242,6 +236,71 @@ class TenantCreationIntegrationTest(TransactionTestCase):
self.assertTrue(system_customer.is_system_customer) self.assertTrue(system_customer.is_system_customer)
self.assertEqual(system_customer.name, 'Анонимный клиент') self.assertEqual(system_customer.name, 'Анонимный клиент')
def test_new_tenant_gets_default_warehouse(self):
"""
Тест: Новый тенант получает склад по умолчанию.
"""
# Создаём и активируем тенант
registration = TenantRegistration.objects.create(
shop_name='Тестовый магазин склад',
schema_name='test_shop_warehouse',
owner_email='owner_warehouse@test.com',
owner_name='Тест Владелец Склад',
phone='+375291111118',
status=TenantRegistration.STATUS_PENDING
)
from tenants.services import TenantOnboardingService
tenant = TenantOnboardingService.activate_registration(registration, self.admin_user)
# Проверяем склад по умолчанию
with schema_context('test_shop_warehouse'):
default_warehouse = Warehouse.objects.filter(is_default=True).first()
self.assertIsNotNone(
default_warehouse,
"Склад по умолчанию не создан"
)
self.assertTrue(default_warehouse.is_default)
self.assertTrue(default_warehouse.is_active)
self.assertTrue(default_warehouse.is_pickup_point)
self.assertEqual(default_warehouse.name, 'Основной склад')
def test_new_tenant_gets_default_showcase(self):
"""
Тест: Новый тенант получает витрину по умолчанию.
"""
# Создаём и активируем тенант
registration = TenantRegistration.objects.create(
shop_name='Тестовый магазин витрина',
schema_name='test_shop_showcase',
owner_email='owner_showcase@test.com',
owner_name='Тест Владелец Витрина',
phone='+375291111119',
status=TenantRegistration.STATUS_PENDING
)
from tenants.services import TenantOnboardingService
tenant = TenantOnboardingService.activate_registration(registration, self.admin_user)
# Проверяем витрину по умолчанию
with schema_context('test_shop_showcase'):
default_showcase = Showcase.objects.filter(is_default=True).first()
self.assertIsNotNone(
default_showcase,
"Витрина по умолчанию не создана"
)
self.assertTrue(default_showcase.is_default)
self.assertTrue(default_showcase.is_active)
self.assertEqual(default_showcase.name, 'Основная витрина')
# Проверяем что витрина привязана к складу по умолчанию
self.assertIsNotNone(default_showcase.warehouse)
self.assertTrue(default_showcase.warehouse.is_default)
def test_new_tenant_gets_superuser(self): def test_new_tenant_gets_superuser(self):
""" """
Тест: Новый тенант получает суперпользователя для доступа к админке. Тест: Новый тенант получает суперпользователя для доступа к админке.
@@ -256,11 +315,8 @@ class TenantCreationIntegrationTest(TransactionTestCase):
status=TenantRegistration.STATUS_PENDING status=TenantRegistration.STATUS_PENDING
) )
from tenants.admin import TenantRegistrationAdmin from tenants.services import TenantOnboardingService
from django.contrib.admin.sites import AdminSite tenant = TenantOnboardingService.activate_registration(registration, self.admin_user)
admin = TenantRegistrationAdmin(TenantRegistration, AdminSite())
tenant = admin._approve_registration(registration, self.admin_user)
# Проверяем суперпользователя # Проверяем суперпользователя
with schema_context('test_shop_admin'): with schema_context('test_shop_admin'):
@@ -290,11 +346,8 @@ class TenantCreationIntegrationTest(TransactionTestCase):
status=TenantRegistration.STATUS_PENDING status=TenantRegistration.STATUS_PENDING
) )
from tenants.admin import TenantRegistrationAdmin from tenants.services import TenantOnboardingService
from django.contrib.admin.sites import AdminSite tenant = TenantOnboardingService.activate_registration(registration, self.admin_user)
admin = TenantRegistrationAdmin(TenantRegistration, AdminSite())
tenant = admin._approve_registration(registration, self.admin_user)
# Проверяем домен # Проверяем домен
domain = Domain.objects.filter(tenant=tenant).first() domain = Domain.objects.filter(tenant=tenant).first()
@@ -325,11 +378,8 @@ class TenantCreationIntegrationTest(TransactionTestCase):
self.assertEqual(registration.status, TenantRegistration.STATUS_PENDING) self.assertEqual(registration.status, TenantRegistration.STATUS_PENDING)
# Активируем # Активируем
from tenants.admin import TenantRegistrationAdmin from tenants.services import TenantOnboardingService
from django.contrib.admin.sites import AdminSite tenant = TenantOnboardingService.activate_registration(registration, self.admin_user)
admin = TenantRegistrationAdmin(TenantRegistration, AdminSite())
tenant = admin._approve_registration(registration, self.admin_user)
# Обновляем объект из БД # Обновляем объект из БД
registration.refresh_from_db() registration.refresh_from_db()
@@ -357,11 +407,8 @@ class TenantCreationIntegrationTest(TransactionTestCase):
status=TenantRegistration.STATUS_PENDING status=TenantRegistration.STATUS_PENDING
) )
from tenants.admin import TenantRegistrationAdmin from tenants.services import TenantOnboardingService
from django.contrib.admin.sites import AdminSite tenant = TenantOnboardingService.activate_registration(registration, self.admin_user)
admin = TenantRegistrationAdmin(TenantRegistration, AdminSite())
tenant = admin._approve_registration(registration, self.admin_user)
# Проверяем тенант # Проверяем тенант
self.assertIsNotNone(tenant) self.assertIsNotNone(tenant)
@@ -389,6 +436,17 @@ class TenantCreationIntegrationTest(TransactionTestCase):
superuser = User.objects.filter(email=settings.TENANT_ADMIN_EMAIL).first() superuser = User.objects.filter(email=settings.TENANT_ADMIN_EMAIL).first()
self.assertIsNotNone(superuser, "Должен быть суперпользователь") self.assertIsNotNone(superuser, "Должен быть суперпользователь")
# 5. Склад по умолчанию
default_warehouse = Warehouse.objects.filter(is_default=True).first()
self.assertIsNotNone(default_warehouse, "Должен быть склад по умолчанию")
self.assertTrue(default_warehouse.is_active)
# 6. Витрина по умолчанию
default_showcase = Showcase.objects.filter(is_default=True).first()
self.assertIsNotNone(default_showcase, "Должна быть витрина по умолчанию")
self.assertTrue(default_showcase.is_active)
self.assertEqual(default_showcase.warehouse, default_warehouse)
# Проверяем заявку # Проверяем заявку
registration.refresh_from_db() registration.refresh_from_db()
self.assertEqual(registration.status, TenantRegistration.STATUS_APPROVED) self.assertEqual(registration.status, TenantRegistration.STATUS_APPROVED)
@@ -403,4 +461,6 @@ class TenantCreationIntegrationTest(TransactionTestCase):
print(f"Статусов заказов: {order_statuses_count}") print(f"Статусов заказов: {order_statuses_count}")
print(f"Системный клиент: ✓") print(f"Системный клиент: ✓")
print(f"Суперпользователь: ✓") print(f"Суперпользователь: ✓")
print(f"Склад по умолчанию: ✓")
print(f"Витрина по умолчанию: ✓")
print("=" * 70) print("=" * 70)