diff --git a/myproject/inventory/services/__init__.py b/myproject/inventory/services/__init__.py index 8b45a5a..99a875f 100644 --- a/myproject/inventory/services/__init__.py +++ b/myproject/inventory/services/__init__.py @@ -6,10 +6,14 @@ from .batch_manager import StockBatchManager from .sale_processor import SaleProcessor from .inventory_processor import InventoryProcessor from .showcase_manager import ShowcaseManager +from .warehouse_service import WarehouseService +from .showcase_service import ShowcaseService __all__ = [ 'StockBatchManager', 'SaleProcessor', 'InventoryProcessor', 'ShowcaseManager', + 'WarehouseService', + 'ShowcaseService', ] diff --git a/myproject/inventory/services/showcase_service.py b/myproject/inventory/services/showcase_service.py new file mode 100644 index 0000000..1624fc4 --- /dev/null +++ b/myproject/inventory/services/showcase_service.py @@ -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 diff --git a/myproject/inventory/services/warehouse_service.py b/myproject/inventory/services/warehouse_service.py new file mode 100644 index 0000000..4472eb2 --- /dev/null +++ b/myproject/inventory/services/warehouse_service.py @@ -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 diff --git a/myproject/orders/management/commands/create_payment_methods.py b/myproject/orders/management/commands/create_payment_methods.py index 509d1a5..9385a67 100644 --- a/myproject/orders/management/commands/create_payment_methods.py +++ b/myproject/orders/management/commands/create_payment_methods.py @@ -1,56 +1,19 @@ # -*- coding: utf-8 -*- +""" +Management команда для создания стандартных способов оплаты. +""" from django.core.management.base import BaseCommand -from orders.models import PaymentMethod +from orders.services import PaymentMethodService class Command(BaseCommand): help = 'Создаёт стандартные способы оплаты для цветочного магазина' def handle(self, *args, **options): - 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 - }, - ] + results = PaymentMethodService.create_default_methods() created_count = 0 - for method_data in payment_methods: - method, created = PaymentMethod.objects.get_or_create( - code=method_data['code'], - defaults=method_data - ) + for method, created in results: if created: created_count += 1 self.stdout.write( diff --git a/myproject/orders/services/__init__.py b/myproject/orders/services/__init__.py index b9f0969..03e3d58 100644 --- a/myproject/orders/services/__init__.py +++ b/myproject/orders/services/__init__.py @@ -2,4 +2,10 @@ Сервисный слой для приложения orders. """ -__all__ = [] +from .order_status_service import OrderStatusService +from .payment_method_service import PaymentMethodService + +__all__ = [ + 'OrderStatusService', + 'PaymentMethodService', +] diff --git a/myproject/orders/services/payment_method_service.py b/myproject/orders/services/payment_method_service.py new file mode 100644 index 0000000..47d807f --- /dev/null +++ b/myproject/orders/services/payment_method_service.py @@ -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] diff --git a/myproject/tenants/admin.py b/myproject/tenants/admin.py index 5ab8abe..152f1d0 100644 --- a/myproject/tenants/admin.py +++ b/myproject/tenants/admin.py @@ -4,7 +4,6 @@ from django.contrib import messages from django.utils import timezone from django.utils.html import format_html from django.urls import reverse -from django.db import transaction 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) - @transaction.atomic def _approve_registration(self, registration, admin_user): """ - Активация заявки: создание тенанта, домена и триальной подписки + Активация заявки: делегирует всю работу TenantOnboardingService. """ - import logging - logger = logging.getLogger(__name__) - - 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 # Перебрасываем исключение для отображения в админке + from tenants.services import TenantOnboardingService + return TenantOnboardingService.activate_registration(registration, admin_user) def _redirect_to_changelist(self): """Редирект на список заявок""" diff --git a/myproject/tenants/management/commands/activate_registration.py b/myproject/tenants/management/commands/activate_registration.py index 9bc8d5a..c886010 100644 --- a/myproject/tenants/management/commands/activate_registration.py +++ b/myproject/tenants/management/commands/activate_registration.py @@ -9,9 +9,8 @@ Management команда для ручной активации заявки н 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 +from django.conf import settings +from tenants.models import TenantRegistration, Client, Domain class Command(BaseCommand): @@ -33,22 +32,20 @@ class Command(BaseCommand): 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('') + self._print_registration_info(registration) # Проверяем статус 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})') + return else: self.stdout.write(self.style.WARNING('Но тенант не был создан. Используйте --force для пересоздания')) if not self._confirm('Продолжить с --force?'): @@ -63,74 +60,47 @@ class Command(BaseCommand): 'Используйте --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.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('Старый тенант удален')) + from tenants.services import TenantOnboardingService + client = TenantOnboardingService.activate_registration(registration, admin_user=None) - # Создаем тенант - 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_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('✓ Заявка обновлена')) + # Выводим результат + domain = Domain.objects.filter(tenant=client, is_primary=True).first() + domain_base = settings.TENANT_DOMAIN_BASE + if ':' in domain_base: + domain_base = domain_base.split(':')[0] + domain_name = f"{registration.schema_name}.{domain_base}" self.stdout.write('') self.stdout.write(self.style.SUCCESS('=' * 60)) @@ -140,7 +110,7 @@ class Command(BaseCommand): 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(f'Подписка до: {client.subscription.expires_at.strftime("%Y-%m-%d")} ({client.subscription.days_left()} дней)') self.stdout.write('') except Exception as e: @@ -150,6 +120,6 @@ class Command(BaseCommand): raise CommandError('Активация не удалась') def _confirm(self, question): - """Запрос подтверждения у пользователя""" + """Запрос подтверждения у пользователя.""" answer = input(f'{question} (yes/no): ') return answer.lower() in ['yes', 'y', 'да'] diff --git a/myproject/tenants/management/commands/init_tenant_data.py b/myproject/tenants/management/commands/init_tenant_data.py index a743ff9..35e0ae1 100644 --- a/myproject/tenants/management/commands/init_tenant_data.py +++ b/myproject/tenants/management/commands/init_tenant_data.py @@ -6,21 +6,22 @@ Management команда для инициализации всех систе - Системного клиента (анонимный покупатель для POS) - Системные статусы заказов - Системные способы оплаты +- Склад по умолчанию +- Витрину по умолчанию Использование: # Инициализация для конкретного тенанта 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.db import connection from django_tenants.utils import get_tenant_model, schema_context class Command(BaseCommand): - help = 'Инициализация всех системных данных тенанта (клиент, статусы, способы оплаты)' + help = 'Инициализация всех системных данных тенанта (клиент, статусы, способы оплаты, склад, витрина)' def add_arguments(self, parser): parser.add_argument( @@ -48,135 +49,16 @@ class Command(BaseCommand): return 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): - # 1. Создаём системного клиента - self.stdout.write('\n' + '='*70) - 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}')) + from tenants.services import TenantOnboardingService + TenantOnboardingService.init_tenant_data(reset=reset) # Итоговое сообщение self.stdout.write('\n' + '='*70) diff --git a/myproject/tenants/services/__init__.py b/myproject/tenants/services/__init__.py new file mode 100644 index 0000000..bd9bbf7 --- /dev/null +++ b/myproject/tenants/services/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +""" +Сервисы для работы с тенантами. +""" + +from .onboarding import TenantOnboardingService +from .email import send_password_setup_email + +__all__ = [ + 'TenantOnboardingService', + 'send_password_setup_email', +] diff --git a/myproject/tenants/services.py b/myproject/tenants/services/email.py similarity index 98% rename from myproject/tenants/services.py rename to myproject/tenants/services/email.py index 2a2bc68..e1fa72d 100644 --- a/myproject/tenants/services.py +++ b/myproject/tenants/services/email.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """ -Сервисы для работы с тенантами +Сервис отправки email для тенантов. """ import uuid from django.utils import timezone diff --git a/myproject/tenants/services/onboarding.py b/myproject/tenants/services/onboarding.py new file mode 100644 index 0000000..3147e62 --- /dev/null +++ b/myproject/tenants/services/onboarding.py @@ -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} финализирована") diff --git a/myproject/tenants/tests/test_tenant_creation.py b/myproject/tenants/tests/test_tenant_creation.py index ee8e35c..a7fe79c 100644 --- a/myproject/tenants/tests/test_tenant_creation.py +++ b/myproject/tenants/tests/test_tenant_creation.py @@ -9,6 +9,8 @@ 4. Создание способов оплаты 5. Создание системных статусов заказов 6. Создание системного клиента +7. Создание склада по умолчанию +8. Создание витрины по умолчанию """ from django.test import TestCase, TransactionTestCase from django.db import connection @@ -19,6 +21,7 @@ from django_tenants.utils import schema_context from tenants.models import Client, Domain, TenantRegistration from orders.models import PaymentMethod, OrderStatus from customers.models import Customer +from inventory.models import Warehouse, Showcase User = get_user_model() @@ -77,12 +80,9 @@ class TenantCreationIntegrationTest(TransactionTestCase): status=TenantRegistration.STATUS_PENDING ) - # 2. Активируем заявку (как в админке) - from tenants.admin import TenantRegistrationAdmin - from django.contrib.admin.sites import AdminSite - - admin = TenantRegistrationAdmin(TenantRegistration, AdminSite()) - tenant = admin._approve_registration(registration, self.admin_user) + # 2. Активируем заявку через сервис + from tenants.services import TenantOnboardingService + tenant = TenantOnboardingService.activate_registration(registration, self.admin_user) # 3. Проверяем что тенант создан self.assertIsNotNone(tenant) @@ -145,12 +145,9 @@ class TenantCreationIntegrationTest(TransactionTestCase): status=TenantRegistration.STATUS_PENDING ) - from tenants.admin import TenantRegistrationAdmin - from django.contrib.admin.sites import AdminSite - - admin = TenantRegistrationAdmin(TenantRegistration, AdminSite()) - tenant = admin._approve_registration(registration, self.admin_user) - + from tenants.services import TenantOnboardingService + tenant = TenantOnboardingService.activate_registration(registration, self.admin_user) + # Проверяем статусы заказов with schema_context('test_shop_statuses'): statuses = OrderStatus.objects.all() @@ -223,24 +220,86 @@ class TenantCreationIntegrationTest(TransactionTestCase): phone='+375291111113', status=TenantRegistration.STATUS_PENDING ) - - from tenants.admin import TenantRegistrationAdmin - from django.contrib.admin.sites import AdminSite - - admin = TenantRegistrationAdmin(TenantRegistration, AdminSite()) - tenant = admin._approve_registration(registration, self.admin_user) - + + from tenants.services import TenantOnboardingService + tenant = TenantOnboardingService.activate_registration(registration, self.admin_user) + # Проверяем системного клиента with schema_context('test_shop_customers'): system_customer = Customer.objects.filter(is_system_customer=True).first() - + self.assertIsNotNone( system_customer, "Системный клиент не создан" ) - + self.assertTrue(system_customer.is_system_customer) 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): """ @@ -256,12 +315,9 @@ class TenantCreationIntegrationTest(TransactionTestCase): status=TenantRegistration.STATUS_PENDING ) - from tenants.admin import TenantRegistrationAdmin - from django.contrib.admin.sites import AdminSite - - admin = TenantRegistrationAdmin(TenantRegistration, AdminSite()) - tenant = admin._approve_registration(registration, self.admin_user) - + from tenants.services import TenantOnboardingService + tenant = TenantOnboardingService.activate_registration(registration, self.admin_user) + # Проверяем суперпользователя with schema_context('test_shop_admin'): superuser = User.objects.filter( @@ -290,12 +346,9 @@ class TenantCreationIntegrationTest(TransactionTestCase): status=TenantRegistration.STATUS_PENDING ) - from tenants.admin import TenantRegistrationAdmin - from django.contrib.admin.sites import AdminSite - - admin = TenantRegistrationAdmin(TenantRegistration, AdminSite()) - tenant = admin._approve_registration(registration, self.admin_user) - + from tenants.services import TenantOnboardingService + tenant = TenantOnboardingService.activate_registration(registration, self.admin_user) + # Проверяем домен domain = Domain.objects.filter(tenant=tenant).first() @@ -325,12 +378,9 @@ class TenantCreationIntegrationTest(TransactionTestCase): self.assertEqual(registration.status, TenantRegistration.STATUS_PENDING) # Активируем - from tenants.admin import TenantRegistrationAdmin - from django.contrib.admin.sites import AdminSite - - admin = TenantRegistrationAdmin(TenantRegistration, AdminSite()) - tenant = admin._approve_registration(registration, self.admin_user) - + from tenants.services import TenantOnboardingService + tenant = TenantOnboardingService.activate_registration(registration, self.admin_user) + # Обновляем объект из БД registration.refresh_from_db() @@ -357,12 +407,9 @@ class TenantCreationIntegrationTest(TransactionTestCase): status=TenantRegistration.STATUS_PENDING ) - from tenants.admin import TenantRegistrationAdmin - from django.contrib.admin.sites import AdminSite - - admin = TenantRegistrationAdmin(TenantRegistration, AdminSite()) - tenant = admin._approve_registration(registration, self.admin_user) - + from tenants.services import TenantOnboardingService + tenant = TenantOnboardingService.activate_registration(registration, self.admin_user) + # Проверяем тенант self.assertIsNotNone(tenant) self.assertTrue(tenant.is_active) @@ -376,23 +423,34 @@ class TenantCreationIntegrationTest(TransactionTestCase): # 1. Способы оплаты payment_methods_count = PaymentMethod.objects.count() self.assertEqual(payment_methods_count, 5, "Должно быть 5 способов оплаты") - + # 2. Статусы заказов order_statuses_count = OrderStatus.objects.count() self.assertGreaterEqual(order_statuses_count, 3, "Должно быть минимум 3 статуса") - + # 3. Системный клиент system_customer = Customer.objects.filter(is_system_customer=True).first() self.assertIsNotNone(system_customer, "Должен быть системный клиент") - + # 4. Суперпользователь superuser = User.objects.filter(email=settings.TENANT_ADMIN_EMAIL).first() 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() self.assertEqual(registration.status, TenantRegistration.STATUS_APPROVED) - + print("\n" + "=" * 70) print("✓ ПОЛНЫЙ ОНБОРДИНГ ТЕНАНТА ПРОШЁЛ УСПЕШНО") print("=" * 70) @@ -403,4 +461,6 @@ class TenantCreationIntegrationTest(TransactionTestCase): print(f"Статусов заказов: {order_statuses_count}") print(f"Системный клиент: ✓") print(f"Суперпользователь: ✓") + print(f"Склад по умолчанию: ✓") + print(f"Витрина по умолчанию: ✓") print("=" * 70)