Added save_model override to TenantRegistrationAdmin to catch status changes from PENDING to APPROVED and trigger the full activation flow. Previously, changing status via admin form only saved the status field without creating the tenant, sending emails, etc. Now both methods work: - Click "Activate" button (via GET parameter) - Change status dropdown and save (via save_model) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
490 lines
24 KiB
Python
490 lines
24 KiB
Python
# -*- 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('<span style="color: red;">Истекла {}</span>', sub.expires_at.date())
|
||
elif sub.days_left() <= 7:
|
||
return format_html('<span style="color: orange;">Осталось {} дн.</span>', sub.days_left())
|
||
else:
|
||
return format_html('<span style="color: green;">{} до {}</span>',
|
||
sub.get_plan_display(), sub.expires_at.date())
|
||
except Subscription.DoesNotExist:
|
||
return format_html('<span style="color: gray;">Нет подписки</span>')
|
||
|
||
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(
|
||
'<a class="button" href="{}?approve=1" style="background: #417690; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px; margin-right: 5px;">Активировать</a> '
|
||
'<a class="button" href="{}?reject=1" style="background: #ba2121; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px;">Отклонить</a>',
|
||
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('<a href="{}">Перейти к тенанту →</a>', 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 # Пароль будет установлен через ссылку
|
||
)
|
||
# Помечаем 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
|
||
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('<span style="color: red;">Истекла</span>')
|
||
elif days <= 7:
|
||
return format_html('<span style="color: orange;">{} дн.</span>', 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('<span style="color: red;">Да</span>')
|
||
else:
|
||
return format_html('<span style="color: green;">Нет</span>')
|
||
|
||
is_expired_display.short_description = "Истекла"
|