Игорь Надеин ddf22c47da fix: автоматическое обновление JWT токенов
- ApiClient теперь обрабатывает 401 и 403 ошибки для обновления токена
- Добавлен JWTRefreshMiddleware для проактивного обновления токенов за 5 минут до истечения
- Добавлена настройка JWT_REFRESH_THRESHOLD_MINUTES (по умолчанию 5)
- Исправлена проблема с периодическими 403 ошибками после истечения access token
2025-11-20 18:52:29 +03:00

853 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# backend/communications/models.py
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.db import IntegrityError, models
from django.db.models import Q
from django.utils import timezone
from employees.models import Department, EmployeeDepartment
Employee = get_user_model()
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,
related_name="chat_read_states",
)
last_read_at = models.DateTimeField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
# НОВЫЕ ПОЛЯ для расширенной функциональности
# Последнее прочитанное сообщение (явная связь)
last_read_message = models.ForeignKey(
'Message',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+',
verbose_name="Последнее прочитанное сообщение"
)
# Упоминания
unread_mentions_count = models.IntegerField(
default=0,
verbose_name="Непрочитанных упоминаний"
)
# Треды
unread_thread_replies_count = models.IntegerField(
default=0,
verbose_name="Непрочитанных ответов в тредах"
)
# Статус набора текста
is_typing = models.BooleanField(
default=False,
verbose_name="Набирает сообщение"
)
typing_updated_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Последнее обновление статуса набора"
)
class Meta:
# заменяем устаревший unique_together на UniqueConstraint
constraints = [
models.UniqueConstraint(
fields=["chat", "user"],
name="uniq_chatreadstate_chat_user",
),
]
indexes = [
models.Index(fields=["chat", "user"]),
models.Index(fields=["chat", "last_read_at"]),
models.Index(fields=["chat", "user", "is_typing"]),
]
def __str__(self):
return f"read:{self.user_id}@{self.chat_id} → {self.last_read_at or '-'}"
class Chat(models.Model):
CHAT_TYPE_CHOICES = [
("private", "Личный"),
("group", "Групповой"),
("department", "Отдел"),
("channel", "Канал"),
("announcement", "Объявления"),
("global", "Глобальный"),
]
type = models.CharField(
max_length=16,
choices=CHAT_TYPE_CHOICES,
verbose_name="Тип чата",
db_index=True
)
# Новые базовые поля
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,
blank=True,
on_delete=models.SET_NULL,
related_name='created_chats',
verbose_name="Создатель"
)
# Для гибкой настройки участников
include_all_employees = models.BooleanField(
default=False,
verbose_name="Включить всех сотрудников",
help_text="Для анонсов и общих чатов"
)
participants = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="chats",
verbose_name="Участники",
help_text="Используется только для личных чатов",
)
department = models.ForeignKey(
Department,
null=True,
blank=True,
on_delete=models.CASCADE, # при удалении отдела чат удалится
verbose_name="Отдел",
help_text="Указывается только для чатов отдела",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
is_main = models.BooleanField(default=False, verbose_name="Основной чат")
class Meta:
verbose_name = "Чат"
verbose_name_plural = "Чаты"
constraints = [
# Ровно один «главный» глобальный чат
models.UniqueConstraint(
fields=["type"],
condition=Q(is_main=True, type="global"),
name="unique_main_global_chat",
),
# Ровно один «главный» чат на отдел
models.UniqueConstraint(
fields=["type", "department"],
condition=Q(is_main=True, type="department"),
name="unique_main_department_chat",
),
]
indexes = [
models.Index(fields=["type", "is_main"]),
models.Index(fields=["department"]),
models.Index(fields=["created_at"]),
]
def clean(self):
super().clean()
# Доп. валидация только для глобального «главного»
if self.is_main and self.type == "global":
exists = (
Chat.objects.exclude(pk=self.pk)
.filter(type="global", is_main=True)
.exists()
)
if exists:
raise ValidationError("Основной глобальный чат уже существует.")
def delete(self, *args, **kwargs):
# Запрещаем удалять только глобальный основной чат
if self.is_main and self.type == "global":
raise ValidationError("Основной глобальный чат компании нельзя удалить!")
return super().delete(*args, **kwargs)
def mark_read(self, user):
"""
Помечает чат прочитанным «до последнего сообщения».
Сделано без update_or_create — устойчиво для SQLite.
"""
last = self.messages.order_by("-created_at").only("created_at").first()
ts = last.created_at if last else timezone.now()
# обновим, только если новое время больше
updated = ChatReadState.objects.filter(
chat=self, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
if not updated:
try:
ChatReadState.objects.create(chat=self, user=user, last_read_at=ts)
except IntegrityError:
# гонка: запись уже есть — попробуем ещё раз обновить
ChatReadState.objects.filter(
chat=self, user=user, last_read_at__lt=ts
).update(last_read_at=ts)
def unread_count_for(self, user):
"""
Количество непрочитанных (кроме своих).
"""
rs = (
ChatReadState.objects.filter(chat=self, user=user)
.only("last_read_at")
.first()
)
qs = self.messages.exclude(author=user)
if rs and rs.last_read_at:
qs = qs.filter(created_at__gt=rs.last_read_at)
return qs.count()
@property
def get_participants(self):
"""
Возвращает QuerySet участников чата (по типу).
Для отдела берём активные связи + руководителя.
"""
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)
return Employee.objects.none()
def __str__(self):
if self.type == "private":
# лениво формируем подпись, без лишних запросов
parts = list(self.participants.values_list("first_name", "last_name")[:3])
names = ", ".join(f"{ln} {fn}".strip() for fn, ln in parts) or "—"
more = self.participants.count() - len(parts)
if more > 0:
names += f" и ещё {more}"
return f"Личный чат: {names}"
if self.type == "group":
return self.name or "Групповой чат"
if self.type == "department":
return f"Чат отдела: {self.department or '—'}"
if self.type == "channel":
return self.name or "Канал"
if self.type == "announcement":
return self.name or "Объявления"
return "Глобальный чат"
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,
related_name='chat_settings'
)
# Закрепление
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:
verbose_name = "Настройки чата"
verbose_name_plural = "Настройки чатов"
unique_together = [('chat', 'user')]
ordering = ['-is_pinned', '-pinned_at']
indexes = [
models.Index(fields=['user', 'is_pinned']),
models.Index(fields=['chat', 'user']),
]
def __str__(self):
return f"{self.user} → {self.chat}"
class Message(models.Model):
chat = models.ForeignKey(
Chat,
related_name="messages",
on_delete=models.CASCADE,
verbose_name="Чат"
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
verbose_name="Автор"
)
content = models.TextField("Текст сообщения")
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Время отправки",
db_index=True
)
# НОВЫЕ ПОЛЯ
# Ответ на сообщение (упрощенная связь)
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)
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)
# Реакции
reactions = models.JSONField(
default=dict,
blank=True,
help_text="Формат: {'👍': [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"]
verbose_name = "Сообщение"
verbose_name_plural = "Сообщения"
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']),
]
def __str__(self):
return f"{self.author}: {self.content[:30]}"
class MessageAttachment(models.Model):
"""Вложения в сообщениях"""
FILE_TYPE_CHOICES = [
('image', 'Изображение'),
('video', 'Видео'),
('audio', 'Аудио'),
('file', 'Файл'),
]
message = models.ForeignKey(
Message,
on_delete=models.CASCADE,
related_name='attachments',
verbose_name="Сообщение"
)
file = models.FileField(
upload_to='chat_attachments/%Y/%m/%d/',
verbose_name="Файл"
)
file_type = models.CharField(
max_length=10,
choices=FILE_TYPE_CHOICES,
verbose_name="Тип файла"
)
file_name = models.CharField(
max_length=255,
verbose_name="Название файла"
)
file_size = models.BigIntegerField(
verbose_name="Размер файла (байты)"
)
mime_type = models.CharField(
max_length=100,
blank=True,
verbose_name="MIME тип"
)
uploaded_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Время загрузки"
)
thumbnail = models.ImageField(
upload_to='chat_attachments/thumbnails/%Y/%m/%d/',
null=True,
blank=True,
verbose_name="Миниатюра",
help_text="Для изображений и видео"
)
class Meta:
verbose_name = "Вложение"
verbose_name_plural = "Вложения"
ordering = ['uploaded_at']
indexes = [
models.Index(fields=['message', 'file_type']),
models.Index(fields=['uploaded_at']),
]
def __str__(self):
return f"{self.file_name} ({self.get_file_type_display()})"
class ForwardedMessage(models.Model):
"""Информация о пересылке сообщения"""
message = models.OneToOneField(
Message,
on_delete=models.CASCADE,
related_name='forward_info',
verbose_name="Пересланное сообщение"
)
original_message = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='forwarded_copies',
verbose_name="Самое первое сообщение в цепочке"
)
immediate_source = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name="Непосредственный источник пересылки",
help_text="Откуда переслали (может отличаться от original)"
)
original_chat = models.ForeignKey(
Chat,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name="Исходный чат"
)
original_author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name="Автор оригинального сообщения"
)
forwarded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='forwarded_messages',
verbose_name="Кто переслал"
)
forwarded_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Время пересылки"
)
forward_count = models.IntegerField(
default=1,
verbose_name="Количество пересылок",
help_text="Сколько раз это сообщение пересылалось"
)
preserved_content = models.TextField(
blank=True,
verbose_name="Сохранённый текст",
help_text="На случай удаления оригинала"
)
preserved_author_name = models.CharField(
max_length=255,
blank=True,
verbose_name="Имя автора оригинала"
)
class Meta:
verbose_name = "Информация о пересылке"
verbose_name_plural = "Информация о пересылках"
indexes = [
models.Index(fields=['original_message']),
models.Index(fields=['forwarded_by', 'forwarded_at']),
]
def __str__(self):
return f"Пересылка: {self.message_id} от {self.forwarded_by}"
class MessageReply(models.Model):
"""Расширенная информация об ответе на сообщение"""
REPLY_TYPE_CHOICES = [
('inline', 'В потоке'),
('quote', 'С цитатой'),
('thread', 'В треде'),
]
message = models.OneToOneField(
Message,
on_delete=models.CASCADE,
related_name='reply_info',
verbose_name="Сообщение-ответ"
)
replied_to = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='extended_replies',
verbose_name="На какое сообщение ответ"
)
is_cross_chat_reply = models.BooleanField(
default=False,
verbose_name="Ответ из другого чата"
)
original_chat = models.ForeignKey(
Chat,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name="Исходный чат",
help_text="Если ответ был из другого чата"
)
preserved_text = models.TextField(
blank=True,
verbose_name="Сохранённый текст оригинала"
)
preserved_author_name = models.CharField(
max_length=255,
blank=True,
verbose_name="Имя автора оригинала"
)
reply_type = models.CharField(
max_length=10,
choices=REPLY_TYPE_CHOICES,
default='inline',
verbose_name="Тип ответа"
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Время создания ответа"
)
class Meta:
verbose_name = "Информация об ответе"
verbose_name_plural = "Информация об ответах"
indexes = [
models.Index(fields=['replied_to']),
models.Index(fields=['is_cross_chat_reply']),
]
def __str__(self):
return f"Ответ: {self.message_id} → {self.replied_to_id}"
class ChatAccessPermission(models.Model):
"""Права доступа к чату для внешних пользователей"""
ACCESS_LEVEL_CHOICES = [
('read', 'Только чтение'),
('write', 'Чтение и отправка'),
('moderate', 'Модерация'),
]
chat = models.ForeignKey(
Chat,
on_delete=models.CASCADE,
related_name='access_permissions',
verbose_name="Чат"
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='chat_access_permissions',
verbose_name="Пользователь"
)
access_level = models.CharField(
max_length=10,
choices=ACCESS_LEVEL_CHOICES,
default='write',
verbose_name="Уровень доступа"
)
granted_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name="Кто предоставил доступ"
)
granted_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Когда предоставлен доступ"
)
expires_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Срок действия",
help_text="Если не указано - бессрочно"
)
is_active = models.BooleanField(
default=True,
verbose_name="Активен"
)
class Meta:
verbose_name = "Право доступа к чату"
verbose_name_plural = "Права доступа к чатам"
unique_together = [('chat', 'user')]
indexes = [
models.Index(fields=['chat', 'is_active']),
models.Index(fields=['user', 'is_active']),
]
def __str__(self):
return f"{self.user} → {self.chat} ({self.get_access_level_display()})"
class CrossChatMessage(models.Model):
"""Сообщения, отправленные в чаты, где пользователь не является участником"""
STATUS_CHOICES = [
('pending', 'На модерации'),
('approved', 'Одобрено'),
('rejected', 'Отклонено'),
]
message = models.OneToOneField(
Message,
on_delete=models.CASCADE,
related_name='cross_chat_info',
verbose_name="Сообщение"
)
sender = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='sent_cross_chat_messages',
verbose_name="Отправитель"
)
target_chat = models.ForeignKey(
Chat,
on_delete=models.CASCADE,
related_name='cross_chat_messages',
verbose_name="Целевой чат"
)
status = models.CharField(
max_length=10,
choices=STATUS_CHOICES,
default='pending',
verbose_name="Статус"
)
requires_moderation = models.BooleanField(
default=True,
verbose_name="Требует модерации"
)
moderated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name="Модератор"
)
moderated_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Время модерации"
)
moderation_note = models.TextField(
blank=True,
verbose_name="Заметка модератора"
)
sent_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Время отправки"
)
class Meta:
verbose_name = "Кросс-чат сообщение"
verbose_name_plural = "Кросс-чат сообщения"
indexes = [
models.Index(fields=['target_chat', 'status']),
models.Index(fields=['sender', 'sent_at']),
models.Index(fields=['status', 'requires_moderation']),
]
def __str__(self):
return f"CrossChat: {self.sender} → {self.target_chat} ({self.status})"
class ChatMembership(models.Model):
"""Явное управление участниками чатов"""
ROLE_CHOICES = [
('owner', 'Владелец'),
('admin', 'Администратор'),
('moderator', 'Модератор'),
('member', 'Участник'),
('guest', 'Гость'),
]
chat = models.ForeignKey(
Chat,
on_delete=models.CASCADE,
related_name='memberships',
verbose_name="Чат"
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='chat_memberships',
verbose_name="Пользователь"
)
role = models.CharField(
max_length=10,
choices=ROLE_CHOICES,
default='member',
verbose_name="Роль"
)
joined_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Дата вступления"
)
invited_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='+',
verbose_name="Кто пригласил"
)
is_active = models.BooleanField(
default=True,
verbose_name="Активен",
help_text="False если вышел из чата"
)
left_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата выхода"
)
can_send_messages = models.BooleanField(
default=True,
verbose_name="Может отправлять сообщения"
)
can_add_members = models.BooleanField(
default=False,
verbose_name="Может добавлять участников"
)
can_remove_members = models.BooleanField(
default=False,
verbose_name="Может удалять участников"
)
can_pin_messages = models.BooleanField(
default=False,
verbose_name="Может закреплять сообщения"
)
class Meta:
verbose_name = "Участник чата"
verbose_name_plural = "Участники чатов"
unique_together = [('chat', 'user')]
indexes = [
models.Index(fields=['chat', 'is_active']),
models.Index(fields=['user', 'is_active']),
models.Index(fields=['chat', 'role']),
]
def __str__(self):
return f"{self.user} в {self.chat} ({self.get_role_display()})"