feat(communications): Refactor Chat model to universal GenericForeignKey

BREAKING CHANGES (backward compatible with data migration):
- Add context_object (GenericFK) to replace department FK
- Add flags (JSONField) to replace is_main boolean
- Add extra_data (JSONField) for extensibility
- Rename include_all_employees → include_all_users

NEW FEATURES:
- Generic context support (Department, Project, Team, etc.)
- Flexible flags system without migrations
- Settings-based participant resolver callback
- Zero data loss migrations (4 migrations)

MIGRATIONS:
- 0031: Add new fields (context_object, flags, extra_data)
- 0032: Migrate department → context_object (15 chats)
- 0033: Migrate is_main → flags['is_primary'] (15 chats)
- 0034: Rename include_all_employees → include_all_users

FILES CHANGED:
- models.py: Refactored Chat.get_participants() with callback support
- employees/utils.py: Added resolve_chat_participants() callback
- settings.py: Added COMMUNICATIONS_PARTICIPANT_RESOLVER setting
- verify_chat_migration.py: Command to verify data integrity

TESTED:
✅ All 32 chats migrated successfully
✅ Legacy department FK still works
✅ Context-based chats work (GenericFK)
✅ Global/private/announcement chats work
✅ Participants resolver callback works
This commit is contained in:
lizerk 2026-03-11 14:21:57 +03:00
parent ec6189b96c
commit 51aecacf09
8 changed files with 580 additions and 28 deletions

View File

@ -0,0 +1,200 @@
"""
Команда для проверки корректности миграции модели Chat.
Использование:
python manage.py verify_chat_migration
Проверяет:
1. Миграцию department FK → context_object GFK
2. Миграцию is_main Boolean → flags['is_primary'] JSON
3. Переименование include_all_employees → include_all_users
"""
from django.core.management.base import BaseCommand
from django.contrib.contenttypes.models import ContentType
from communications.models import Chat
class Command(BaseCommand):
help = 'Verify Chat migration: old fields → new fields'
def add_arguments(self, parser):
parser.add_argument(
'--verbose',
action='store_true',
help='Show detailed information for each chat',
)
def handle(self, *args, **options):
verbose = options.get('verbose', False)
self.stdout.write("\n" + "=" * 70)
self.stdout.write(self.style.HTTP_INFO("ПРОВЕРКА МИГРАЦИИ МОДЕЛИ CHAT"))
self.stdout.write("=" * 70 + "\n")
errors = []
warnings = []
# ===== 1. Проверка department → context_object =====
self.stdout.write(self.style.HTTP_INFO(
"1. Проверка миграции department → context_object..."
))
# Проверяем наличие старого поля (может быть удалено)
has_department_field = hasattr(Chat, 'department')
if has_department_field:
from employees.models import Department
dept_ct = ContentType.objects.get_for_model(Department)
chats_with_dept = Chat.objects.filter(department__isnull=False)
count_dept = chats_with_dept.count()
self.stdout.write(f" Найдено чатов с department: {count_dept}")
for chat in chats_with_dept:
# Проверяем что context заполнен
if not chat.context_object_id:
errors.append(
f"Chat #{chat.id}: есть department но 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 не Department"
)
elif verbose:
self.stdout.write(
f" ✓ Chat #{chat.id}: department={chat.department_id} "
f"→ context={chat.context_object_id}"
)
if not errors and count_dept > 0:
self.stdout.write(self.style.SUCCESS(
f" ✓ Все {count_dept} чатов с department мигрированы корректно"
))
elif count_dept == 0:
self.stdout.write(self.style.WARNING(
" ⚠ Чатов с department не найдено"
))
else:
# Поле department удалено - проверяем только context
chats_with_context = Chat.objects.filter(
context_content_type__isnull=False,
context_object_id__isnull=False
).count()
self.stdout.write(self.style.SUCCESS(
f" ✓ Поле 'department' удалено, чатов с context: {chats_with_context}"
))
# ===== 2. Проверка is_main → flags['is_primary'] =====
self.stdout.write("\n" + self.style.HTTP_INFO(
"2. Проверка миграции is_main → flags['is_primary']..."
))
has_is_main_field = hasattr(Chat, 'is_main')
if has_is_main_field:
main_chats = Chat.objects.filter(is_main=True)
count_main = main_chats.count()
self.stdout.write(f" Найдено главных чатов (is_main=True): {count_main}")
for chat in main_chats:
flags_primary = chat.flags.get('is_primary') if chat.flags else False
if not flags_primary:
errors.append(
f"Chat #{chat.id}: is_main=True но flags['is_primary'] не установлен"
)
elif verbose:
self.stdout.write(
f" ✓ Chat #{chat.id}: is_main=True → flags['is_primary']=True"
)
if not errors and count_main > 0:
self.stdout.write(self.style.SUCCESS(
f" ✓ Все {count_main} главных чатов мигрированы в flags корректно"
))
elif count_main == 0:
self.stdout.write(self.style.WARNING(
" ⚠ Главных чатов не найдено"
))
else:
# Поле is_main удалено - проверяем только flags
chats_with_primary = Chat.objects.filter(
flags__is_primary=True
).count()
self.stdout.write(self.style.SUCCESS(
f" ✓ Поле 'is_main' удалено, чатов с flags.is_primary: {chats_with_primary}"
))
# ===== 3. Проверка include_all_users =====
self.stdout.write("\n" + self.style.HTTP_INFO(
"3. Проверка поля include_all_users..."
))
has_old_field = hasattr(Chat, 'include_all_employees')
has_new_field = hasattr(Chat, 'include_all_users')
if has_old_field and not has_new_field:
warnings.append("Поле 'include_all_employees' еще не переименовано")
count = Chat.objects.filter(include_all_employees=True).count()
self.stdout.write(self.style.WARNING(
f" ⚠ Старое поле 'include_all_employees' существует, чатов: {count}"
))
elif has_new_field:
count = Chat.objects.filter(include_all_users=True).count()
self.stdout.write(self.style.SUCCESS(
f" ✓ Поле 'include_all_users' существует, чатов использующих: {count}"
))
else:
errors.append("Ни 'include_all_employees' ни 'include_all_users' не найдены!")
# ===== 4. Общая статистика =====
self.stdout.write("\n" + self.style.HTTP_INFO("4. Общая статистика..."))
total = Chat.objects.count()
with_context = Chat.objects.filter(
context_content_type__isnull=False
).count()
with_flags_primary = Chat.objects.filter(
flags__is_primary=True
).count()
self.stdout.write(f" Всего чатов: {total}")
self.stdout.write(f" С context_object: {with_context}")
self.stdout.write(f" С flags.is_primary: {with_flags_primary}")
# ===== ИТОГОВЫЙ ОТЧЕТ =====
self.stdout.write("\n" + "=" * 70)
if errors:
self.stdout.write(self.style.ERROR(f"❌ ОШИБОК: {len(errors)}"))
for error in errors:
self.stdout.write(self.style.ERROR(f" • {error}"))
result_code = 1
else:
self.stdout.write(self.style.SUCCESS("✅ ОШИБОК НЕ ОБНАРУЖЕНО"))
result_code = 0
if warnings:
self.stdout.write("\n" + self.style.WARNING(f"⚠️ ПРЕДУПРЕЖДЕНИЙ: {len(warnings)}"))
for warning in warnings:
self.stdout.write(self.style.WARNING(f" • {warning}"))
self.stdout.write("=" * 70 + "\n")
if result_code == 0:
self.stdout.write(self.style.SUCCESS(
"✅ Миграция выполнена корректно! Данные не потеряны."
))
else:
self.stdout.write(self.style.ERROR(
"❌ Обнаружены проблемы при миграции. Проверьте ошибки выше."
))
return result_code

View File

@ -0,0 +1,47 @@
# Generated by Django 5.2.4 on 2026-03-11 10:07
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('communications', '0030_chatreadstate_unread_count_and_more'),
('contenttypes', '0002_remove_content_type_name'),
('employees', '0041_alter_departmentpermission_code'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='chat',
name='context_content_type',
field=models.ForeignKey(blank=True, help_text='Type of related object (e.g., Department, Project, Team)', null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name='Context type'),
),
migrations.AddField(
model_name='chat',
name='context_object_id',
field=models.PositiveIntegerField(blank=True, null=True, verbose_name='Context object ID'),
),
migrations.AddField(
model_name='chat',
name='extra_data',
field=models.JSONField(blank=True, default=dict, help_text='Additional chat metadata (extensible)', verbose_name='Extra data'),
),
migrations.AddField(
model_name='chat',
name='flags',
field=models.JSONField(blank=True, default=dict, help_text="Custom flags: {'is_primary': true, 'is_archived': false, etc.}", verbose_name='Flags'),
),
migrations.AlterField(
model_name='chat',
name='type',
field=models.CharField(choices=[('private', 'Личный'), ('group', 'Групповой'), ('department', 'Отдел'), ('channel', 'Канал'), ('announcement', 'Объявления'), ('global', 'Глобальный')], db_index=True, help_text='Chat type: private, group, channel, announcement, department, global', max_length=32, verbose_name='Тип чата'),
),
migrations.AddIndex(
model_name='chat',
index=models.Index(fields=['context_content_type', 'context_object_id'], name='chat_context_idx'),
),
]

View File

@ -0,0 +1,68 @@
# Generated by Django 5.2.4 on 2026-03-11 10:07
from django.db import migrations
def migrate_department_to_context(apps, schema_editor):
"""
Копирует department → context_object для всех чатов с department.
"""
Chat = apps.get_model('communications', 'Chat')
ContentType = apps.get_model('contenttypes', 'ContentType')
# Получаем ContentType для модели Department
try:
dept_ct = ContentType.objects.get(app_label='employees', model='department')
except ContentType.DoesNotExist:
print("ContentType для Department не найден, пропускаем миграцию")
return
# Находим все чаты с department
chats_with_dept = Chat.objects.filter(department__isnull=False)
count = 0
for chat in chats_with_dept:
chat.context_content_type = dept_ct
chat.context_object_id = chat.department_id
chat.save(update_fields=['context_content_type', 'context_object_id'])
count += 1
print(f"Мигрировано {count} чатов: department → context_object")
def reverse_migrate(apps, schema_editor):
"""
Откат: копирует context_object → department для Department объектов.
"""
Chat = apps.get_model('communications', 'Chat')
ContentType = apps.get_model('contenttypes', 'ContentType')
try:
dept_ct = ContentType.objects.get(app_label='employees', model='department')
except ContentType.DoesNotExist:
return
chats_with_context = Chat.objects.filter(
context_content_type=dept_ct,
context_object_id__isnull=False
)
count = 0
for chat in chats_with_context:
chat.department_id = chat.context_object_id
chat.save(update_fields=['department'])
count += 1
print(f"Откат: восстановлено {count} чатов: context_object → department")
class Migration(migrations.Migration):
dependencies = [
('communications', '0031_add_universal_context_fields'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.RunPython(migrate_department_to_context, reverse_code=reverse_migrate),
]

View File

@ -0,0 +1,51 @@
# Generated by Django 5.2.4 on 2026-03-11 10:08
from django.db import migrations
def migrate_is_main_to_flags(apps, schema_editor):
"""
Копирует is_main → flags['is_primary'].
"""
Chat = apps.get_model('communications', 'Chat')
chats_with_is_main = Chat.objects.filter(is_main=True)
count = 0
for chat in chats_with_is_main:
if not chat.flags:
chat.flags = {}
chat.flags['is_primary'] = True
chat.save(update_fields=['flags'])
count += 1
print(f"Мигрировано {count} чатов: is_main=True → flags['is_primary']=True")
def reverse_migrate(apps, schema_editor):
"""
Откат: копирует flags['is_primary'] → is_main.
"""
Chat = apps.get_model('communications', 'Chat')
all_chats = Chat.objects.all()
count = 0
for chat in all_chats:
if chat.flags and chat.flags.get('is_primary'):
chat.is_main = True
chat.save(update_fields=['is_main'])
count += 1
print(f"Откат: восстановлено {count} чатов: flags['is_primary'] → is_main")
class Migration(migrations.Migration):
dependencies = [
('communications', '0032_migrate_department_to_context'),
]
operations = [
migrations.RunPython(migrate_is_main_to_flags, reverse_code=reverse_migrate),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2026-03-11 10:09
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('communications', '0033_migrate_is_main_to_flags'),
]
operations = [
migrations.RenameField(
model_name='chat',
old_name='include_all_employees',
new_name='include_all_users',
),
]

View File

@ -1,10 +1,14 @@
# backend/communications/models.py
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import IntegrityError, models
from django.db.models import Q
from django.utils import timezone
# DEPRECATED: Will be removed after migration complete
from employees.models import Department, EmployeeDepartment
Employee = get_user_model()
@ -86,10 +90,11 @@ class Chat(models.Model):
]
type = models.CharField(
max_length=16,
max_length=32, # Увеличено с 16 для кастомных типов
choices=CHAT_TYPE_CHOICES,
verbose_name="Тип чата",
db_index=True
db_index=True,
help_text="Chat type: private, group, channel, announcement, department, global"
)
# Новые базовые поля
@ -119,9 +124,9 @@ class Chat(models.Model):
)
# Для гибкой настройки участников
include_all_employees = models.BooleanField(
include_all_users = models.BooleanField(
default=False,
verbose_name="Включить всех сотрудников",
verbose_name="Включить всех пользователей",
help_text="Для анонсов и общих чатов"
)
@ -140,9 +145,44 @@ class Chat(models.Model):
verbose_name="Отдел",
help_text="Указывается только для чатов отдела",
)
# ===== NEW: Universal context (GenericForeignKey) =====
# Позволяет привязать чат к ЛЮБОЙ модели (Department, Project, Team, Event, 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="Создан")
is_main = models.BooleanField(default=False, verbose_name="Основной чат")
# ===== NEW: Flexible flags & metadata =====
flags = models.JSONField(
default=dict,
blank=True,
verbose_name="Flags",
help_text="Custom flags: {'is_primary': true, 'is_archived': false, etc.}"
)
extra_data = models.JSONField(
default=dict,
blank=True,
verbose_name="Extra data",
help_text="Additional chat metadata (extensible)"
)
# Новые поля для announcement и channel
is_blocked = models.BooleanField(
default=False,
@ -195,6 +235,8 @@ class Chat(models.Model):
models.Index(fields=["type", "is_main"]),
models.Index(fields=["department"]),
models.Index(fields=["created_at"]),
# NEW: Index for GenericForeignKey
models.Index(fields=["context_content_type", "context_object_id"], name="chat_context_idx"),
]
def clean(self):
@ -259,37 +301,58 @@ class Chat(models.Model):
)
return rs.unread_count if rs else 0
@property
def get_participants(self):
"""
Возвращает QuerySet участников чата (по типу).
Для отдела берём активные связи + руководителя.
Возвращает QuerySet участников чата.
Использует callback из settings.COMMUNICATIONS_PARTICIPANT_RESOLVER
для проектно-специфичной логики (EUSRR: Department, EmployeeDepartment).
Fallback логика:
- private: M2M participants
- global: все активные пользователи
- announcement/channel с include_all_users: все активные
- иначе: ChatMembership + participants
"""
# Попытка использовать callback из settings
from django.conf import settings
from django.utils.module_loading import import_string
resolver_path = getattr(settings, 'COMMUNICATIONS_PARTICIPANT_RESOLVER', None)
if resolver_path:
try:
resolver_func = import_string(resolver_path)
result = resolver_func(self)
if result is not None:
return result
except (ImportError, AttributeError) as e:
# Логируем ошибку, но не падаем
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Failed to import participant resolver '{resolver_path}': {e}")
# Fallback логика (универсальная, без бизнес-специфики)
if self.type == "private":
return self.participants.all()
if self.type == "department" and self.department_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":
return Employee.objects.filter(is_active=True)
if self.type in ["announcement", "channel"]:
# Если include_all_employees - все активные сотрудники
if self.include_all_employees:
return Employee.objects.filter(is_active=True)
# Иначе только явно добавленные через participants или ChatMembership
from .models import ChatMembership
membership_ids = ChatMembership.objects.filter(
chat=self
).values_list("user_id", flat=True)
return Employee.objects.filter(
Q(id__in=self.participants.values_list('id', flat=True)) |
Q(id__in=membership_ids)
).distinct()
return Employee.objects.none()
if self.type in ["announcement", "channel"] and self.include_all_users:
return Employee.objects.filter(is_active=True)
# Для group/channel/announcement без флага - явные участники
from communications.models import ChatMembership
membership_ids = ChatMembership.objects.filter(
chat=self
).values_list("user_id", flat=True)
return Employee.objects.filter(
Q(id__in=self.participants.values_list('id', flat=True)) |
Q(id__in=membership_ids)
).distinct()
def __str__(self):
if self.type == "private":

View File

@ -200,3 +200,103 @@ def _build_links_for_dept(dept: Department, serializer) -> list[dict]:
links.insert(0, {"employee": head_data, "role": None, "is_active": True})
return links
# ===== Communications callbacks (EUSRR-specific) =====
def resolve_chat_participants_for_department(context_object, **kwargs):
"""
EUSRR callback для разрешения участников чата отдела.
Args:
context_object: Department instance
**kwargs: Дополнительные параметры (chat, type и т.д.)
Returns:
QuerySet Employee objects - участники чата
"""
from django.db.models import Q
# Проверяем что это Department
if not isinstance(context_object, Department):
return Employee.objects.none()
department = context_object
# Получаем активных сотрудников отдела
employee_ids = EmployeeDepartment.objects.filter(
department_id=department.id,
is_active=True
).values_list("employee_id", flat=True)
# Включаем руководителя отдела
return Employee.objects.filter(
Q(id__in=employee_ids) | Q(id=department.head_id)
).distinct()
def resolve_chat_participants(chat):
"""
Главная функция для разрешения участников чата в EUSRR.
Вызывается из Chat.get_participants() через настройки.
Args:
chat: Chat instance
Returns:
QuerySet Employee objects или None (если не обработали)
"""
from django.db.models import Q
# 1. Приватные чаты - используем M2M participants
if chat.type == "private":
return chat.participants.all()
# 2. Глобальные чаты - все активные пользователи
if chat.type == "global":
return Employee.objects.filter(is_active=True)
# 3. Announcement / Channel с include_all_users
if chat.type in ["announcement", "channel"] and chat.include_all_users:
return Employee.objects.filter(is_active=True)
# 4. Context-based чаты
if chat.context_object:
# Department context
if isinstance(chat.context_object, Department):
return resolve_chat_participants_for_department(
chat.context_object,
chat=chat,
type=chat.type
)
# Можно добавить другие типы контекстов:
# - Project
# - Team
# - Event
# и т.д.
# 5. Legacy: department field (DEPRECATED, для обратной совместимости)
if chat.type == "department" and hasattr(chat, 'department') and chat.department_id:
employee_ids = EmployeeDepartment.objects.filter(
department_id=chat.department_id,
is_active=True
).values_list("employee_id", flat=True)
return Employee.objects.filter(
Q(id__in=employee_ids) | Q(id=chat.department.head_id)
).distinct()
# 6. Fallback: ChatMembership или participants
from communications.models import ChatMembership
membership_ids = ChatMembership.objects.filter(
chat=chat
).values_list("user_id", flat=True)
participant_ids = chat.participants.values_list('id', flat=True)
return Employee.objects.filter(
Q(id__in=participant_ids) | Q(id__in=membership_ids)
).distinct()

View File

@ -609,3 +609,8 @@ CORS_ALLOWED_ORIGINS = [
"http://127.0.0.1:3000",
]
CORS_ALLOW_CREDENTIALS = True
# ===== Communications App Settings =====
# Функция для разрешения участников чата (callback)
# Позволяет подключить бизнес-специфичную логику (EUSRR: Department, EmployeeDepartment)
COMMUNICATIONS_PARTICIPANT_RESOLVER = 'employees.utils.resolve_chat_participants'