From f5ec8ffc8b06f9901ffaf3bb36bb042ff4ea287f Mon Sep 17 00:00:00 2001 From: lizerk Date: Wed, 11 Mar 2026 12:55:21 +0300 Subject: [PATCH] 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 --- docs/in-progress/CHAT_MIGRATION_PLAN.md | 662 ++++++++++++++++ .../CHAT_REFACTORING_COMPARISON.md | 739 ++++++++++++++++++ 2 files changed, 1401 insertions(+) create mode 100644 docs/in-progress/CHAT_MIGRATION_PLAN.md create mode 100644 docs/in-progress/CHAT_REFACTORING_COMPARISON.md diff --git a/docs/in-progress/CHAT_MIGRATION_PLAN.md b/docs/in-progress/CHAT_MIGRATION_PLAN.md new file mode 100644 index 00000000..f57385ec --- /dev/null +++ b/docs/in-progress/CHAT_MIGRATION_PLAN.md @@ -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. Или сначала **посчитаем сколько данных** нужно мигрировать в вашей БД? diff --git a/docs/in-progress/CHAT_REFACTORING_COMPARISON.md b/docs/in-progress/CHAT_REFACTORING_COMPARISON.md new file mode 100644 index 00000000..6ee31432 --- /dev/null +++ b/docs/in-progress/CHAT_REFACTORING_COMPARISON.md @@ -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` приложение.