# -*- coding: utf-8 -*-
from django.contrib import admin
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
class DomainInline(admin.TabularInline):
"""
Inline для управления доменами тенанта.
"""
model = Domain
extra = 1
fields = ['domain', 'is_primary']
@admin.register(Client)
class ClientAdmin(admin.ModelAdmin):
"""
Админ-панель для управления тенантами (магазинами).
ВАЖНО: Эта админка доступна только в Public схеме!
"""
list_display = [
'name',
'schema_name',
'owner_email',
'is_active',
'created_at',
'subscription_status',
]
list_filter = [
'is_active',
'created_at',
]
search_fields = [
'name',
'schema_name',
'owner_email',
'owner_name',
]
# Убираем schema_name из readonly при создании нового тенанта
def get_readonly_fields(self, request, obj=None):
if obj: # Редактирование существующего тенанта
return ['schema_name', 'created_at']
else: # Создание нового тенанта
return ['created_at']
fieldsets = (
('Информация о магазине', {
'fields': ('name', 'schema_name', 'is_active')
}),
('Владелец', {
'fields': ('owner_name', 'owner_email', 'phone')
}),
('Дополнительно', {
'fields': ('created_at', 'notes'),
'classes': ('collapse',)
}),
)
inlines = [DomainInline]
def subscription_status(self, obj):
"""Отображение статуса подписки"""
try:
sub = obj.subscription
if sub.is_expired():
return format_html('Истекла {}', sub.expires_at.date())
elif sub.days_left() <= 7:
return format_html('Осталось {} дн.', sub.days_left())
else:
return format_html('{} до {}',
sub.get_plan_display(), sub.expires_at.date())
except Subscription.DoesNotExist:
return format_html('Нет подписки')
subscription_status.short_description = "Подписка"
def has_delete_permission(self, request, obj=None):
"""
Запрещаем удаление тенантов через админку (для безопасности).
Удаление должно происходить через специальную команду.
"""
return False
@admin.register(Domain)
class DomainAdmin(admin.ModelAdmin):
"""
Админ-панель для управления доменами.
"""
list_display = [
'domain',
'tenant',
'is_primary',
]
list_filter = [
'is_primary',
]
search_fields = [
'domain',
'tenant__name',
'tenant__schema_name',
]
@admin.register(TenantRegistration)
class TenantRegistrationAdmin(admin.ModelAdmin):
"""
Админка для управления заявками на регистрацию
"""
list_display = ("shop_name", "schema_name", "owner_email", "status", "created_at", "actions_column")
list_filter = ("status", "created_at")
search_fields = ("shop_name", "schema_name", "owner_email", "owner_name")
readonly_fields = ("created_at", "processed_at", "processed_by", "tenant")
fieldsets = (
("Информация о магазине", {
"fields": ("shop_name", "schema_name")
}),
("Владелец", {
"fields": ("owner_name", "owner_email", "phone")
}),
("Статус", {
"fields": ("status", "created_at", "processed_at", "processed_by", "rejection_reason")
}),
("Результат", {
"fields": ("tenant",)
}),
)
actions = ['approve_registrations', 'reject_registrations', 'resend_password_setup_email']
def actions_column(self, obj):
"""Кнопки действий для каждой заявки"""
if obj.status == TenantRegistration.STATUS_PENDING:
approve_url = reverse('admin:tenants_tenantregistration_change', args=[obj.pk])
return format_html(
'
',
approve_url, approve_url
)
elif obj.status == TenantRegistration.STATUS_APPROVED:
if obj.tenant:
tenant_url = reverse('admin:tenants_client_change', args=[obj.tenant.pk])
return format_html('Перейти к тенанту →', tenant_url)
return "Одобрено"
else:
return "Отклонено"
actions_column.short_description = "Действия"
def approve_registrations(self, request, queryset):
"""Массовая активация заявок"""
approved_count = 0
for registration in queryset.filter(status=TenantRegistration.STATUS_PENDING):
try:
self._approve_registration(registration, request.user)
approved_count += 1
except Exception as e:
messages.error(request, f"Ошибка при активации '{registration.shop_name}': {str(e)}")
if approved_count > 0:
messages.success(request, f"Успешно активировано заявок: {approved_count}")
approve_registrations.short_description = "✓ Активировать выбранные заявки"
def reject_registrations(self, request, queryset):
"""Массовое отклонение заявок"""
rejected_count = queryset.filter(status=TenantRegistration.STATUS_PENDING).update(
status=TenantRegistration.STATUS_REJECTED,
processed_at=timezone.now(),
processed_by=request.user,
rejection_reason="Отклонено массовым действием"
)
if rejected_count > 0:
messages.success(request, f"Отклонено заявок: {rejected_count}")
reject_registrations.short_description = "✗ Отклонить выбранные заявки"
def resend_password_setup_email(self, request, queryset):
"""Повторно отправить письмо с ссылкой для установки пароля"""
from tenants.services import send_password_setup_email
sent_count = 0
for registration in queryset.filter(status=TenantRegistration.STATUS_APPROVED):
try:
send_password_setup_email(registration)
sent_count += 1
except Exception as e:
messages.error(request, f"Не удалось отправить письмо для {registration.shop_name}: {e}")
if sent_count > 0:
messages.success(request, f"Письма повторно отправлены: {sent_count}")
resend_password_setup_email.short_description = "📧 Отправить письмо повторно"
def save_model(self, request, obj, form, change):
"""
Отлавливаем изменение статуса на APPROVED и запускаем процесс активации
"""
# Если это изменение существующего объекта
if change:
# Получаем старое значение из БД
try:
old_obj = TenantRegistration.objects.get(pk=obj.pk)
old_status = old_obj.status
except TenantRegistration.DoesNotExist:
old_status = None
# Если статус изменился на APPROVED
if old_status == TenantRegistration.STATUS_PENDING and obj.status == TenantRegistration.STATUS_APPROVED:
# Не сохраняем пока - сначала активируем
try:
self._approve_registration(obj, request.user)
messages.success(request, f"Заявка '{obj.shop_name}' успешно активирована!")
return # _approve_registration уже сохраняет объект
except Exception as e:
messages.error(request, f"Ошибка при активации: {str(e)}")
# Откатываем статус обратно
obj.status = old_status
super().save_model(request, obj, form, change)
return
# Обычное сохранение для всех остальных случаев
super().save_model(request, obj, form, change)
def changeform_view(self, request, object_id=None, form_url='', extra_context=None):
"""
Обработка действий активации/отклонения через GET параметры
"""
if object_id and request.method == 'GET':
registration = TenantRegistration.objects.get(pk=object_id)
if 'approve' in request.GET and registration.status == TenantRegistration.STATUS_PENDING:
try:
self._approve_registration(registration, request.user)
messages.success(request, f"Заявка '{registration.shop_name}' успешно активирована!")
return self._redirect_to_changelist()
except Exception as e:
messages.error(request, f"Ошибка при активации: {str(e)}")
elif 'reject' in request.GET and registration.status == TenantRegistration.STATUS_PENDING:
# Обрабатываем отклонение
registration.status = TenantRegistration.STATUS_REJECTED
registration.processed_at = timezone.now()
registration.processed_by = request.user
registration.rejection_reason = "Отклонено администратором"
registration.save()
messages.warning(request, f"Заявка '{registration.shop_name}' отклонена.")
return self._redirect_to_changelist()
return super().changeform_view(request, object_id, form_url, extra_context)
@transaction.atomic
def _approve_registration(self, registration, admin_user):
"""
Активация заявки: создание тенанта, домена и триальной подписки
"""
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}")
# Создаем домен (для локальной разработки используем localhost)
domain_name = f"{registration.schema_name}.localhost"
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})")
# Назначаем роль owner владельцу
try:
from user_roles.models import Role
from user_roles.services import RoleService
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 user_roles.services import RoleService
try:
RoleService.create_default_roles()
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):
"""Редирект на список заявок"""
from django.shortcuts import redirect
return redirect('admin:tenants_tenantregistration_changelist')
@admin.register(Subscription)
class SubscriptionAdmin(admin.ModelAdmin):
"""
Админка для управления подписками
"""
list_display = ("client", "plan", "started_at", "expires_at", "is_active", "days_left_display", "is_expired_display")
list_filter = ("plan", "is_active", "auto_renew")
search_fields = ("client__name", "client__schema_name")
readonly_fields = ("created_at", "updated_at", "days_left_display", "is_expired_display")
fieldsets = (
("Основная информация", {
"fields": ("client", "plan", "is_active", "auto_renew")
}),
("Период действия", {
"fields": ("started_at", "expires_at", "days_left_display", "is_expired_display")
}),
("Служебная информация", {
"fields": ("created_at", "updated_at")
}),
)
def days_left_display(self, obj):
"""Отображение оставшихся дней"""
if not obj or not obj.expires_at:
return "—"
days = obj.days_left()
if days == 0:
return format_html('Истекла')
elif days <= 7:
return format_html('{} дн.', days)
else:
return format_html('{} дн.', days)
days_left_display.short_description = "Осталось"
def is_expired_display(self, obj):
"""Отображение статуса истечения"""
if not obj or not obj.expires_at:
return "—"
if obj.is_expired():
return format_html('Да')
else:
return format_html('Нет')
is_expired_display.short_description = "Истекла"