Files
octopus/myproject/tenants/admin.py
Andrey Smakotin 4b44624f86 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>
2025-10-27 18:20:26 +03:00

345 lines
15 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']
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 = "Истекла"