- 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
27 KiB
27 KiB
Рефакторинг модели Chat: Сравнение
❌ ТЕКУЩАЯ ВЕРСИЯ (с бизнес-логикой EUSRR)
# 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
✅ РЕФАКТОРЕННАЯ ВЕРСИЯ (универсальная)
# 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)
# 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',
}
}
🔧 КАСТОМНАЯ ЛОГИКА В ВАШЕМ ПРОЕКТЕ
# 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 В ВАШЕМ ПРОЕКТЕ
# 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)
📊 МИГРАЦИЯ ДАННЫХ
Если нужно мигрировать существующие данные:
# 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',
),
]
🎯 ПРИМЕРЫ ИСПОЛЬЗОВАНИЯ
До (с бизнес-логикой):
# Создание чата отдела
chat = Chat.objects.create(
type='department',
name=f'Чат {dept.name}',
department=dept, # ← жесткая FK
is_main=True, # ← EUSRR флаг
)
# Получение участников
participants = chat.get_participants() # ← знает про EmployeeDepartment
После (универсально):
# Создание чата отдела
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,
)
✅ ПРЕИМУЩЕСТВА РЕФАКТОРЕННОЙ ВЕРСИИ
-
Универсальность:
- ✅ GenericForeignKey → можно привязать к ЛЮБОЙ модели (Department, Project, Team, Event, etc.)
- ✅ Динамические типы через settings → расширяемость
- ✅ JSONField для flags/extra_data → расширение без миграций
-
Нет жестких зависимостей:
- ✅ Нет import employees
- ✅ Нет FK на Department
- ✅ Нет знания о EmployeeDepartment, head_id, is_active
-
Гибкость:
- ✅ Кастомная логика через callbacks (CHAT_PARTICIPANTS_RESOLVER)
- ✅ Работает "из коробки" с базовой логикой
- ✅ Расширяется без изменения кода пакета
-
Переиспользуемость:
- ✅ Можно использовать в HR-системе (Department)
- ✅ Можно использовать в Project Management (Project, Team)
- ✅ Можно использовать в Event Management (Event, Conference)
- ✅ Можно использовать в E-commerce (Order, Support)
-
Совместимость:
- ✅ Миграция данных без потери
- ✅ Можно оставить старые поля для совместимости
- ✅ Постепенный переход
📋 ЧЕКЛИСТ ИЗМЕНЕНИЙ
В модели Chat:
- Убрать
from employees.models import Department, EmployeeDepartment - Заменить
department = FK(Department)→context_object = GenericForeignKey() - Переименовать
include_all_employees→include_all_users - Заменить
is_main→flags = JSONField()с{'is_primary': True} - Добавить
extra_data = JSONField()для расширений - Рефакторинг
get_participants()→ hook с callback - Убрать типы "department", "global" из CHAT_TYPE_CHOICES → сделать CORE_CHAT_TYPES
- Добавить
get_chat_type_choices()для динамических типов - Убрать department-специфичные constraints
- Изменить
__str__()для универсальности - Увеличить 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 приложение.