Feat: Add cleanup_tenant management command for improved tenant deletion

- Create new cleanup_tenant command with better tenant deletion workflow
- Handle TenantRegistration automatic processing (keep history or delete)
- Add --purge-registration flag to remove TenantRegistration records
- Add --delete-files flag to remove physical tenant files from /media/tenants/
- Provide detailed deletion summary with confirmation step
- Update УДАЛЕНИЕ_ТЕНАНТОВ.md documentation with:
  * Complete guide for new cleanup_tenant command
  * Explanation of TenantRegistration problem and solutions
  * Recommendations for different use cases (test vs production)
  * Examples of all command variants

Tested:
- Successfully deleted grach tenant (no registration)
- Successfully deleted bingo tenant with --purge-registration flag
- Verified registration records are properly managed
- All database and file operations work correctly

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-23 21:32:32 +03:00
parent 30f21989d6
commit 8079abe939
2 changed files with 632 additions and 0 deletions

View File

@@ -0,0 +1,177 @@
# -*- coding: utf-8 -*-
"""
Management команда для полного удаления тенанта и его данных.
ВАЖНО: Это необратимая операция! Все данные тенанта будут удалены.
Использование:
# Базовое удаление (только Client и БД, заявка остается с tenant=NULL)
python manage.py cleanup_tenant --schema=papa --noinput
# Удалить Client, БД и TenantRegistration
python manage.py cleanup_tenant --schema=papa --noinput --purge-registration
# Полная очистка: Client, БД, файлы и заявка
python manage.py cleanup_tenant --schema=papa --noinput --purge-registration --delete-files
"""
from django.core.management.base import BaseCommand
from django.db import transaction, connection
from tenants.models import Client, TenantRegistration
import shutil
import os
class Command(BaseCommand):
help = 'Полное удаление тенанта (магазина) с его схемой БД и опционально файлами'
def add_arguments(self, parser):
parser.add_argument(
'--schema',
type=str,
help='Имя схемы БД тенанта для удаления (пример: papa)'
)
parser.add_argument(
'--noinput',
action='store_true',
help='Не запрашивать подтверждение (для автоматизации)'
)
parser.add_argument(
'--purge-registration',
action='store_true',
help='Также удалить связанную заявку на регистрацию (TenantRegistration)'
)
parser.add_argument(
'--delete-files',
action='store_true',
help='Также удалить физические файлы тенанта из /media/tenants/{schema_name}/'
)
def handle(self, *args, **options):
self.stdout.write(self.style.SUCCESS('\n=== Tenant (Shop) Deletion ===\n'))
# Получаем параметры
schema_name = options.get('schema')
noinput = options.get('noinput', False)
purge_registration = options.get('purge_registration', False)
delete_files = options.get('delete_files', False)
# Валидация параметров
if not schema_name:
self.stdout.write(self.style.ERROR('\nERROR: specify --schema=<schema_name>\n'))
return
# Проверяем что тенант существует
try:
tenant = Client.objects.get(schema_name=schema_name)
except Client.DoesNotExist:
self.stdout.write(self.style.ERROR(f'\nERROR: Tenant with schema="{schema_name}" not found\n'))
return
# Показываем информацию о том что будет удалено
self.stdout.write('='*70)
self.stdout.write(self.style.WARNING('WARNING! The following data will be deleted:'))
self.stdout.write('='*70)
self.stdout.write(f'\n[Tenant]')
self.stdout.write(f' Name: {tenant.name}')
self.stdout.write(f' Schema: {tenant.schema_name}')
self.stdout.write(f' Owner: {tenant.owner_email}')
self.stdout.write(f'\n[Database]')
self.stdout.write(f' Schema "{schema_name}" will be completely deleted')
self.stdout.write(f' All tables and data will be deleted')
# Проверяем TenantRegistration
registration = TenantRegistration.objects.filter(tenant=tenant).first()
if registration:
self.stdout.write(f'\n[TenantRegistration]')
self.stdout.write(f' From: {registration.owner_name} ({registration.owner_email})')
self.stdout.write(f' Status: {registration.get_status_display()}')
if purge_registration:
self.stdout.write(f' Action: DELETE (--purge-registration)')
else:
self.stdout.write(f' Action: keep with tenant=NULL (keep history)')
# Проверяем файлы
if delete_files:
tenant_files_path = f'media/tenants/{schema_name}'
if os.path.exists(tenant_files_path):
file_count = sum([len(files) for r, d, files in os.walk(tenant_files_path)])
self.stdout.write(f'\n[Files]')
self.stdout.write(f' Path: {tenant_files_path}')
self.stdout.write(f' Count: {file_count}')
self.stdout.write(f' Action: DELETE')
else:
self.stdout.write(f'\n[Files]')
self.stdout.write(f' Path {tenant_files_path} not found (skipping)')
self.stdout.write('\n' + '='*70)
# Запрашиваем подтверждение
if not noinput:
confirm = input(self.style.WARNING('\nAre you sure? Type "yes" to confirm: '))
if confirm.lower() not in ['yes', 'y']:
self.stdout.write(self.style.ERROR('\nCancelled\n'))
return
# Начинаем удаление
self.stdout.write('\n' + '='*70)
self.stdout.write(self.style.SUCCESS('Starting deletion...'))
self.stdout.write('='*70 + '\n')
try:
with transaction.atomic():
# 1. Обновляем TenantRegistration (если нужно)
if registration:
if purge_registration:
self.stdout.write('[1] Deleting TenantRegistration...')
reg_id = registration.id
registration.delete()
self.stdout.write(self.style.SUCCESS(f' OK: TenantRegistration (ID={reg_id}) deleted'))
else:
self.stdout.write('[1] Updating TenantRegistration (keeping history)...')
registration.tenant = None
registration.save()
self.stdout.write(self.style.SUCCESS(' OK: TenantRegistration updated (tenant=NULL)'))
# 2. Удаляем Client (это вызовет удаление схемы БД через django-tenants)
self.stdout.write('[2] Deleting Client and DB schema...')
tenant_name = tenant.name
tenant_schema = tenant.schema_name
tenant.delete()
self.stdout.write(self.style.SUCCESS(f' OK: Client "{tenant_name}" deleted'))
self.stdout.write(self.style.SUCCESS(f' OK: DB schema "{tenant_schema}" deleted'))
# 3. Удаляем файлы (вне transaction, потому что это файловая система, а не БД)
if delete_files:
self.stdout.write('[3] Deleting tenant files...')
tenant_files_path = f'media/tenants/{schema_name}'
if os.path.exists(tenant_files_path):
try:
shutil.rmtree(tenant_files_path)
self.stdout.write(self.style.SUCCESS(f' OK: Folder {tenant_files_path} deleted'))
except Exception as e:
self.stdout.write(self.style.ERROR(f' ERROR: Failed to delete folder: {e}'))
raise
else:
self.stdout.write(self.style.WARNING(f' WARN: Folder {tenant_files_path} not found'))
# Итоговое сообщение
self.stdout.write('\n' + '='*70)
self.stdout.write(self.style.SUCCESS('SUCCESS: Tenant has been deleted!'))
self.stdout.write('='*70)
self.stdout.write(self.style.WARNING('\nDeletion summary:'))
self.stdout.write(f' - Client deleted')
self.stdout.write(f' - DB schema "{schema_name}" deleted')
if registration:
if purge_registration:
self.stdout.write(f' - TenantRegistration deleted')
else:
self.stdout.write(f' - TenantRegistration kept (in history)')
if delete_files:
self.stdout.write(f' - Files deleted from /media/tenants/{schema_name}/')
self.stdout.write('')
except Exception as e:
self.stdout.write(self.style.ERROR(f'\nERROR during tenant deletion: {e}'))
self.stdout.write(self.style.ERROR(' Data may be partially deleted. Check DB state.\n'))
raise