feat: Добавить автоматическое создание суперпользователей для тенантов
Реализована система автоматического создания суперпользователей при активации новых тенантов (магазинов). Credentials читаются из .env файла. Изменения: - Подключен django-environ для управления переменными окружения - Обновлен settings.py: SECRET_KEY, DEBUG, DATABASE теперь из .env - Добавлены настройки TENANT_ADMIN_EMAIL, TENANT_ADMIN_PASSWORD, TENANT_ADMIN_NAME - Обновлен tenants/admin.py: автоматическое создание superuser при активации - Создан activate_tenant.py: универсальный скрипт активации заявок - Обновлен activate_mixflowers.py: добавлено создание superuser - Создан .gitignore для защиты секретов - Добавлена документация TENANT_ADMIN_GUIDE.md Использование: 1. Через админку: Заявки → Активировать (автоматически создаст superuser) 2. Через скрипт: python activate_tenant.py <schema_name> Доступ к админке тенанта: - URL: http://{schema_name}.localhost:8000/admin/ - Email: admin@localhost (из .env) - Password: 1234 (из .env) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
344
myproject/tenants/admin.py
Normal file
344
myproject/tenants/admin.py
Normal file
@@ -0,0 +1,344 @@
|
||||
# -*- 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']
|
||||
|
||||
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 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}")
|
||||
|
||||
# Создаем триальную подписку на 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} уже существует в тенанте")
|
||||
|
||||
# Возвращаемся в public схему
|
||||
from tenants.models import Client as TenantClient
|
||||
public_tenant = TenantClient.objects.get(schema_name='public')
|
||||
connection.set_tenant(public_tenant)
|
||||
|
||||
# Обновляем статус заявки
|
||||
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):
|
||||
"""Отображение оставшихся дней"""
|
||||
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 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 = "Истекла"
|
||||
Reference in New Issue
Block a user