Создан TenantOnboardingService как единый источник истины для: - Активации заявки на регистрацию тенанта - Создания Client, Domain, Subscription - Инициализации системных данных (Customer, статусы, способы оплаты, склад, витрина) Новые сервисы: - TenantOnboardingService (tenants/services/onboarding.py) - WarehouseService (inventory/services/warehouse_service.py) - ShowcaseService (inventory/services/showcase_service.py) - PaymentMethodService (orders/services/payment_method_service.py) Рефакторинг: - admin.py: 220 строк → 5 строк (делегирование сервису) - init_tenant_data.py: 259 строк → 68 строк - activate_registration.py: использует сервис - Тесты обновлены для вызова сервиса напрямую При создании тенанта автоматически создаются склад и витрина по умолчанию. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
324 lines
14 KiB
Python
324 lines
14 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 .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)
|
||
|
||
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 = "Истекла"
|