# backend\communications\signals.py import logging from django.conf import settings from django.core.files.base import ContentFile from django.db.models import F from django.db.models.signals import post_delete, post_save, pre_save from django.dispatch import receiver from django.utils.module_loading import import_string logger = logging.getLogger(__name__) @receiver(post_save, sender="communications.ChatMembership") def synchronize_chat_membership_realtime(sender, instance, **kwargs): """Keep live consumer group membership aligned with database access.""" from communications.realtime import schedule_chat_access_changed schedule_chat_access_changed( chat_id=instance.chat_id, user_id=instance.user_id, membership_active=instance.is_active, ) @receiver(post_delete, sender="communications.ChatMembership") def revoke_deleted_chat_membership_realtime(sender, instance, **kwargs): from communications.realtime import schedule_chat_access_changed schedule_chat_access_changed( chat_id=instance.chat_id, user_id=instance.user_id, membership_active=False, ) # NOTE: Project-specific chat creation signals should be in your project's app # Example: employees/signals.py - create_main_department_chat() def _get_image_compressor(): """ Получает функцию сжатия изображений из настроек или использует дефолтную. Settings: COMMUNICATIONS_IMAGE_COMPRESSOR (str): Путь к функции сжатия. По умолчанию: 'common.image_utils.compress_avatar' (проектно-специфичный) Для standalone: None (отключает сжатие) Пример: 'myapp.utils.compress_image' Returns: callable or None: Функция сжатия или None если отключено """ compressor_path = getattr( settings, "COMMUNICATIONS_IMAGE_COMPRESSOR", "common.image_utils.compress_avatar", # backward compatibility ) if compressor_path is None: return None try: return import_string(compressor_path) except (ImportError, AttributeError) as e: logger.warning( "Chat avatar compressor is unavailable", extra={ "event": "chat.avatar.compressor_unavailable", "compressor_configured": bool(compressor_path), "error_type": type(e).__name__, }, ) return None @receiver(pre_save, sender="communications.Chat") def compress_and_cleanup_chat_avatar(sender, instance, **kwargs): """ Сжимает новый аватар чата и удаляет старый файл. Использует COMMUNICATIONS_IMAGE_COMPRESSOR из settings. Если compressor не настроен - пропускает сжатие. """ if not instance.avatar: return compress_avatar = _get_image_compressor() # Если это новый объект (нет pk), просто сжимаем if not instance.pk: if compress_avatar: try: compressed = compress_avatar(instance.avatar.read()) if compressed: instance.avatar.save( instance.avatar.name, ContentFile(compressed), save=False, ) except Exception as e: logger.exception( "New chat avatar compression failed", extra={ "event": "chat.avatar.compression_failed", "chat_id": instance.pk, "error_type": type(e).__name__, }, ) return # Проверяем, изменился ли файл аватара try: old_instance = sender.objects.get(pk=instance.pk) except sender.DoesNotExist: return old_avatar = old_instance.avatar new_avatar = instance.avatar # Если путь изменился - это новый файл if old_avatar and new_avatar and old_avatar.name != new_avatar.name: try: # Сжимаем новый аватар (если compressor настроен) if compress_avatar: compressed = compress_avatar(new_avatar.read()) if compressed: new_avatar.save( new_avatar.name, ContentFile(compressed), save=False ) # Удаляем старый файл if old_avatar.name: old_avatar.delete(save=False) logger.info( "Old chat avatar deleted", extra={ "event": "chat.avatar.old_file_deleted", "chat_id": instance.pk, }, ) except Exception as e: logger.exception( "Chat avatar processing failed", extra={ "event": "chat.avatar.processing_failed", "chat_id": instance.pk, "error_type": type(e).__name__, }, ) @receiver(post_save, sender="communications.Message") def increment_unread_count_on_new_message(sender, instance, created, **kwargs): """ Инкрементирует unread_count для всех участников чата (кроме автора) при создании нового сообщения. Денормализация: вместо подсчета COUNT(*) в реальном времени, обновляем кешированный счетчик инкрементом. """ if not created or instance.is_deleted: return from communications.mentions import mentioned_user_ids from communications.models import ChatReadState try: # Получаем всех участников чата chat = instance.chat participants = chat.get_participants().exclude(id=instance.author_id) mentioned_ids = mentioned_user_ids(instance.mentions) # Инкрементируем счетчик для существующих ChatReadState # Только для тех, кто еще не прочитал это сообщение updated_count = ( ChatReadState.objects.filter(chat=chat, user__in=participants) .exclude( # Пропускаем тех, кто уже прочитал дальше этого сообщения last_read_message_id__gte=instance.id ) .update(unread_count=F("unread_count") + 1) ) updated_mention_count = 0 if mentioned_ids: updated_mention_count = ( ChatReadState.objects.filter( chat=chat, user__in=participants, user_id__in=mentioned_ids, ) .exclude(last_read_message_id__gte=instance.id) .update(unread_mention_count=F("unread_mention_count") + 1) ) # Создаем ChatReadState для участников, у которых его еще нет existing_users = ChatReadState.objects.filter( chat=chat, user__in=participants ).values_list("user_id", flat=True) new_users = list(participants.exclude(id__in=existing_users)) if new_users: # Создаем ChatReadState с unread_count=1 для новых участников ChatReadState.objects.bulk_create( [ ChatReadState( chat=chat, user=user, last_read_message=None, unread_count=1, unread_mention_count=1 if user.id in mentioned_ids else 0, ) for user in new_users ], ignore_conflicts=True, ) logger.info( "Chat read states created for new message", extra={ "event": "chat.read_states.created", "chat_id": chat.id, "message_id": instance.id, "state_count": len(new_users), }, ) logger.debug( "Chat unread counters updated", extra={ "event": "chat.unread_counters.updated", "chat_id": chat.id, "message_id": instance.id, "state_count": updated_count, "mention_state_count": updated_mention_count, }, ) except Exception as e: logger.exception( "Chat unread counter update failed", extra={ "event": "chat.unread_counters.update_failed", "message_id": instance.id, "chat_id": instance.chat_id, "error_type": type(e).__name__, }, )