- ApiClient теперь обрабатывает 401 и 403 ошибки для обновления токена - Добавлен JWTRefreshMiddleware для проактивного обновления токенов за 5 минут до истечения - Добавлена настройка JWT_REFRESH_THRESHOLD_MINUTES (по умолчанию 5) - Исправлена проблема с периодическими 403 ошибками после истечения access token
32 KiB
32 KiB
Улучшения модели мессенджера
1. Добавить базовые поля в модель Chat
class Chat(models.Model):
# Существующие поля...
type = models.CharField(max_length=16, choices=CHAT_TYPE_CHOICES)
# НОВЫЕ ПОЛЯ:
name = models.CharField(
max_length=255,
blank=True,
verbose_name="Название чата",
help_text="Для групповых/канальных чатов"
)
description = models.TextField(
blank=True,
verbose_name="Описание чата"
)
avatar = models.ImageField(
upload_to='chat_avatars/%Y/%m/',
null=True,
blank=True,
verbose_name="Аватар чата"
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
on_delete=models.SET_NULL,
related_name='created_chats',
verbose_name="Создатель"
)
# Для гибкой настройки участников
include_all_employees = models.BooleanField(
default=False,
verbose_name="Включить всех сотрудников",
help_text="Для анонсов и общих чатов"
)
2. Создать модель для закрепления чатов (персональная настройка)
class ChatUserSettings(models.Model):
"""Персональные настройки пользователя для чата"""
chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name='user_settings')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
# Закрепление
is_pinned = models.BooleanField(default=False, verbose_name="Закреплен")
pinned_at = models.DateTimeField(null=True, blank=True, verbose_name="Время закрепления")
pin_order = models.IntegerField(default=0, verbose_name="Порядок закрепленных")
# Уведомления
notifications_enabled = models.BooleanField(default=True, verbose_name="Уведомления")
# Кастомное название (если пользователь переименовал)
custom_name = models.CharField(max_length=255, blank=True, verbose_name="Свое название")
# Скрыть чат
is_hidden = models.BooleanField(default=False, verbose_name="Скрыт")
class Meta:
unique_together = [('chat', 'user')]
ordering = ['-is_pinned', '-pinned_at']
3. Расширить типы чатов
class Chat(models.Model):
CHAT_TYPE_CHOICES = [
('private', 'Личный'), # 1-на-1
('group', 'Групповой'), # Произвольная группа сотрудников
('department', 'Отдел'), # Привязан к отделу
('channel', 'Канал'), # Только админы пишут, все читают
('announcement', 'Объявления'), # Системный канал
]
4. Добавить роли участников
class ChatMembership(models.Model):
"""Участие в чате с ролями"""
ROLE_CHOICES = [
('owner', 'Владелец'),
('admin', 'Администратор'),
('moderator', 'Модератор'),
('member', 'Участник'),
('readonly', 'Только чтение'),
]
chat = models.ForeignKey(Chat, on_delete=models.CASCADE, related_name='memberships')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='member')
joined_at = models.DateTimeField(auto_now_add=True)
invited_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
on_delete=models.SET_NULL,
related_name='+'
)
# Разрешения
can_send_messages = models.BooleanField(default=True)
can_add_members = models.BooleanField(default=False)
can_edit_chat = models.BooleanField(default=False)
class Meta:
unique_together = [('chat', 'user')]
5. Расширить модель Message
class Message(models.Model):
# Существующие поля...
chat = models.ForeignKey(Chat, related_name="messages", on_delete=models.CASCADE)
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
# НОВЫЕ ПОЛЯ:
# Ответ на сообщение (упрощенная связь, детали в MessageReply)
reply_to = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='direct_replies',
help_text="Простая связь для быстрого доступа"
)
# Редактирование
is_edited = models.BooleanField(default=False)
edited_at = models.DateTimeField(null=True, blank=True)
edit_history = models.JSONField(
default=list,
blank=True,
help_text="История редактирования [{timestamp, old_content}, ...]"
)
# Удаление
is_deleted = models.BooleanField(default=False)
deleted_at = models.DateTimeField(null=True, blank=True)
deleted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text="Кто удалил (может отличаться от автора)"
)
# Вложения
has_attachments = models.BooleanField(default=False)
# Системные сообщения (вступление, выход и т.д.)
is_system = models.BooleanField(default=False)
system_type = models.CharField(max_length=50, blank=True)
# Типы: 'user_joined', 'user_left', 'chat_created', 'chat_renamed',
# 'user_promoted', 'user_demoted', 'settings_changed'
system_metadata = models.JSONField(
default=dict,
blank=True,
help_text="Дополнительные данные для системных сообщений"
)
# Важные/закрепленные
is_pinned = models.BooleanField(default=False)
pinned_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+'
)
pinned_at = models.DateTimeField(null=True, blank=True)
# Реакции (можно хранить как JSON)
reactions = models.JSONField(default=dict, blank=True)
# Формат: {'👍': [user_id1, user_id2], '❤️': [user_id3]}
# Флаги для специальных типов сообщений
is_forwarded = models.BooleanField(default=False)
is_cross_chat = models.BooleanField(
default=False,
help_text="Сообщение отправлено не участником чата"
)
# Треды (для организации обсуждений)
thread_root = models.ForeignKey(
'self',
null=True,
blank=True,
on_delete=models.CASCADE,
related_name='thread_messages',
help_text="Корневое сообщение треда"
)
thread_reply_count = models.IntegerField(default=0)
class Meta:
ordering = ['created_at']
indexes = [
models.Index(fields=['chat', 'created_at']),
models.Index(fields=['chat', 'is_deleted', 'created_at']),
models.Index(fields=['author', 'created_at']),
models.Index(fields=['thread_root', 'created_at']),
models.Index(fields=['is_pinned', 'chat']),
]
6. Добавить модель вложений
class MessageAttachment(models.Model):
ATTACHMENT_TYPE_CHOICES = [
('image', 'Изображение'),
('file', 'Файл'),
('video', 'Видео'),
('audio', 'Аудио'),
]
message = models.ForeignKey(Message, on_delete=models.CASCADE, related_name='attachments')
file = models.FileField(upload_to='chat_attachments/%Y/%m/%d/')
file_type = models.CharField(max_length=20, choices=ATTACHMENT_TYPE_CHOICES)
file_name = models.CharField(max_length=255)
file_size = models.BigIntegerField()
mime_type = models.CharField(max_length=100)
uploaded_at = models.DateTimeField(auto_now_add=True)
# Для изображений
thumbnail = models.ImageField(upload_to='chat_thumbnails/%Y/%m/%d/', null=True, blank=True)
6a. Улучшить модель пересылки сообщений
class ForwardedMessage(models.Model):
"""Информация о пересланных сообщениях (поддержка цепочки пересылок)"""
message = models.OneToOneField(
Message,
on_delete=models.CASCADE,
related_name='forward_info',
help_text="Текущее пересланное сообщение"
)
# Оригинальное сообщение (первое в цепочке)
original_message = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
related_name='forwarded_copies',
help_text="Самое первое сообщение в цепочке пересылок"
)
# Непосредственный источник (откуда переслали)
immediate_source = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
related_name='+',
help_text="Сообщение, которое было непосредственно переслано"
)
original_chat = models.ForeignKey(
Chat,
on_delete=models.SET_NULL,
null=True,
related_name='+'
)
original_author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='+'
)
forwarded_at = models.DateTimeField(auto_now_add=True)
forwarded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='forwarded_messages',
help_text="Кто переслал"
)
# Счетчик пересылок (для оригинала)
forward_count = models.IntegerField(default=0, help_text="Сколько раз переслали")
# Сохранить контент на случай удаления оригинала
preserved_content = models.TextField(blank=True)
preserved_author_name = models.CharField(max_length=255, blank=True)
class Meta:
indexes = [
models.Index(fields=['original_message']),
models.Index(fields=['forwarded_by', 'forwarded_at']),
]
6b. Добавить модель для отправки сообщений в чужие чаты
class CrossChatMessage(models.Model):
"""Сообщения, отправленные в чаты, где пользователь не является участником"""
message = models.OneToOneField(
Message,
on_delete=models.CASCADE,
related_name='cross_chat_info'
)
sender = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='cross_chat_messages',
help_text="Отправитель (не член этого чата)"
)
target_chat = models.ForeignKey(
Chat,
on_delete=models.CASCADE,
related_name='cross_messages',
help_text="Чат-получатель"
)
# Разрешение на отправку
approved = models.BooleanField(default=False, help_text="Одобрено модератором")
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text="Кто одобрил"
)
approved_at = models.DateTimeField(null=True, blank=True)
# Причина/контекст отправки
reason = models.CharField(
max_length=500,
blank=True,
help_text="Причина отправки сообщения в чужой чат"
)
# Режим отображения
DISPLAY_MODE_CHOICES = [
('guest', 'Гостевое (с пометкой "Внешнее сообщение")'),
('forwarded', 'Как пересланное'),
('announcement', 'Как объявление'),
]
display_mode = models.CharField(
max_length=20,
choices=DISPLAY_MODE_CHOICES,
default='guest'
)
sent_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=['target_chat', 'approved']),
models.Index(fields=['sender', 'sent_at']),
]
7. Улучшить ChatReadState
class ChatReadState(models.Model):
chat = models.ForeignKey("Chat", on_delete=models.CASCADE, related_name="read_states")
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
last_read_at = models.DateTimeField(null=True, blank=True)
last_read_message = models.ForeignKey(
Message,
null=True,
blank=True,
on_delete=models.SET_NULL,
help_text="Последнее прочитанное сообщение"
)
# Статус набора текста
is_typing = models.BooleanField(default=False)
typing_updated_at = models.DateTimeField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
8. Добавить модель для ответов на сообщения (расширенная)
class MessageReply(models.Model):
"""Дополнительная информация об ответе на сообщение"""
message = models.OneToOneField(
Message,
on_delete=models.CASCADE,
related_name='reply_info',
help_text="Сообщение-ответ"
)
replied_to = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
related_name='replies',
help_text="Исходное сообщение, на которое отвечают"
)
# Для ответов через чаты (если отвечаешь в другом чате)
is_cross_chat_reply = models.BooleanField(default=False)
original_chat = models.ForeignKey(
Chat,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
help_text="Чат оригинального сообщения (если отличается)"
)
# Сохраненный контент на случай удаления
preserved_text = models.TextField(blank=True)
preserved_author_name = models.CharField(max_length=255, blank=True)
# Тип ответа
REPLY_TYPE_CHOICES = [
('inline', 'Обычный ответ'),
('quote', 'Цитирование'),
('thread', 'В треде'),
]
reply_type = models.CharField(
max_length=20,
choices=REPLY_TYPE_CHOICES,
default='inline'
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=['replied_to']),
]
9. Добавить права доступа к чатам
class ChatAccessPermission(models.Model):
"""Права пользователей на отправку сообщений в чужие чаты"""
chat = models.ForeignKey(
Chat,
on_delete=models.CASCADE,
related_name='access_permissions'
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='chat_permissions'
)
# Типы доступа
PERMISSION_TYPE_CHOICES = [
('send_messages', 'Отправка сообщений'),
('read_only', 'Только чтение'),
('send_with_approval', 'Отправка с модерацией'),
]
permission_type = models.CharField(
max_length=30,
choices=PERMISSION_TYPE_CHOICES,
default='send_with_approval'
)
# Кто выдал право
granted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name='+'
)
granted_at = models.DateTimeField(auto_now_add=True)
# Срок действия
expires_at = models.DateTimeField(
null=True,
blank=True,
help_text="Когда истекает право доступа"
)
is_active = models.BooleanField(default=True)
class Meta:
unique_together = [('chat', 'user')]
indexes = [
models.Index(fields=['chat', 'is_active']),
models.Index(fields=['user', 'is_active']),
]
9. Убрать ограничения для гибкости
Удалить constraint'ы:
unique_main_global_chatunique_main_department_chat
Заменить на поле is_default для автоматического выбора чата по умолчанию.
10. Добавить настройки чата
class ChatSettings(models.Model):
"""Глобальные настройки чата"""
chat = models.OneToOneField(Chat, on_delete=models.CASCADE, related_name='settings')
# Кто может писать
only_admins_can_post = models.BooleanField(default=False)
# Кто может приглашать
members_can_invite = models.BooleanField(default=True)
# Автоудаление сообщений
auto_delete_messages_after = models.IntegerField(
null=True,
blank=True,
help_text="Автоматически удалять сообщения старше N дней"
)
# Максимум участников
max_members = models.IntegerField(null=True, blank=True)
# Запретить личные сообщения между участниками
disable_dm_between_members = models.BooleanField(default=False)
Пример миграции (поэтапно)
Этап 1: Базовые поля
# Добавить name, description, avatar в Chat
python manage.py makemigrations
python manage.py migrate
Этап 2: Персональные настройки
# Создать ChatUserSettings
python manage.py makemigrations
python manage.py migrate
Этап 3: Роли участников
# Создать ChatMembership
python manage.py makemigrations
python manage.py migrate
Этап 4: Расширенные сообщения
# Добавить поля в Message, создать MessageAttachment
python manage.py makemigrations
python manage.py migrate
Итоговая архитектура
Chat (базовая информация + настройки)
├── ChatUserSettings (персональные настройки каждого пользователя)
├── ChatMembership (роли и права участников)
├── ChatSettings (глобальные настройки чата)
├── ChatAccessPermission (права внешних пользователей на отправку сообщений)
├── Message
│ ├── MessageAttachment (файлы, изображения)
│ ├── ForwardedMessage (информация о пересылке с цепочкой)
│ ├── MessageReply (расширенная информация об ответах)
│ └── CrossChatMessage (сообщения из чужих чатов)
└── ChatReadState (статус прочтения)
Новые возможности
1. Пересылка сообщений
- Простая пересылка: отправить сообщение в другой чат с указанием источника
- Цепочка пересылок: отслеживание всей истории пересылки (оригинал → промежуточные → текущее)
- Счетчик пересылок: сколько раз сообщение было переслано
- Сохранение контента: если оригинал удален, контент сохраняется
2. Ответы на сообщения
- Внутри чата: стандартный reply с цитированием
- Кросс-чат ответы: ответить на сообщение из другого чата
- Типы ответов: inline (обычный), quote (цитата), thread (тред)
- Сохранение контекста: текст и автор сохраняются при удалении оригинала
3. Отправка в чужие чаты
- Режимы доступа:
send_messages- свободная отправкаread_only- только чтениеsend_with_approval- модерация перед публикацией
- Типы отображения:
guest- с пометкой "Внешнее сообщение"forwarded- как пересланноеannouncement- как объявление
- Права доступа: временные/постоянные, с указанием срока действия
- Модерация: одобрение администраторами чата
4. Треды (обсуждения)
- Организация дискуссий в отдельные ветки
- Счетчик ответов в треде
- Корневое сообщение треда
Примеры использования
Пересылка сообщения
# В views или consumers
def forward_message(original_message_id, target_chat_id, user):
original = Message.objects.get(id=original_message_id)
# Создаем новое сообщение
forwarded_msg = Message.objects.create(
chat_id=target_chat_id,
author=user,
content=original.content,
is_forwarded=True,
has_attachments=original.has_attachments
)
# Копируем вложения
for att in original.attachments.all():
MessageAttachment.objects.create(
message=forwarded_msg,
file=att.file,
file_type=att.file_type,
file_name=att.file_name,
file_size=att.file_size,
mime_type=att.mime_type
)
# Находим оригинал в цепочке
if hasattr(original, 'forward_info'):
# Это уже пересланное - берем самый первый оригинал
root_original = original.forward_info.original_message
else:
root_original = original
# Создаем информацию о пересылке
ForwardedMessage.objects.create(
message=forwarded_msg,
original_message=root_original,
immediate_source=original,
original_chat=root_original.chat,
original_author=root_original.author,
forwarded_by=user,
preserved_content=root_original.content,
preserved_author_name=root_original.author.get_full_name()
)
# Увеличиваем счетчик
if hasattr(root_original, 'forward_info'):
root_original.forward_info.forward_count += 1
root_original.forward_info.save()
return forwarded_msg
Отправка в чужой чат
def send_to_external_chat(sender, chat_id, content, reason=''):
chat = Chat.objects.get(id=chat_id)
# Проверяем права доступа
permission = ChatAccessPermission.objects.filter(
chat=chat,
user=sender,
is_active=True
).first()
if not permission:
# Создаем запрос на отправку с модерацией
permission = ChatAccessPermission.objects.create(
chat=chat,
user=sender,
permission_type='send_with_approval',
granted_by=None
)
# Создаем сообщение
message = Message.objects.create(
chat=chat,
author=sender,
content=content,
is_cross_chat=True
)
# Создаем метаданные
CrossChatMessage.objects.create(
message=message,
sender=sender,
target_chat=chat,
approved=(permission.permission_type == 'send_messages'),
reason=reason,
display_mode='guest'
)
return message
Ответ на сообщение из другого чата
def reply_across_chats(user, original_msg_id, reply_content, target_chat_id):
original_msg = Message.objects.get(id=original_msg_id)
# Создаем ответ в целевом чате
reply_msg = Message.objects.create(
chat_id=target_chat_id,
author=user,
content=reply_content,
reply_to=original_msg
)
# Создаем расширенную информацию
MessageReply.objects.create(
message=reply_msg,
replied_to=original_msg,
is_cross_chat_reply=True,
original_chat=original_msg.chat,
preserved_text=original_msg.content,
preserved_author_name=original_msg.author.get_full_name(),
reply_type='quote'
)
return reply_msg
Приоритет внедрения (для MVP)
-
Высокий приоритет:
name,avatarв ChatChatUserSettings(закрепление, уведомления)- Расширение типов чатов (
group,channel) reply_to,is_edited,is_forwarded,is_cross_chatв MessageMessageAttachmentForwardedMessage(базовая пересылка)MessageReply(ответы в том же чате)
-
Средний приоритет:
ChatMembership(роли)ChatAccessPermission(права на отправку в чужие чаты)CrossChatMessage(отправка в чужие чаты с модерацией)- Кросс-чат ответы
- Реакции на сообщения
- Системные сообщения
- История редактирования
-
Низкий приоритет:
- Треды
- Автоудаление
- Расширенные разрешения
- Статус "печатает"
- Счетчики пересылок
Изменения в UI/UX
После внедрения этих улучшений понадобится:
Основные функции
- Модалка редактирования чата (название, аватар, описание)
- Контекстное меню для закрепления чатов
- Управление участниками (добавить/удалить/изменить роль)
- Загрузка файлов в сообщениях
- Ответы на сообщения (quote UI)
- Реакции на сообщения
- Редактирование/удаление своих сообщений
Пересылка сообщений
- Кнопка "Переслать" в контекстном меню сообщения
- Модалка выбора чатов для пересылки (с поиском)
- Множественная пересылка (выбрать несколько чатов)
- Отображение "Переслано от [Имя]" в сообщении
- Ссылка на оригинальное сообщение (если доступно)
- Индикатор цепочки пересылок ("Переслано 5 раз")
Ответы между чатами
- Возможность ответить в другом чате через контекстное меню
- Выбор целевого чата для ответа
- Цитата с указанием чата-источника
- Ссылка "Перейти к оригиналу" (если есть доступ)
Отправка в чужие чаты
- Модалка "Отправить сообщение в чат"
- Поиск доступных чатов (даже без участия)
- Выбор режима отправки (гостевое/пересланное/объявление)
- Поле "Причина отправки"
- Статус модерации:
- "Ожидает одобрения" (желтый значок)
- "Одобрено" (зеленая галочка)
- "Отклонено" (красный крестик)
- Для модераторов: панель одобрения внешних сообщений
Визуальные индикаторы
-
Пересланное сообщение:
┌─────────────────────────────────┐ │ 📤 Переслано от Иван Петров │ │ Оригинал: Чат отдела IT │ ├─────────────────────────────────┤ │ Текст сообщения... │ └─────────────────────────────────┘ -
Внешнее сообщение:
┌─────────────────────────────────┐ │ 🔔 Внешнее сообщение │ │ От: Мария Сидорова │ │ Причина: Важное объявление │ ├─────────────────────────────────┤ │ Текст сообщения... │ └─────────────────────────────────┘ -
Ответ из другого чата:
┌─────────────────────────────────┐ │ Иван Петров → Чат отдела IT │ │ ↳ В ответ на сообщение из │ │ "Общий чат компании" │ ├─────────────────────────────────┤ │ > Исходное сообщение... │ │ Ответ... │ └─────────────────────────────────┘
Контекстное меню сообщения (расширенное)
- Ответить
- Ответить в другом чате →
- Переслать →
- В избранное
- В другой чат...
- В несколько чатов...
- Отправить в чужой чат →
- Скопировать
- Редактировать (свои)
- Удалить (свои/админ)
- Закрепить (админ)
- Пожаловаться
Права и разрешения (админ панель чата)
- Настройки чата:
- Кто может писать (все/только участники/только админы)
- Разрешить внешние сообщения (да/нет/с модерацией)
- Кто может приглашать участников
- Управление внешними отправителями:
- Список пользователей с правами
- Добавить/удалить права
- Установить срок действия
- Модерация ожидающих сообщений