Corp/docs/in-progress/CHAT_REFACTORING_COMPARISON.md
lizerk f5ec8ffc8b 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
2026-03-11 12:55:21 +03:00

27 KiB
Raw Blame History

Рефакторинг модели 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,
)

✅ ПРЕИМУЩЕСТВА РЕФАКТОРЕННОЙ ВЕРСИИ

  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:

  • Убрать 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 приложение.