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
This commit is contained in:
parent
9dc0fdb848
commit
f5ec8ffc8b
662
docs/in-progress/CHAT_MIGRATION_PLAN.md
Normal file
662
docs/in-progress/CHAT_MIGRATION_PLAN.md
Normal file
@ -0,0 +1,662 @@
|
||||
# План миграции данных Chat без потерь
|
||||
|
||||
## ✅ **ГАРАНТИЯ: Все данные сохраняются!**
|
||||
|
||||
Миграция будет **аддитивной** (добавляющей), а не деструктивной. Старые поля можно оставить для совместимости или удалить позже.
|
||||
|
||||
---
|
||||
|
||||
## 📊 **Анализ: Какие данные нужно мигрировать**
|
||||
|
||||
### Текущие EUSRR-специфичные поля в Chat:
|
||||
|
||||
```python
|
||||
# 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)
|
||||
```
|
||||
|
||||
Давайте проверим, сколько у вас данных:
|
||||
|
||||
```bash
|
||||
# Проверка количества чатов с 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: Добавление новых полей**
|
||||
|
||||
```python
|
||||
# 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**
|
||||
|
||||
```python
|
||||
# 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']**
|
||||
|
||||
```python
|
||||
# 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**
|
||||
|
||||
```python
|
||||
# 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 (безопасно)**
|
||||
|
||||
```python
|
||||
# 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 (ОПЦИОНАЛЬНАЯ): Удаление старых полей**
|
||||
|
||||
**⚠️ ВАЖНО:** Эту миграцию можно делать через недели/месяцы после развертывания!
|
||||
|
||||
```python
|
||||
# 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, запустите проверку:
|
||||
|
||||
```python
|
||||
# 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
|
||||
```
|
||||
|
||||
Запуск проверки:
|
||||
```bash
|
||||
.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**
|
||||
```bash
|
||||
# Откатиться на последнюю рабочую миграцию
|
||||
.venv/Scripts/python manage.py migrate communications 0XXX_previous_working_migration
|
||||
```
|
||||
|
||||
**Вариант 2: Ручной откат (если старые поля сохранены)**
|
||||
```python
|
||||
# Восстановить 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**
|
||||
```bash
|
||||
# Если делали 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
|
||||
- ✅ Можно сравнить данные до/после
|
||||
|
||||
---
|
||||
|
||||
## 🔥 **Самое главное:**
|
||||
|
||||
### ❌ **НЕ ДЕЛАЙТЕ СРАЗУ:**
|
||||
```python
|
||||
# ❌ ПЛОХО - деструктивная миграция
|
||||
migrations.RemoveField('chat', 'department')
|
||||
migrations.RemoveField('chat', 'is_main')
|
||||
# Данные потеряны навсегда!
|
||||
```
|
||||
|
||||
### ✅ **ДЕЛАЙТЕ ТАК:**
|
||||
```python
|
||||
# ✅ ХОРОШО - аддитивная миграция
|
||||
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. Или сначала **посчитаем сколько данных** нужно мигрировать в вашей БД?
|
||||
739
docs/in-progress/CHAT_REFACTORING_COMPARISON.md
Normal file
739
docs/in-progress/CHAT_REFACTORING_COMPARISON.md
Normal file
@ -0,0 +1,739 @@
|
||||
# Рефакторинг модели Chat: Сравнение
|
||||
|
||||
## ❌ ТЕКУЩАЯ ВЕРСИЯ (с бизнес-логикой EUSRR)
|
||||
|
||||
```python
|
||||
# backend/communications/models.py (СТАРАЯ ВЕРСИЯ)
|
||||
|
||||
from employees.models import Department, EmployeeDepartment # ← жесткая зависимость
|
||||
|
||||
class Chat(models.Model):
|
||||
# ❌ Специфичные типы для EUSRR
|
||||
CHAT_TYPE_CHOICES = [
|
||||
("private", "Личный"),
|
||||
("group", "Групповой"),
|
||||
("department", "Отдел"), # ← EUSRR специфично
|
||||
("channel", "Канал"),
|
||||
("announcement", "Объявления"),
|
||||
("global", "Глобальный"), # ← EUSRR специфично
|
||||
]
|
||||
|
||||
type = models.CharField(max_length=16, choices=CHAT_TYPE_CHOICES, db_index=True)
|
||||
name = models.CharField(max_length=255, blank=True)
|
||||
description = models.TextField(blank=True)
|
||||
avatar = models.ImageField(upload_to='chat_avatars/%Y/%m/', null=True, blank=True)
|
||||
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, ...)
|
||||
|
||||
# ❌ Специфичное название поля
|
||||
include_all_employees = models.BooleanField(default=False) # ← "employees" специфично
|
||||
|
||||
participants = models.ManyToManyField(settings.AUTH_USER_MODEL, blank=True, related_name="chats")
|
||||
|
||||
# ❌ Жесткая FK на Department
|
||||
department = models.ForeignKey(
|
||||
Department, # ← жесткая зависимость от employees app
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
# ❌ EUSRR-специфичное поле
|
||||
is_main = models.BooleanField(default=False) # ← бизнес-правило EUSRR
|
||||
|
||||
is_blocked = models.BooleanField(default=False)
|
||||
blocked_at = models.DateTimeField(null=True, blank=True)
|
||||
blocked_by = models.ForeignKey(settings.AUTH_USER_MODEL, ...)
|
||||
can_reply = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
# ❌ EUSRR-специфичные constraints
|
||||
models.UniqueConstraint(
|
||||
fields=["type"],
|
||||
condition=Q(is_main=True, type="global"),
|
||||
name="unique_main_global_chat",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["type", "department"], # ← привязка к Department
|
||||
condition=Q(is_main=True, type="department"),
|
||||
name="unique_main_department_chat",
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["type", "created_by"],
|
||||
condition=Q(type="announcement"),
|
||||
name="unique_announcement_per_user",
|
||||
),
|
||||
]
|
||||
indexes = [
|
||||
models.Index(fields=["department"]), # ← department-специфичный индекс
|
||||
...
|
||||
]
|
||||
|
||||
# ❌ Жесткая бизнес-логика в методе
|
||||
@property
|
||||
def get_participants(self):
|
||||
if self.type == "department" and self.department_id:
|
||||
# ← Знает про EmployeeDepartment, Department.head_id
|
||||
employee_ids = EmployeeDepartment.objects.filter(
|
||||
department_id=self.department_id, is_active=True
|
||||
).values_list("employee_id", flat=True)
|
||||
return Employee.objects.filter(
|
||||
Q(id__in=employee_ids) | Q(id=self.department.head_id)
|
||||
).distinct()
|
||||
|
||||
if self.type == "global":
|
||||
# ← Знает про Employee.is_active
|
||||
return Employee.objects.filter(is_active=True)
|
||||
|
||||
if self.type in ["announcement", "channel"]:
|
||||
if self.include_all_employees: # ← специфичное название
|
||||
return Employee.objects.filter(is_active=True)
|
||||
...
|
||||
```
|
||||
|
||||
**Проблемы:**
|
||||
- ❌ Импорт `Department`, `EmployeeDepartment` - жесткая зависимость
|
||||
- ❌ FK на `Department` - нельзя использовать с другими моделями
|
||||
- ❌ Типы "department", "global" - специфичны для HR-систем
|
||||
- ❌ `include_all_employees` - специфичное название
|
||||
- ❌ `is_main` - бизнес-правило EUSRR
|
||||
- ❌ Constraints привязаны к `department`
|
||||
- ❌ `get_participants()` знает про `EmployeeDepartment`, `head_id`, `is_active`
|
||||
|
||||
---
|
||||
|
||||
## ✅ РЕФАКТОРЕННАЯ ВЕРСИЯ (универсальная)
|
||||
|
||||
```python
|
||||
# backend/communications/models.py (НОВАЯ ВЕРСИЯ)
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
class Chat(models.Model):
|
||||
"""
|
||||
Универсальная модель чата для любых Django проектов.
|
||||
|
||||
Поддерживает:
|
||||
- Личные чаты (1-на-1 или группа)
|
||||
- Групповые чаты с ролями
|
||||
- Каналы (broadcast)
|
||||
- Объявления
|
||||
- Привязку к любым моделям через GenericForeignKey (опционально)
|
||||
|
||||
Расширяется через:
|
||||
- Дополнительные типы через settings.CHAT_EXTRA_TYPES
|
||||
- Callbacks для получения участников
|
||||
- Plugins для кастомной логики
|
||||
"""
|
||||
|
||||
# ✅ Базовые универсальные типы (минимальный набор)
|
||||
CORE_CHAT_TYPES = [
|
||||
("private", "Private"), # Личный чат (1-на-1 или мини-группа)
|
||||
("group", "Group"), # Групповой чат с управлением
|
||||
("channel", "Channel"), # Канал (односторонняя трансляция)
|
||||
("announcement", "Announcement"), # Объявления
|
||||
]
|
||||
|
||||
type = models.CharField(
|
||||
max_length=32, # ✅ увеличили для кастомных типов
|
||||
db_index=True,
|
||||
help_text="Chat type: private, group, channel, announcement, or custom"
|
||||
)
|
||||
|
||||
# Основные поля
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
verbose_name="Chat name",
|
||||
help_text="Display name for group/channel chats"
|
||||
)
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
verbose_name="Description"
|
||||
)
|
||||
avatar = models.ImageField(
|
||||
upload_to='chat_avatars/%Y/%m/',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Avatar"
|
||||
)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='created_chats',
|
||||
verbose_name="Creator"
|
||||
)
|
||||
|
||||
# ✅ Универсальное название вместо include_all_employees
|
||||
include_all_users = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Include all users",
|
||||
help_text="Auto-include all active users (for announcements/broadcasts)"
|
||||
)
|
||||
|
||||
# Прямые участники (M2M)
|
||||
participants = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
related_name="chats",
|
||||
verbose_name="Participants",
|
||||
help_text="Direct participants (for private chats or explicit membership)"
|
||||
)
|
||||
|
||||
# ✅ GenericForeignKey вместо жесткой FK на Department
|
||||
# Позволяет привязать чат к ЛЮБОЙ модели (Department, Project, Team, etc.)
|
||||
context_content_type = models.ForeignKey(
|
||||
ContentType,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
verbose_name="Context type",
|
||||
help_text="Type of related object (e.g., Department, Project, Team)"
|
||||
)
|
||||
context_object_id = models.PositiveIntegerField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Context object ID"
|
||||
)
|
||||
context_object = GenericForeignKey(
|
||||
'context_content_type',
|
||||
'context_object_id'
|
||||
)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created at")
|
||||
|
||||
# ✅ Опциональное поле для любых флагов (вместо is_main)
|
||||
# Значение интерпретируется в вашем проекте
|
||||
flags = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
verbose_name="Flags",
|
||||
help_text="Custom flags: {'is_primary': true, 'is_archived': false, etc.}"
|
||||
)
|
||||
|
||||
# Модерация
|
||||
is_blocked = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name="Blocked",
|
||||
help_text="Chat blocked by admin"
|
||||
)
|
||||
blocked_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name="Blocked at"
|
||||
)
|
||||
blocked_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='blocked_chats',
|
||||
verbose_name="Blocked by"
|
||||
)
|
||||
can_reply = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name="Can reply",
|
||||
help_text="Allow replies (False for read-only announcements)"
|
||||
)
|
||||
|
||||
# ✅ Дополнительные данные для расширения без миграций
|
||||
extra_data = models.JSONField(
|
||||
default=dict,
|
||||
blank=True,
|
||||
verbose_name="Extra data",
|
||||
help_text="Additional chat metadata (extensible)"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Chat"
|
||||
verbose_name_plural = "Chats"
|
||||
indexes = [
|
||||
models.Index(fields=["type", "created_at"]),
|
||||
models.Index(fields=["context_content_type", "context_object_id"]),
|
||||
models.Index(fields=["created_at"]),
|
||||
]
|
||||
# ✅ Только универсальный constraint
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["type", "created_by"],
|
||||
condition=Q(type="announcement"),
|
||||
name="unique_announcement_per_user",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
"""Универсальное строковое представление"""
|
||||
if self.name:
|
||||
return self.name
|
||||
|
||||
if self.type == "private":
|
||||
# Показываем первых участников
|
||||
participant_names = [
|
||||
u.get_full_name() or u.username
|
||||
for u in self.participants.all()[:3]
|
||||
]
|
||||
if participant_names:
|
||||
names = ", ".join(participant_names)
|
||||
count = self.participants.count()
|
||||
if count > 3:
|
||||
names += f" +{count - 3} more"
|
||||
return f"Private: {names}"
|
||||
return "Private chat"
|
||||
|
||||
# Для типов с context_object
|
||||
if self.context_object:
|
||||
return f"{self.get_type_display()}: {self.context_object}"
|
||||
|
||||
return f"{self.get_type_display()} chat"
|
||||
|
||||
# ✅ Hook-метод для получения участников
|
||||
def get_participants(self):
|
||||
"""
|
||||
Получить всех участников чата.
|
||||
|
||||
Логика:
|
||||
1. Если настроен CHAT_PARTICIPANTS_RESOLVER - вызываем его
|
||||
2. Иначе используем базовую логику:
|
||||
- private/group: participants + memberships
|
||||
- announcement/channel: include_all_users или participants
|
||||
|
||||
Переопределите через settings.CHAT_PARTICIPANTS_RESOLVER
|
||||
для кастомной логики (например, для department чатов).
|
||||
"""
|
||||
# Пробуем callback из settings
|
||||
resolver = getattr(settings, 'CHAT_PARTICIPANTS_RESOLVER', None)
|
||||
if resolver:
|
||||
try:
|
||||
resolver_func = import_string(resolver)
|
||||
result = resolver_func(self)
|
||||
if result is not None:
|
||||
return result
|
||||
except (ImportError, AttributeError) as e:
|
||||
import logging
|
||||
logging.warning(f"Failed to import CHAT_PARTICIPANTS_RESOLVER: {e}")
|
||||
|
||||
# Базовая логика (работает без настройки)
|
||||
return self._get_default_participants()
|
||||
|
||||
def _get_default_participants(self):
|
||||
"""
|
||||
Дефолтная логика получения участников (без внешних зависимостей).
|
||||
"""
|
||||
User = get_user_model()
|
||||
|
||||
if self.type in ["private", "group"]:
|
||||
# Прямые участники + активные membership
|
||||
from communications.models import ChatMembership
|
||||
membership_ids = ChatMembership.objects.filter(
|
||||
chat=self,
|
||||
is_active=True
|
||||
).values_list("user_id", flat=True)
|
||||
|
||||
return User.objects.filter(
|
||||
Q(id__in=self.participants.values_list('id', flat=True)) |
|
||||
Q(id__in=membership_ids)
|
||||
).distinct()
|
||||
|
||||
if self.type in ["announcement", "channel"]:
|
||||
# Трансляция: все активные или явные участники
|
||||
if self.include_all_users:
|
||||
# ✅ Используем is_active если есть, иначе все
|
||||
if hasattr(User, 'is_active'):
|
||||
return User.objects.filter(is_active=True)
|
||||
return User.objects.all()
|
||||
|
||||
# Только явные участники + memberships
|
||||
from communications.models import ChatMembership
|
||||
membership_ids = ChatMembership.objects.filter(
|
||||
chat=self
|
||||
).values_list("user_id", flat=True)
|
||||
|
||||
return User.objects.filter(
|
||||
Q(id__in=self.participants.values_list('id', flat=True)) |
|
||||
Q(id__in=membership_ids)
|
||||
).distinct()
|
||||
|
||||
# Для неизвестных типов - только participants
|
||||
return self.participants.all()
|
||||
|
||||
@classmethod
|
||||
def get_chat_type_choices(cls):
|
||||
"""
|
||||
Динамические choices для типов чатов.
|
||||
Объединяет базовые типы + кастомные из settings.
|
||||
"""
|
||||
choices = list(cls.CORE_CHAT_TYPES)
|
||||
|
||||
# Добавляем кастомные типы из settings
|
||||
extra_types = getattr(settings, 'CHAT_EXTRA_TYPES', [])
|
||||
if extra_types:
|
||||
choices.extend(extra_types)
|
||||
|
||||
return choices
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Валидация типа чата"""
|
||||
valid_types = [t[0] for t in self.get_chat_type_choices()]
|
||||
if self.type not in valid_types:
|
||||
raise ValueError(
|
||||
f"Invalid chat type '{self.type}'. "
|
||||
f"Valid types: {', '.join(valid_types)}"
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ НАСТРОЙКА В ВАШЕМ ПРОЕКТЕ (settings.py)
|
||||
|
||||
```python
|
||||
# backend/eusrr_backend/settings.py
|
||||
|
||||
# ✅ Добавляем EUSRR-специфичные типы чатов
|
||||
CHAT_EXTRA_TYPES = [
|
||||
('department', 'Department'),
|
||||
('global', 'Global Company'),
|
||||
]
|
||||
|
||||
# ✅ Callback для получения участников специальных типов
|
||||
CHAT_PARTICIPANTS_RESOLVER = 'employees.utils.resolve_chat_participants'
|
||||
|
||||
# Опционально: callback для сжатия аватаров
|
||||
CHAT_AVATAR_COMPRESSOR = 'common.image_utils.compress_avatar'
|
||||
|
||||
# Опционально: автосоздание чатов
|
||||
CHAT_AUTO_CREATE_FOR_MODELS = {
|
||||
'employees.Department': {
|
||||
'chat_type': 'department',
|
||||
'callback': 'employees.utils.create_department_chat',
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 КАСТОМНАЯ ЛОГИКА В ВАШЕМ ПРОЕКТЕ
|
||||
|
||||
```python
|
||||
# backend/employees/utils.py
|
||||
|
||||
def resolve_chat_participants(chat):
|
||||
"""
|
||||
EUSRR-специфичная логика получения участников.
|
||||
Вызывается через CHAT_PARTICIPANTS_RESOLVER.
|
||||
"""
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db.models import Q
|
||||
from employees.models import Department, EmployeeDepartment
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# Чаты отделов
|
||||
if chat.type == 'department':
|
||||
# Получаем Department из context_object
|
||||
department = chat.context_object
|
||||
if isinstance(department, Department):
|
||||
# Ваша бизнес-логика
|
||||
employee_ids = EmployeeDepartment.objects.filter(
|
||||
department=department,
|
||||
is_active=True
|
||||
).values_list("employee_id", flat=True)
|
||||
|
||||
return User.objects.filter(
|
||||
Q(id__in=employee_ids) | Q(id=department.head_id)
|
||||
).distinct()
|
||||
|
||||
# Глобальный чат компании
|
||||
if chat.type == 'global':
|
||||
return User.objects.filter(is_active=True)
|
||||
|
||||
# Для остальных типов - вернуть None (будет использован default)
|
||||
return None
|
||||
|
||||
|
||||
def create_department_chat(department):
|
||||
"""
|
||||
Создание чата при создании отдела.
|
||||
Вызывается из signal в вашем проекте.
|
||||
"""
|
||||
from communications.models import Chat
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
# Проверяем, есть ли уже главный чат
|
||||
ct = ContentType.objects.get_for_model(department)
|
||||
existing = Chat.objects.filter(
|
||||
type='department',
|
||||
context_content_type=ct,
|
||||
context_object_id=department.id,
|
||||
flags__is_primary=True # используем flags вместо is_main
|
||||
).exists()
|
||||
|
||||
if not existing:
|
||||
Chat.objects.create(
|
||||
type='department',
|
||||
name=f"Чат {department.name}",
|
||||
context_object=department, # GFK автоматически заполнит content_type и object_id
|
||||
flags={'is_primary': True}, # EUSRR-специфичный флаг
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 SIGNALS В ВАШЕМ ПРОЕКТЕ
|
||||
|
||||
```python
|
||||
# backend/employees/signals.py
|
||||
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from employees.models import Department
|
||||
|
||||
@receiver(post_save, sender=Department)
|
||||
def create_department_chat(sender, instance, created, **kwargs):
|
||||
"""Автосоздание чата для нового отдела"""
|
||||
if created:
|
||||
from employees.utils import create_department_chat as create_chat
|
||||
create_chat(instance)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 МИГРАЦИЯ ДАННЫХ
|
||||
|
||||
Если нужно мигрировать существующие данные:
|
||||
|
||||
```python
|
||||
# backend/communications/migrations/XXXX_migrate_to_generic_fk.py
|
||||
|
||||
from django.db import migrations
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
|
||||
def migrate_department_to_context(apps, schema_editor):
|
||||
"""Миграция department FK → context GenericFK"""
|
||||
Chat = apps.get_model('communications', 'Chat')
|
||||
Department = apps.get_model('employees', 'Department')
|
||||
|
||||
department_ct = ContentType.objects.get_for_model(Department)
|
||||
|
||||
for chat in Chat.objects.filter(department__isnull=False):
|
||||
chat.context_content_type = department_ct
|
||||
chat.context_object_id = chat.department_id
|
||||
chat.save(update_fields=['context_content_type', 'context_object_id'])
|
||||
|
||||
|
||||
def migrate_is_main_to_flags(apps, schema_editor):
|
||||
"""Миграция is_main → flags"""
|
||||
Chat = apps.get_model('communications', 'Chat')
|
||||
|
||||
for chat in Chat.objects.filter(is_main=True):
|
||||
flags = chat.flags or {}
|
||||
flags['is_primary'] = True
|
||||
chat.flags = flags
|
||||
chat.save(update_fields=['flags'])
|
||||
|
||||
|
||||
def migrate_include_all_employees(apps, schema_editor):
|
||||
"""Переименование поля уже в миграции"""
|
||||
# Это делается через AlterField, не через data migration
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('communications', 'YYYY_previous_migration'),
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# 1. Добавляем новые поля
|
||||
migrations.AddField(
|
||||
model_name='chat',
|
||||
name='context_content_type',
|
||||
field=models.ForeignKey(...),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chat',
|
||||
name='context_object_id',
|
||||
field=models.PositiveIntegerField(...),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chat',
|
||||
name='flags',
|
||||
field=models.JSONField(default=dict, blank=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='chat',
|
||||
name='extra_data',
|
||||
field=models.JSONField(default=dict, blank=True),
|
||||
),
|
||||
|
||||
# 2. Мигрируем данные
|
||||
migrations.RunPython(
|
||||
migrate_department_to_context,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
),
|
||||
migrations.RunPython(
|
||||
migrate_is_main_to_flags,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
),
|
||||
|
||||
# 3. Переименовываем поля
|
||||
migrations.RenameField(
|
||||
model_name='chat',
|
||||
old_name='include_all_employees',
|
||||
new_name='include_all_users',
|
||||
),
|
||||
|
||||
# 4. Удаляем старые поля (опционально, можно оставить для совместимости)
|
||||
# migrations.RemoveField(
|
||||
# model_name='chat',
|
||||
# name='department',
|
||||
# ),
|
||||
# migrations.RemoveField(
|
||||
# model_name='chat',
|
||||
# name='is_main',
|
||||
# ),
|
||||
|
||||
# 5. Удаляем старые constraints
|
||||
migrations.RemoveConstraint(
|
||||
model_name='chat',
|
||||
name='unique_main_department_chat',
|
||||
),
|
||||
migrations.RemoveConstraint(
|
||||
model_name='chat',
|
||||
name='unique_main_global_chat',
|
||||
),
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ
|
||||
|
||||
### До (с бизнес-логикой):
|
||||
```python
|
||||
# Создание чата отдела
|
||||
chat = Chat.objects.create(
|
||||
type='department',
|
||||
name=f'Чат {dept.name}',
|
||||
department=dept, # ← жесткая FK
|
||||
is_main=True, # ← EUSRR флаг
|
||||
)
|
||||
|
||||
# Получение участников
|
||||
participants = chat.get_participants() # ← знает про EmployeeDepartment
|
||||
```
|
||||
|
||||
### После (универсально):
|
||||
```python
|
||||
# Создание чата отдела
|
||||
chat = Chat.objects.create(
|
||||
type='department', # ← тип из CHAT_EXTRA_TYPES
|
||||
name=f'Чат {dept.name}',
|
||||
context_object=dept, # ← GenericFK (любая модель!)
|
||||
flags={'is_primary': True}, # ← гибкие флаги
|
||||
)
|
||||
|
||||
# Получение участников
|
||||
participants = chat.get_participants() # ← вызовет ваш resolver
|
||||
|
||||
# Создание чата проекта (новое!)
|
||||
chat = Chat.objects.create(
|
||||
type='project', # ← можно добавить в CHAT_EXTRA_TYPES
|
||||
name=f'Проект {project.name}',
|
||||
context_object=project, # ← работает с любой моделью!
|
||||
)
|
||||
|
||||
# Создание чата команды (новое!)
|
||||
chat = Chat.objects.create(
|
||||
type='team',
|
||||
name=f'Команда {team.name}',
|
||||
context_object=team,
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ ПРЕИМУЩЕСТВА РЕФАКТОРЕННОЙ ВЕРСИИ
|
||||
|
||||
1. **Универсальность:**
|
||||
- ✅ GenericForeignKey → можно привязать к ЛЮБОЙ модели (Department, Project, Team, Event, etc.)
|
||||
- ✅ Динамические типы через settings → расширяемость
|
||||
- ✅ JSONField для flags/extra_data → расширение без миграций
|
||||
|
||||
2. **Нет жестких зависимостей:**
|
||||
- ✅ Нет import employees
|
||||
- ✅ Нет FK на Department
|
||||
- ✅ Нет знания о EmployeeDepartment, head_id, is_active
|
||||
|
||||
3. **Гибкость:**
|
||||
- ✅ Кастомная логика через callbacks (CHAT_PARTICIPANTS_RESOLVER)
|
||||
- ✅ Работает "из коробки" с базовой логикой
|
||||
- ✅ Расширяется без изменения кода пакета
|
||||
|
||||
4. **Переиспользуемость:**
|
||||
- ✅ Можно использовать в HR-системе (Department)
|
||||
- ✅ Можно использовать в Project Management (Project, Team)
|
||||
- ✅ Можно использовать в Event Management (Event, Conference)
|
||||
- ✅ Можно использовать в E-commerce (Order, Support)
|
||||
|
||||
5. **Совместимость:**
|
||||
- ✅ Миграция данных без потери
|
||||
- ✅ Можно оставить старые поля для совместимости
|
||||
- ✅ Постепенный переход
|
||||
|
||||
---
|
||||
|
||||
## 📋 ЧЕКЛИСТ ИЗМЕНЕНИЙ
|
||||
|
||||
### В модели Chat:
|
||||
- [x] Убрать `from employees.models import Department, EmployeeDepartment`
|
||||
- [x] Заменить `department = FK(Department)` → `context_object = GenericForeignKey()`
|
||||
- [x] Переименовать `include_all_employees` → `include_all_users`
|
||||
- [x] Заменить `is_main` → `flags = JSONField()` с `{'is_primary': True}`
|
||||
- [x] Добавить `extra_data = JSONField()` для расширений
|
||||
- [x] Рефакторинг `get_participants()` → hook с callback
|
||||
- [x] Убрать типы "department", "global" из CHAT_TYPE_CHOICES → сделать CORE_CHAT_TYPES
|
||||
- [x] Добавить `get_chat_type_choices()` для динамических типов
|
||||
- [x] Убрать department-специфичные constraints
|
||||
- [x] Изменить `__str__()` для универсальности
|
||||
- [x] Увеличить max_length типа с 16 до 32
|
||||
|
||||
### В других файлах:
|
||||
- [ ] Создать `employees/utils.py` с `resolve_chat_participants()` и `create_department_chat()`
|
||||
- [ ] Переместить signal из `communications/signals.py` в `employees/signals.py`
|
||||
- [ ] Добавить settings в `settings.py`: `CHAT_EXTRA_TYPES`, `CHAT_PARTICIPANTS_RESOLVER`
|
||||
- [ ] Создать миграцию для GenericFK и переименования полей
|
||||
- [ ] Обновить views.py (удалить `from employees.models import Department`)
|
||||
- [ ] Обновить serializers.py
|
||||
- [ ] Обновить тесты
|
||||
|
||||
---
|
||||
|
||||
## 🚀 ИТОГ
|
||||
|
||||
**Убрали:**
|
||||
- ❌ Все импорты employees
|
||||
- ❌ FK на Department
|
||||
- ❌ Жесткие типы "department", "global"
|
||||
- ❌ Поле include_all_employees
|
||||
- ❌ Поле is_main
|
||||
- ❌ Department-специфичные constraints
|
||||
- ❌ Знание о EmployeeDepartment, head_id
|
||||
|
||||
**Добавили:**
|
||||
- ✅ GenericForeignKey для универсальной привязки
|
||||
- ✅ JSONField flags для гибких флагов
|
||||
- ✅ JSONField extra_data для расширений
|
||||
- ✅ Callback-систему для get_participants()
|
||||
- ✅ Динамические типы через settings
|
||||
- ✅ Базовую логику, работающую "из коробки"
|
||||
|
||||
**Результат:**
|
||||
Модель Chat теперь **полностью универсальна** и может использоваться в ЛЮБОМ Django проекте, а ваша EUSRR-специфичная логика вынесена в `employees` приложение.
|
||||
Loading…
x
Reference in New Issue
Block a user