diff --git a/backend/communications/management/commands/verify_chat_migration.py b/backend/communications/management/commands/verify_chat_migration.py new file mode 100644 index 00000000..cff74e20 --- /dev/null +++ b/backend/communications/management/commands/verify_chat_migration.py @@ -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 diff --git a/backend/communications/migrations/0031_add_universal_context_fields.py b/backend/communications/migrations/0031_add_universal_context_fields.py new file mode 100644 index 00000000..611bc5c6 --- /dev/null +++ b/backend/communications/migrations/0031_add_universal_context_fields.py @@ -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'), + ), + ] diff --git a/backend/communications/migrations/0032_migrate_department_to_context.py b/backend/communications/migrations/0032_migrate_department_to_context.py new file mode 100644 index 00000000..96a7a1e8 --- /dev/null +++ b/backend/communications/migrations/0032_migrate_department_to_context.py @@ -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), + ] diff --git a/backend/communications/migrations/0033_migrate_is_main_to_flags.py b/backend/communications/migrations/0033_migrate_is_main_to_flags.py new file mode 100644 index 00000000..96ef37bd --- /dev/null +++ b/backend/communications/migrations/0033_migrate_is_main_to_flags.py @@ -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), + ] diff --git a/backend/communications/migrations/0034_rename_include_all_employees.py b/backend/communications/migrations/0034_rename_include_all_employees.py new file mode 100644 index 00000000..5a7aeab3 --- /dev/null +++ b/backend/communications/migrations/0034_rename_include_all_employees.py @@ -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', + ), + ] diff --git a/backend/communications/models.py b/backend/communications/models.py index 353cc6ed..8e4050df 100644 --- a/backend/communications/models.py +++ b/backend/communications/models.py @@ -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": diff --git a/backend/employees/utils.py b/backend/employees/utils.py index 813d388e..a4eb605c 100644 --- a/backend/employees/utils.py +++ b/backend/employees/utils.py @@ -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() diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 6f473595..e6d751d5 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -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'