252 lines
9.1 KiB
Python
252 lines
9.1 KiB
Python
# 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__,
|
||
},
|
||
)
|