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:
lizerk 2026-03-11 12:55:21 +03:00
parent 9dc0fdb848
commit f5ec8ffc8b
2 changed files with 1401 additions and 0 deletions

View 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. Или сначала **посчитаем сколько данных** нужно мигрировать в вашей БД?

View 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` приложение.