- 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
23 KiB
План миграции данных 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-5
- Запустить миграции
- Запустить
verify_chat_migration - Обновить code (models.py, views.py, signals.py)
- Запустить тесты
- Проверить вручную в интерфейсе
Этап 2: Staging (тестовый сервер)
- Сделать backup БД
- Применить миграции
- Запустить verification
- Задеплоить новый код
- Тестирование 1-2 недели
Этап 3: Production
- ⚠️ ОБЯЗАТЕЛЬНО сделать backup БД!
- Включить maintenance mode (опционально)
- Применить миграции 1-5 (БЕЗ удаления старых полей!)
- Задеплоить новый код
- Запустить verification
- Мониторинг 1-2 недели
- Только потом (через месяц) миграция 6 (удаление старых полей)
📈 Преимущества такого подхода
-
Безопасность:
- ✅ Никакие данные не удаляются
- ✅ Старые поля сохраняются для отката
- ✅ Можно откатить на любом этапе
-
Постепенность:
- ✅ Миграции маленькие и понятные
- ✅ Каждая миграция проверяемая
- ✅ Можно остановиться на любом этапе
-
Обратимость:
- ✅ Каждая миграция имеет reverse()
- ✅ Можно откатить через
migrate communications 0XXX - ✅ Данные дублируются до финальной очистки
-
Тестируемость:
- ✅ Можно проверить на 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-2)
- Неделя 2: Скопировать данные (миграции 3-4)
- Неделя 3: Обновить код, тестирование
- Неделя 4: Deploy на staging
- Неделя 5-6: Тестирование на staging
- Неделя 7: Deploy на production
- Неделя 8-12: Мониторинг
- Месяц 3-4: Удаление старых полей (опционально)
🎯 Итоговый ответ на ваш вопрос:
Потеряются ли данные?
❌ НЕТ! Абсолютно ВСЕ данные сохраняются!
Можно ли аккуратно перенести?
✅ ДА! Через аддитивные миграции с копированием данных
Можно ли откатить?
✅ ДА! Старые поля остаются, миграции обратимы
Насколько это безопасно?
✅ ОЧЕНЬ БЕЗОПАСНО при правильном подходе (backup + поэтапное развертывание)
Хотите, чтобы я:
- Создал эти миграции прямо сейчас в вашем проекте?
- Создал команду verify_chat_migration для проверки?
- Показал пример backup/restore для вашей БД?
- Или сначала посчитаем сколько данных нужно мигрировать в вашей БД?