Files
octopus/myproject/tenants/admin.py
Andrey Smakotin 9b430c7eb0 Исправлен порядок создания ролей при одобрении заявки
- Перемещено создание системных ролей перед назначением роли владельцу
- Теперь UserRole создается автоматически для владельца при одобрении заявки
- Исправлена ошибка: роль назначалась до создания ролей в БД
2025-12-22 10:44:42 +03:00

498 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
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(
'<div style="display: flex; flex-direction: column; gap: 5px; min-width: 140px;">'
'<a class="button" href="{}?approve=1" style="background: #417690; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px; text-align: center; display: block; white-space: nowrap;">Активировать</a>'
'<a class="button" href="{}?reject=1" style="background: #ba2121; color: white; padding: 5px 10px; text-decoration: none; border-radius: 3px; text-align: center; display: block; white-space: nowrap;">Отклонить</a>'
'</div>',
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}")
# Создаем домен (динамически определяется из настроек)
# Локально: schema_name.localhost:8000
# Продакшен: schema_name.mix.smaa.by
from django.conf import settings
domain_base = settings.TENANT_DOMAIN_BASE
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):
"""Редирект на список заявок"""
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 = "Истекла"