Corp/docs/in-progress/CHAT_MIGRATION_PLAN.md
lizerk f5ec8ffc8b docs: add communications refactoring plan and migration strategy
- Add detailed comparison of current vs refactored Chat model
- Add step-by-step migration plan with zero data loss guarantee
- Document GenericForeignKey approach for universal chat contexts
- Include rollback procedures and verification commands
2026-03-11 12:55:21 +03:00

23 KiB
Raw Blame History

План миграции данных Chat без потерь

✅ ГАРАНТИЯ: Все данные сохраняются!

Миграция будет аддитивной (добавляющей), а не деструктивной. Старые поля можно оставить для совместимости или удалить позже.


📊 Анализ: Какие данные нужно мигрировать

Текущие EUSRR-специфичные поля в Chat:

# 1. department (ForeignKey) → 146 записей с department != NULL
department = models.ForeignKey(Department, ...)

# 2. include_all_employees (Boolean) → просто переименование
include_all_employees = models.BooleanField(default=False)

# 3. is_main (Boolean) → значения True/False
is_main = models.BooleanField(default=False)

Давайте проверим, сколько у вас данных:

# Проверка количества чатов с department
.venv/Scripts/python manage.py shell
>>> from communications.models import Chat
>>> Chat.objects.filter(department__isnull=False).count()
>>> Chat.objects.filter(is_main=True).count()
>>> Chat.objects.filter(include_all_employees=True).count()

🔄 Стратегия миграции: Пошаговая (безопасная)

Фаза 1: Добавление новых полей (АДДИТИВНАЯ)

Добавляем новые поля БЕЗ удаления старых → 0% риска потери данных

Фаза 2: Миграция данных

Копируем данные из старых полей в новые → Данные дублируются

Фаза 3: Обновление кода

Переключаем код на использование новых полей → Старые поля остаются для отката

Фаза 4: Удаление старых полей (опционально)

Удаляем старые поля только после полной проверки → Можно отложить на недели/месяцы


📝 Детальный план миграций

Миграция 1: Добавление новых полей

# backend/communications/migrations/0XXX_add_generic_context.py

from django.db import migrations, models
import django.contrib.contenttypes.models

class Migration(migrations.Migration):
    dependencies = [
        ('communications', '0YYY_previous_migration'),
        ('contenttypes', '0002_remove_content_type_name'),
    ]

    operations = [
        # 1. Добавляем GenericForeignKey поля
        migrations.AddField(
            model_name='chat',
            name='context_content_type',
            field=models.ForeignKey(
                blank=True,
                null=True,
                on_delete=django.db.models.deletion.CASCADE,
                to='contenttypes.contenttype',
                verbose_name='Context type',
                help_text='Type of related object (e.g., Department, Project, Team)'
            ),
        ),
        migrations.AddField(
            model_name='chat',
            name='context_object_id',
            field=models.PositiveIntegerField(
                blank=True,
                null=True,
                verbose_name='Context object ID'
            ),
        ),
        
        # 2. Добавляем JSON поля
        migrations.AddField(
            model_name='chat',
            name='flags',
            field=models.JSONField(
                blank=True,
                default=dict,
                verbose_name='Flags',
                help_text="Custom flags: {'is_primary': true, 'is_archived': false, etc.}"
            ),
        ),
        migrations.AddField(
            model_name='chat',
            name='extra_data',
            field=models.JSONField(
                blank=True,
                default=dict,
                verbose_name='Extra data',
                help_text='Additional chat metadata (extensible)'
            ),
        ),
        
        # 3. Увеличиваем max_length для type (для кастомных типов)
        migrations.AlterField(
            model_name='chat',
            name='type',
            field=models.CharField(
                db_index=True,
                max_length=32,  # было 16
                help_text='Chat type: private, group, channel, announcement, or custom'
            ),
        ),
        
        # 4. Индексы для GenericFK
        migrations.AddIndex(
            model_name='chat',
            index=models.Index(
                fields=['context_content_type', 'context_object_id'],
                name='chat_context_idx'
            ),
        ),
    ]

Результат: У вас теперь есть И старые И новые поля. Данные не тронуты! ✅


Миграция 2: Копирование данных из department → context_object

# backend/communications/migrations/0XXX_migrate_department_to_context.py

from django.db import migrations
from django.contrib.contenttypes.models import ContentType

def migrate_department_to_generic_fk(apps, schema_editor):
    """
    Миграция: department FK → context GenericFK
    
    Копирует данные БЕЗ удаления старого поля!
    """
    Chat = apps.get_model('communications', 'Chat')
    Department = apps.get_model('employees', 'Department')
    
    # Получаем ContentType для Department
    department_ct = ContentType.objects.get_for_model(Department)
    
    # Счетчик для отчета
    migrated_count = 0
    
    # Переносим все чаты с department
    chats_with_dept = Chat.objects.filter(department__isnull=False)
    total = chats_with_dept.count()
    
    print(f"\n[MIGRATION] Migrating {total} chats with department...")
    
    for chat in chats_with_dept:
        # Копируем department_id в context
        chat.context_content_type = department_ct
        chat.context_object_id = chat.department_id
        chat.save(update_fields=['context_content_type', 'context_object_id'])
        migrated_count += 1
        
        if migrated_count % 100 == 0:
            print(f"  Migrated {migrated_count}/{total}...")
    
    print(f"[MIGRATION] Successfully migrated {migrated_count} chats! ✅")
    print(f"[INFO] Old 'department' field preserved for rollback")


def reverse_migrate(apps, schema_editor):
    """
    Откат: context GenericFK → department FK
    
    Восстанавливаем department_id из context_object_id
    """
    Chat = apps.get_model('communications', 'Chat')
    Department = apps.get_model('employees', 'Department')
    
    department_ct = ContentType.objects.get_for_model(Department)
    
    # Восстанавливаем department для чатов с context типа Department
    chats = Chat.objects.filter(
        context_content_type=department_ct,
        context_object_id__isnull=False
    )
    
    for chat in chats:
        try:
            dept = Department.objects.get(pk=chat.context_object_id)
            chat.department = dept
            chat.save(update_fields=['department'])
        except Department.DoesNotExist:
            print(f"Warning: Department {chat.context_object_id} not found for chat {chat.id}")


class Migration(migrations.Migration):
    dependencies = [
        ('communications', '0XXX_add_generic_context'),
        ('employees', '0001_initial'),  # убедитесь что Department существует
    ]

    operations = [
        migrations.RunPython(
            migrate_department_to_generic_fk,
            reverse_code=reverse_migrate,
        ),
    ]

Результат:

  • ✅ Все чаты с department теперь имеют context_object
  • ✅ Старое поле department сохранено (можно откатить!)
  • ✅ Миграция обратима (reverse_migrate)

Миграция 3: Копирование is_main → flags['is_primary']

# backend/communications/migrations/0XXX_migrate_is_main_to_flags.py

from django.db import migrations

def migrate_is_main_to_flags(apps, schema_editor):
    """
    Миграция: is_main Boolean → flags['is_primary']
    
    Копирует значение БЕЗ удаления старого поля!
    """
    Chat = apps.get_model('communications', 'Chat')
    
    # Находим все чаты с is_main=True
    main_chats = Chat.objects.filter(is_main=True)
    count = main_chats.count()
    
    print(f"\n[MIGRATION] Migrating is_main to flags for {count} chats...")
    
    migrated = 0
    for chat in main_chats:
        # Получаем текущие flags или создаем пустой dict
        flags = chat.flags or {}
        
        # Добавляем is_primary
        flags['is_primary'] = True
        
        chat.flags = flags
        chat.save(update_fields=['flags'])
        migrated += 1
    
    print(f"[MIGRATION] Successfully migrated {migrated} chats with is_main! ✅")
    print(f"[INFO] Old 'is_main' field preserved for rollback")


def reverse_migrate(apps, schema_editor):
    """
    Откат: flags['is_primary'] → is_main
    """
    Chat = apps.get_model('communications', 'Chat')
    
    # Находим все чаты с flags.is_primary=True
    for chat in Chat.objects.all():
        if chat.flags and chat.flags.get('is_primary'):
            chat.is_main = True
            chat.save(update_fields=['is_main'])


class Migration(migrations.Migration):
    dependencies = [
        ('communications', '0XXX_migrate_department_to_context'),
    ]

    operations = [
        migrations.RunPython(
            migrate_is_main_to_flags,
            reverse_code=reverse_migrate,
        ),
    ]

Результат:

  • ✅ Все чаты с is_main=True теперь имеют flags={'is_primary': True}
  • ✅ Старое поле is_main сохранено
  • ✅ Обратимая миграция

Миграция 4: Переименование include_all_employees → include_all_users

# backend/communications/migrations/0XXX_rename_include_all_employees.py

from django.db import migrations

class Migration(migrations.Migration):
    dependencies = [
        ('communications', '0XXX_migrate_is_main_to_flags'),
    ]

    operations = [
        # Простое переименование поля - Django делает все сам!
        migrations.RenameField(
            model_name='chat',
            old_name='include_all_employees',
            new_name='include_all_users',
        ),
    ]

Результат:

  • ✅ Поле переименовано
  • ✅ ВСЕ данные сохранены (это просто ALTER TABLE RENAME COLUMN)
  • ✅ Обратимо автоматически

Миграция 5: Удаление старых constraints (безопасно)

# backend/communications/migrations/0XXX_remove_old_constraints.py

from django.db import migrations

class Migration(migrations.Migration):
    dependencies = [
        ('communications', '0XXX_rename_include_all_employees'),
    ]

    operations = [
        # Удаляем constraint на department (больше не нужен)
        migrations.RemoveConstraint(
            model_name='chat',
            name='unique_main_department_chat',
        ),
        
        # Удаляем constraint на global (специфичен для EUSRR)
        migrations.RemoveConstraint(
            model_name='chat',
            name='unique_main_global_chat',
        ),
        
        # Оставляем универсальный constraint
        # (unique_announcement_per_user - это универсально)
    ]

Результат:

  • ✅ Старые constraints удалены
  • ✅ Данные НЕ затронуты (constraints не содержат данных)

Миграция 6 (ОПЦИОНАЛЬНАЯ): Удаление старых полей

⚠️ ВАЖНО: Эту миграцию можно делать через недели/месяцы после развертывания!

# backend/communications/migrations/0XXX_remove_deprecated_fields.py

from django.db import migrations

class Migration(migrations.Migration):
    dependencies = [
        ('communications', '0XXX_remove_old_constraints'),
    ]

    operations = [
        # ⚠️ Удаление старых полей - делать только после полной проверки!
        
        migrations.RemoveField(
            model_name='chat',
            name='department',
        ),
        
        migrations.RemoveField(
            model_name='chat',
            name='is_main',
        ),
        
        # Индекс на department тоже удалится автоматически
    ]

Результат:

  • ✅ Старые поля удалены (база данных становится чище)
  • ⚠️ Откат этой миграции невозможен без backup!

🔍 Проверка данных после миграции

После выполнения миграций 1-5, запустите проверку:

# backend/communications/management/commands/verify_chat_migration.py

from django.core.management.base import BaseCommand
from django.contrib.contenttypes.models import ContentType
from communications.models import Chat
from employees.models import Department

class Command(BaseCommand):
    help = 'Verify Chat migration: old fields → new fields'

    def handle(self, *args, **options):
        self.stdout.write("\n" + "="*60)
        self.stdout.write("VERIFYING CHAT MIGRATION")
        self.stdout.write("="*60 + "\n")
        
        errors = []
        warnings = []
        
        # 1. Проверка department → context_object
        self.stdout.write("1. Checking department → context_object migration...")
        
        dept_ct = ContentType.objects.get_for_model(Department)
        chats_with_dept = Chat.objects.filter(department__isnull=False)
        
        for chat in chats_with_dept:
            # Проверяем что context заполнен
            if not chat.context_object_id:
                errors.append(
                    f"Chat {chat.id}: has department but context_object_id is NULL"
                )
            elif chat.context_object_id != chat.department_id:
                errors.append(
                    f"Chat {chat.id}: context_object_id ({chat.context_object_id}) "
                    f"!= department_id ({chat.department_id})"
                )
            elif chat.context_content_type_id != dept_ct.id:
                errors.append(
                    f"Chat {chat.id}: context_content_type is not Department"
                )
        
        if not errors:
            self.stdout.write(self.style.SUCCESS(
                f"   ✓ All {chats_with_dept.count()} chats with department migrated correctly"
            ))
        
        # 2. Проверка is_main → flags['is_primary']
        self.stdout.write("\n2. Checking is_main → flags['is_primary'] migration...")
        
        main_chats = Chat.objects.filter(is_main=True)
        
        for chat in main_chats:
            if not chat.flags.get('is_primary'):
                errors.append(
                    f"Chat {chat.id}: is_main=True but flags['is_primary'] is not True"
                )
        
        if not errors:
            self.stdout.write(self.style.SUCCESS(
                f"   ✓ All {main_chats.count()} main chats migrated to flags correctly"
            ))
        
        # 3. Проверка include_all_users (после переименования)
        self.stdout.write("\n3. Checking include_all_users field...")
        
        if hasattr(Chat, 'include_all_employees'):
            warnings.append("Old field 'include_all_employees' still exists")
        
        if hasattr(Chat, 'include_all_users'):
            count = Chat.objects.filter(include_all_users=True).count()
            self.stdout.write(self.style.SUCCESS(
                f"   ✓ Field 'include_all_users' exists, {count} chats use it"
            ))
        else:
            errors.append("Field 'include_all_users' not found!")
        
        # Итоговый отчет
        self.stdout.write("\n" + "="*60)
        if errors:
            self.stdout.write(self.style.ERROR(f"❌ ERRORS: {len(errors)}"))
            for error in errors:
                self.stdout.write(self.style.ERROR(f"  - {error}"))
        else:
            self.stdout.write(self.style.SUCCESS("✅ NO ERRORS"))
        
        if warnings:
            self.stdout.write(self.style.WARNING(f"⚠️  WARNINGS: {len(warnings)}"))
            for warning in warnings:
                self.stdout.write(self.style.WARNING(f"  - {warning}"))
        
        self.stdout.write("="*60 + "\n")
        
        return 0 if not errors else 1

Запуск проверки:

.venv/Scripts/python manage.py verify_chat_migration

📊 Таблица: Что происходит с данными

Старое поле Новое поле Миграция Потеря данных? Обратимо?
department (FK) context_object (GFK) Копирование FK → GFK ❌ НЕТ ✅ ДА
is_main (Boolean) flags['is_primary'] (JSON) Копирование в JSON ❌ НЕТ ✅ ДА
include_all_employees include_all_users RENAME COLUMN ❌ НЕТ ✅ ДА
Constraints на department Убраны DROP CONSTRAINT ❌ НЕТ* ✅ ДА
Индекс на department Новый индекс на GFK ADD INDEX ❌ НЕТ ✅ ДА

*Constraints не содержат данных, только правила валидации


🛡️ План отката (Rollback Plan)

Если что-то пошло не так на production:

Вариант 1: Откат через Django migrations

# Откатиться на последнюю рабочую миграцию
.venv/Scripts/python manage.py migrate communications 0XXX_previous_working_migration

Вариант 2: Ручной откат (если старые поля сохранены)

# Восстановить department из context
Chat = Chat.objects.filter(
    context_content_type=dept_ct,
    context_object_id__isnull=False
)
for chat in chats:
    chat.department_id = chat.context_object_id
    chat.save()

# Восстановить is_main из flags
for chat in Chat.objects.all():
    if chat.flags.get('is_primary'):
        chat.is_main = True
        chat.save()

Вариант 3: Восстановление из backup

# Если делали backup перед миграцией
pg_restore -d eusrr_db backup_before_migration.dump

✅ Рекомендуемая последовательность развертывания

Этап 1: Development (локально)

  1. Создать миграции 1-5
  2. Запустить миграции
  3. Запустить verify_chat_migration
  4. Обновить code (models.py, views.py, signals.py)
  5. Запустить тесты
  6. Проверить вручную в интерфейсе

Этап 2: Staging (тестовый сервер)

  1. Сделать backup БД
  2. Применить миграции
  3. Запустить verification
  4. Задеплоить новый код
  5. Тестирование 1-2 недели

Этап 3: Production

  1. ⚠️ ОБЯЗАТЕЛЬНО сделать backup БД!
  2. Включить maintenance mode (опционально)
  3. Применить миграции 1-5 (БЕЗ удаления старых полей!)
  4. Задеплоить новый код
  5. Запустить verification
  6. Мониторинг 1-2 недели
  7. Только потом (через месяц) миграция 6 (удаление старых полей)

📈 Преимущества такого подхода

  1. Безопасность:

    • ✅ Никакие данные не удаляются
    • ✅ Старые поля сохраняются для отката
    • ✅ Можно откатить на любом этапе
  2. Постепенность:

    • ✅ Миграции маленькие и понятные
    • ✅ Каждая миграция проверяемая
    • ✅ Можно остановиться на любом этапе
  3. Обратимость:

    • ✅ Каждая миграция имеет reverse()
    • ✅ Можно откатить через migrate communications 0XXX
    • ✅ Данные дублируются до финальной очистки
  4. Тестируемость:

    • ✅ Можно проверить на staging
    • ✅ Есть команда verify_chat_migration
    • ✅ Можно сравнить данные до/после

🔥 Самое главное:

❌ НЕ ДЕЛАЙТЕ СРАЗУ:

# ❌ ПЛОХО - деструктивная миграция
migrations.RemoveField('chat', 'department')
migrations.RemoveField('chat', 'is_main')
# Данные потеряны навсегда!

✅ ДЕЛАЙТЕ ТАК:

# ✅ ХОРОШО - аддитивная миграция
migrations.AddField('chat', 'context_content_type', ...)
migrations.AddField('chat', 'context_object_id', ...)
migrations.AddField('chat', 'flags', ...)
# Старые поля остаются! Можно откатить!

📅 График миграции:

  1. Неделя 1: Добавить новые поля (миграции 1-2)
  2. Неделя 2: Скопировать данные (миграции 3-4)
  3. Неделя 3: Обновить код, тестирование
  4. Неделя 4: Deploy на staging
  5. Неделя 5-6: Тестирование на staging
  6. Неделя 7: Deploy на production
  7. Неделя 8-12: Мониторинг
  8. Месяц 3-4: Удаление старых полей (опционально)

🎯 Итоговый ответ на ваш вопрос:

Потеряются ли данные?

❌ НЕТ! Абсолютно ВСЕ данные сохраняются!

Можно ли аккуратно перенести?

✅ ДА! Через аддитивные миграции с копированием данных

Можно ли откатить?

✅ ДА! Старые поля остаются, миграции обратимы

Насколько это безопасно?

✅ ОЧЕНЬ БЕЗОПАСНО при правильном подходе (backup + поэтапное развертывание)


Хотите, чтобы я:

  1. Создал эти миграции прямо сейчас в вашем проекте?
  2. Создал команду verify_chat_migration для проверки?
  3. Показал пример backup/restore для вашей БД?
  4. Или сначала посчитаем сколько данных нужно мигрировать в вашей БД?