Files
octopus/myproject/tenants/admin.py

335 lines
14 KiB
Python
Raw Permalink 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 .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):
"""Массовое отклонение заявок"""
from platform_admin.models import PlatformAdmin
# Получаем processed_by только если это PlatformAdmin
processed_by = request.user if isinstance(request.user, PlatformAdmin) else None
# Обновляем каждую заявку индивидуально (нельзя использовать update с FK на другую модель)
rejected_count = 0
for registration in queryset.filter(status=TenantRegistration.STATUS_PENDING):
registration.status = TenantRegistration.STATUS_REJECTED
registration.processed_at = timezone.now()
registration.processed_by = processed_by
registration.rejection_reason = "Отклонено массовым действием"
registration.save()
rejected_count += 1
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:
from platform_admin.models import PlatformAdmin
# Обрабатываем отклонение
registration.status = TenantRegistration.STATUS_REJECTED
registration.processed_at = timezone.now()
# processed_by только если это PlatformAdmin
registration.processed_by = request.user if isinstance(request.user, PlatformAdmin) else None
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)
def _approve_registration(self, registration, admin_user):
"""
Активация заявки: делегирует всю работу TenantOnboardingService.
"""
from tenants.services import TenantOnboardingService
return TenantOnboardingService.activate_registration(registration, admin_user)
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 = "Истекла"