Игорь Надеин 4f6244ab1f
Some checks failed
Deploy production / deploy (push) Failing after 2s
Fix CORP runtime errors and harden deployment and media access
2026-09-10 16:40:08 +03:00

1284 lines
47 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.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q
from django.utils import timezone
User = get_user_model()
class ChatReadState(models.Model):
"""
Состояние прочтения чата пользователем.
Использует Telegram-style подход: последнее прочитанное сообщение
отмечается автоматически при загрузке сообщений через GET запросы.
"""
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",
)
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="Последнее прочитанное сообщение",
help_text=("Обновляется автоматически при загрузке сообщений (Telegram-style)"),
)
# Денормализованный счетчик непрочитанных (оптимизация)
unread_count = models.IntegerField(
default=0,
verbose_name="Непрочитанных сообщений",
help_text=(
"Кешированное значение для производительности. "
"Обновляется при новых сообщениях и прочтении."
),
)
unread_mention_count = models.PositiveIntegerField(
default=0,
verbose_name="Непрочитанных упоминаний",
help_text="Кешированное количество непрочитанных сообщений с @-упоминанием пользователя.",
)
# Статус набора текста
is_typing = models.BooleanField(default=False, verbose_name="Набирает сообщение")
typing_updated_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Последнее обновление статуса набора",
)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["chat", "user"],
name="uniq_chatreadstate_chat_user",
),
]
indexes = [
models.Index(fields=["chat", "user"]),
models.Index(fields=["chat", "user", "is_typing"]),
# Для быстрого поиска по message
models.Index(fields=["last_read_message"]),
# Для быстрых запросов непрочитанных
models.Index(fields=["user", "unread_count"]),
models.Index(fields=["user", "unread_mention_count"]),
]
def __str__(self):
msg_id = self.last_read_message_id if self.last_read_message_id else "-"
return f"read:{self.user_id}@{self.chat_id} → msg#{msg_id}"
@property
def last_read_at(self):
return self.updated_at
class ActiveChatSession(models.Model):
"""Cross-worker presence for a user currently viewing a chat."""
connection_id = models.CharField(max_length=255, unique=True)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="active_chat_sessions",
)
chat = models.ForeignKey(
"Chat",
on_delete=models.CASCADE,
related_name="active_sessions",
)
last_seen_at = models.DateTimeField(auto_now=True)
expires_at = models.DateTimeField(db_index=True)
class Meta:
indexes = [models.Index(fields=["user", "chat", "expires_at"])]
def __str__(self):
return f"{self.user_id} @ {self.chat_id} ({self.connection_id})"
class Chat(models.Model):
CHAT_TYPE_CHOICES = [
("private", "Личный"),
("group", "Групповой"),
("channel", "Канал"),
("announcement", "Объявления"),
("global", "Глобальный"),
("comments", "Комментарии"),
]
type = models.CharField(
max_length=32, # Увеличено с 16 для кастомных типов
choices=CHAT_TYPE_CHOICES,
verbose_name="Тип чата",
db_index=True,
help_text=(
"Chat type: private, group, channel, announcement, "
"global, comments. "
"Use 'group' + context_object for domain-specific chats."
),
)
# Новые базовые поля
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_users = models.BooleanField(
default=False,
verbose_name="Включить всех пользователей",
help_text="Для анонсов и общих чатов",
)
participants = models.ManyToManyField(
settings.AUTH_USER_MODEL,
blank=True,
related_name="chats",
verbose_name="Участники",
)
# ===== Universal context (GenericForeignKey) =====
# Позволяет привязать чат к ЛЮБОЙ модели (Project, Team, Event, Document,
# 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., Project, Team, Event, Document)"),
)
context_object_id = models.PositiveBigIntegerField(
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,
verbose_name="Заблокирован",
help_text=("Для announcement: заблокирован администратором, скрыт у всех"),
)
blocked_at = models.DateTimeField(
null=True, blank=True, verbose_name="Дата блокировки"
)
blocked_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="blocked_chats",
verbose_name="Кто заблокировал",
)
can_reply = models.BooleanField(
default=True,
verbose_name="Можно отвечать",
help_text="False для announcement (только реакции)",
)
class Meta:
verbose_name = "Чат"
verbose_name_plural = "Чаты"
constraints = [
# Ровно один «главный» глобальный чат
models.UniqueConstraint(
fields=["type"],
condition=Q(is_main=True, type="global"),
name="unique_main_global_chat",
),
# Только 1 announcement на сотрудника
models.UniqueConstraint(
fields=["type", "created_by"],
condition=Q(type="announcement"),
name="unique_announcement_per_user",
),
]
indexes = [
models.Index(fields=["type", "is_main"]),
models.Index(fields=["created_at"]),
# Index for GenericForeignKey
models.Index(
fields=["context_content_type", "context_object_id"],
name="chat_context_idx",
),
]
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):
# Удаление глобального чата разрешено администраторам и владельцам
# Проверка прав происходит на уровне API permissions
return super().delete(*args, **kwargs)
def mark_read(self, user):
"""
[DEPRECATED] Помечает чат прочитанным до последнего сообщения.
ВНИМАНИЕ: Этот метод устарел. Используйте автоматическую отметку
через ChatViewSet._auto_mark_read() при загрузке сообщений.
"""
last_message = self.messages.order_by("-created_at").first()
if not last_message:
return
# Обновляем, только если новое сообщение НОВЕЕ текущего
read_state, created = ChatReadState.objects.get_or_create(
chat=self,
user=user,
defaults={
"last_read_message": last_message,
"unread_count": 0,
"unread_mention_count": 0,
},
)
if not created:
if read_state.last_read_message_id:
if last_message.id <= read_state.last_read_message_id:
return # Не откатываем назад
read_state.last_read_message = last_message
read_state.unread_count = 0
read_state.unread_mention_count = 0
read_state.save(
update_fields=[
"last_read_message",
"unread_count",
"unread_mention_count",
"updated_at",
]
)
def unread_count_for(self, user):
"""
Количество непрочитанных сообщений (кроме своих).
ОПТИМИЗИРОВАНО: Использует денормализованное поле
unread_count из ChatReadState
вместо подсчета в реальном времени.
"""
rs = (
ChatReadState.objects.filter(chat=self, user=user)
.only("unread_count")
.first()
)
return rs.unread_count if rs else 0
def get_participants(self):
"""
Возвращает QuerySet участников чата.
MIGRATION: Переписано для использования только ChatMembership
Использует callback из settings.COMMUNICATIONS_PARTICIPANT_RESOLVER
для проектно-специфичной логики.
Fallback логика:
- private/group/channel/announcement: только активные ChatMembership
- global: все активные пользователи
- с include_all_users: все активные пользователи
"""
# Попытка использовать 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(
"Chat participant resolver import failed",
extra={
"event": "chat.participant_resolver.import_failed",
"chat_id": self.pk,
"error_type": type(e).__name__,
},
)
# Fallback логика (универсальная, без бизнес-специфики)
if self.type == "global" or self.include_all_users:
return User.objects.filter(is_active=True)
# Для всех остальных типов - используем только активные memberships
from communications.models import ChatMembership
membership_user_ids = ChatMembership.objects.filter(
chat=self, is_active=True
).values_list("user_id", flat=True)
return User.objects.filter(id__in=membership_user_ids).distinct()
def __str__(self):
if self.type == "private":
# MIGRATION: используем memberships вместо participants
members = ChatMembership.objects.filter(
chat=self, is_active=True
).select_related("user")[:3]
names = (
", ".join(
f"{m.user.last_name} {m.user.first_name}".strip() for m in members
)
or "—"
)
total_count = ChatMembership.objects.filter(
chat=self, is_active=True
).count()
more = total_count - len(members)
if more > 0:
names += f" и ещё {more}"
return f"Личный чат: {names}"
if self.type == "group":
return self.name or "Групповой чат"
if self.type == "comments":
# Comments linked to any object via GenericFK
context = self.context_object if self.context_object else None
return f"Комментарии: {context or self.name or '—'}"
if self.type == "channel":
return self.name or "Канал"
if self.type == "announcement":
return self.name or "Объявления"
return "Глобальный чат"
@property
def include_all_employees(self):
return self.include_all_users
@include_all_employees.setter
def include_all_employees(self, value):
self.include_all_users = value
class ChatUserSettings(models.Model):
"""Персональные настройки пользователя для чата"""
class NotificationMode(models.TextChoices):
ALL = "all", "Все сообщения"
MENTIONS = "mentions", "Только упоминания и ответы"
MUTED = "muted", "Отключены"
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="Уведомления"
)
notification_mode = models.CharField(
max_length=20,
choices=NotificationMode.choices,
default=NotificationMode.ALL,
verbose_name="Режим уведомлений",
)
muted_until = models.DateTimeField(
null=True,
blank=True,
verbose_name="Не беспокоить до",
)
show_notification_preview = models.BooleanField(
default=True,
verbose_name="Показывать текст сообщения",
)
notification_sound_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}"
def effective_notification_mode(self, *, at=None):
"""Return the mode that is active now, including a temporary mute."""
at = at or timezone.now()
if self.muted_until and self.muted_until > at:
return self.NotificationMode.MUTED
if (
not self.notifications_enabled
and self.notification_mode == self.NotificationMode.ALL
and self.muted_until is None
):
return self.NotificationMode.MENTIONS
return self.notification_mode
def allows_notification(self, verb: str, *, at=None) -> bool:
"""Check whether a chat event should create a notification."""
mode = self.effective_notification_mode(at=at)
if mode == self.NotificationMode.MUTED:
return False
if mode == self.NotificationMode.MENTIONS:
return verb in {"chat_mention", "chat_reply"}
return True
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("Текст сообщения")
mentions = models.JSONField(
default=list,
blank=True,
verbose_name="Упоминания",
help_text="Проверенные ссылки на пользователей и диапазоны в тексте сообщения",
)
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)
# Удаление
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)
# Флаги для специальных типов сообщений
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="Корневое сообщение треда",
)
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]}"
def get_reactions_summary(self):
"""
Получить сводку по реакциям для этого сообщения
Возвращает словарь вида:
{
'👍': {
'count': 3,
'users': [1, 2, 3],
'user_names': ['User1', ...],
'user_details': [{'id': 1, 'name': 'User1', 'avatar': '/media/...'}]
},
'❤️': {'count': 1, 'users': [4], 'user_names': ['User4'], 'user_details': [...]}
}
"""
# Используем related_name='reactions' из MessageReaction
reactions_qs = self.reactions.select_related("user")
summary = {}
for reaction in reactions_qs:
emoji = reaction.emoji
if emoji not in summary:
summary[emoji] = {
"count": 0,
"users": [],
"user_names": [],
"user_details": [],
}
user_name = reaction.user.get_full_name() or reaction.user.username
summary[emoji]["count"] += 1
summary[emoji]["users"].append(reaction.user_id)
summary[emoji]["user_names"].append(user_name)
summary[emoji]["user_details"].append(
{
"id": reaction.user_id,
"name": user_name,
"avatar": (
reaction.user.avatar.url
if getattr(reaction.user, "avatar", None)
else None
),
}
)
return summary
class MessageAttachment(models.Model):
"""Вложения в сообщениях"""
FILE_TYPE_CHOICES = [
("image", "Изображение"),
("video", "Видео"),
("audio", "Аудио"),
("file", "Файл"),
]
message = models.ForeignKey(
Message,
on_delete=models.CASCADE,
related_name="attachments",
verbose_name="Сообщение",
null=True,
blank=True,
help_text="Может быть null для временных вложений при редактировании",
)
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="uploaded_chat_attachments",
help_text="Владелец временного вложения; старые вложения без владельца нельзя присоединить",
)
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="Для изображений и видео",
)
# Размеры для изображений и видео
width = models.IntegerField(
null=True,
blank=True,
verbose_name="Ширина",
help_text="Ширина изображения или видео в пикселях",
)
height = models.IntegerField(
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()})"
def save(self, *args, **kwargs):
"""
Автоматически извлекает размеры изображения при сохранении.
Telegram-подход: размеры всегда известны до рендеринга.
"""
# Если это изображение и размеры еще не установлены
if (
self.file_type == "image"
and self.file
and (not self.width or not self.height)
):
try:
from PIL import Image
# Открываем файл
file_obj = self.file.file if hasattr(self.file, "file") else self.file
# Сохраняем позицию курсора
if hasattr(file_obj, "seek"):
file_obj.seek(0)
# Открываем изображение
image = Image.open(file_obj)
self.width, self.height = image.size
# Возвращаем курсор в начало
if hasattr(file_obj, "seek"):
file_obj.seek(0)
except Exception as e:
# Если не удалось извлечь размеры - не критично
import logging
logger = logging.getLogger(__name__)
logger.warning(
"Chat attachment image dimensions could not be extracted",
extra={
"event": "chat.attachment.dimension_extraction_failed",
"attachment_id": self.pk,
"error_type": type(e).__name__,
},
)
super().save(*args, **kwargs)
class MessageEditHistory(models.Model):
"""История редактирования сообщения"""
message = models.ForeignKey(
Message,
on_delete=models.CASCADE,
related_name="edit_history_records",
verbose_name="Сообщение",
)
edited_at = models.DateTimeField(
auto_now_add=True, verbose_name="Время редактирования", db_index=True
)
previous_content = models.TextField(verbose_name="Предыдущий текст")
edited_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
related_name="+",
verbose_name="Кто отредактировал",
)
class Meta:
verbose_name = "Запись истории редактирования"
verbose_name_plural = "История редактирования сообщений"
ordering = ["edited_at"]
indexes = [
models.Index(fields=["message", "edited_at"]),
]
def __str__(self):
return f"Редактирование сообщения {self.message_id} в {self.edited_at}"
class MessageForwardMetadata(models.Model):
"""Метаданные пересланного сообщения"""
message = models.OneToOneField(
Message,
on_delete=models.CASCADE,
related_name="forward_metadata",
verbose_name="Пересланное сообщение",
)
original_message = models.ForeignKey(
Message,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="forwarded_copies",
verbose_name="Оригинальное сообщение",
help_text="Самое первое сообщение в цепочке пересылок",
)
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="Сколько раз пересылалось",
)
original_created_at = models.DateTimeField(
null=True, blank=True, verbose_name="Дата создания оригинала"
)
original_chat_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.forward_count}x"
class ChatMembership(models.Model):
"""
Явное управление участниками чатов
Роли и права:
- admin: полный доступ к управлению чатом (кроме удаления)
- moderator: может модерировать контент
(закреплять, удалять чужие сообщения)
- member: обычный участник (может отправлять сообщения)
- guest: ограниченный доступ (только чтение,
может быть отключен can_send_messages)
Примечание: роль 'owner' (владелец) НЕ используется - владелец определяется
через Chat.created_by и имеет максимальные права через django-rules.
"""
ROLE_CHOICES = [
("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()})"
@property
def can_manage_members(self):
"""Может ли участник управлять другими участниками.
Добавлять и удалять пользователей.
"""
return self.can_add_members and self.can_remove_members
def set_permissions_for_role(self):
"""
Устанавливает права доступа автоматически на основе роли.
Вызывается при save() если права не были установлены явно.
"""
if self.role == "admin":
self.can_send_messages = True
self.can_add_members = True
self.can_remove_members = True
self.can_pin_messages = True
elif self.role == "moderator":
self.can_send_messages = True
self.can_add_members = False
self.can_remove_members = False
self.can_pin_messages = True
elif self.role == "member":
self.can_send_messages = True
self.can_add_members = False
self.can_remove_members = False
self.can_pin_messages = False
elif self.role == "guest":
self.can_send_messages = False
self.can_add_members = False
self.can_remove_members = False
self.can_pin_messages = False
def promote_to_admin(self):
"""Повышает участника до администратора"""
self.role = "admin"
self.set_permissions_for_role()
self.save()
def demote_to_member(self):
"""Понижает участника до обычного члена"""
self.role = "member"
self.set_permissions_for_role()
self.save()
def save(self, *args, **kwargs):
# Автоматически устанавливаем права при создании
if not self.pk:
self.set_permissions_for_role()
super().save(*args, **kwargs)
class MessageReaction(models.Model):
"""Реакции на сообщения (эмодзи)"""
message = models.ForeignKey(
Message,
on_delete=models.CASCADE,
related_name="reactions",
verbose_name="Сообщение",
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="message_reactions",
verbose_name="Пользователь",
)
emoji = models.CharField(
max_length=10,
verbose_name="Эмодзи",
help_text="Unicode эмодзи (например: 👍, ❤️, 😂)",
)
created_at = models.DateTimeField(
auto_now_add=True, verbose_name="Время добавления"
)
class Meta:
verbose_name = "Реакция на сообщение"
verbose_name_plural = "Реакции на сообщения"
unique_together = [("message", "user")]
indexes = [
models.Index(fields=["message", "emoji"]),
models.Index(fields=["message", "user"]),
models.Index(fields=["created_at"]),
]
ordering = ["created_at"]
def __str__(self):
return f"{self.user} {self.emoji} → сообщение {self.message_id}"
class AvailableReaction(models.Model):
"""Доступные реакции для сообщений"""
emoji = models.CharField(
max_length=10,
unique=True,
verbose_name="Эмодзи",
help_text="Unicode эмодзи (например: 👍, ❤️, 😂)",
)
name = models.CharField(
max_length=50,
verbose_name="Название",
help_text="Человекочитаемое название (например: 'Лайк', 'Сердце')",
)
order = models.IntegerField(
default=0,
verbose_name="Порядок отображения",
help_text="Меньше = выше в списке",
)
is_active = models.BooleanField(
default=True,
verbose_name="Активна",
help_text="Отображать ли эту реакцию пользователям",
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Дата добавления")
class Meta:
verbose_name = "Доступная реакция"
verbose_name_plural = "Доступные реакции"
ordering = ["order", "created_at"]
indexes = [
models.Index(fields=["is_active", "order"]),
]
def __str__(self):
return f"{self.emoji} {self.name}"
class Poll(models.Model):
"""Голосование в чате"""
message = models.OneToOneField(
Message,
on_delete=models.CASCADE,
related_name="poll",
verbose_name="Сообщение",
)
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="created_polls",
verbose_name="Автор",
)
question = models.CharField(max_length=500, verbose_name="Вопрос")
# Настройки голосования
is_anonymous = models.BooleanField(
default=False,
verbose_name="Анонимное голосование",
help_text="Не показывать кто голосовал",
)
is_multiple_choice = models.BooleanField(
default=False,
verbose_name="Множественный выбор",
help_text="Можно выбрать несколько вариантов",
)
is_quiz = models.BooleanField(
default=False,
verbose_name="Викторина",
help_text=("Только один правильный ответ, показывается после голосования"),
)
allows_custom_answers = models.BooleanField(
default=False,
verbose_name="Разрешить свои варианты",
help_text="Пользователи могут добавить свой вариант ответа",
)
# Статус
is_closed = models.BooleanField(default=False, verbose_name="Голосование закрыто")
closed_at = models.DateTimeField(
null=True, blank=True, verbose_name="Время закрытия"
)
closes_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Автоматическое закрытие",
help_text="Голосование автоматически закроется в указанное время",
)
# Метаданные
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создано")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Обновлено")
total_voters = models.IntegerField(
default=0,
verbose_name="Всего проголосовало",
help_text="Количество уникальных пользователей, проголосовавших",
)
class Meta:
verbose_name = "Голосование"
verbose_name_plural = "Голосования"
ordering = ["-created_at"]
indexes = [
models.Index(fields=["message"]),
models.Index(fields=["author", "created_at"]),
models.Index(fields=["is_closed", "closes_at"]),
]
def __str__(self):
return f"Голосование: {self.question[:50]}"
def close(self):
"""Закрыть голосование"""
if not self.is_closed:
self.is_closed = True
self.closed_at = timezone.now()
self.save(update_fields=["is_closed", "closed_at"])
def get_results(self):
"""Получить результаты голосования"""
options = self.options.annotate(vote_count_calc=models.Count("votes")).order_by(
"position"
)
total_votes = sum(opt.vote_count for opt in options)
results = []
for option in options:
percentage = (
(option.vote_count / total_votes * 100) if total_votes > 0 else 0
)
results.append(
{
"id": option.id,
"text": option.text,
"vote_count": option.vote_count,
"percentage": round(percentage, 1),
"is_correct": option.is_correct,
"voters": []
if self.is_anonymous
else list(
option.votes.select_related("voter").values(
"voter__id",
"voter__first_name",
"voter__last_name",
"voter__email",
)
),
}
)
return {
"id": self.id,
"question": self.question,
"total_voters": self.total_voters,
"is_closed": self.is_closed,
"is_anonymous": self.is_anonymous,
"is_multiple_choice": self.is_multiple_choice,
"is_quiz": self.is_quiz,
"options": results,
}
class PollOption(models.Model):
"""Вариант ответа в голосовании"""
poll = models.ForeignKey(
Poll,
on_delete=models.CASCADE,
related_name="options",
verbose_name="Голосование",
)
text = models.CharField(max_length=200, verbose_name="Текст варианта")
position = models.IntegerField(default=0, verbose_name="Порядковый номер")
vote_count = models.IntegerField(default=0, verbose_name="Количество голосов")
is_correct = models.BooleanField(
default=False, verbose_name="Правильный ответ", help_text="Для викторин"
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Создан")
class Meta:
verbose_name = "Вариант ответа"
verbose_name_plural = "Варианты ответов"
ordering = ["position", "id"]
indexes = [
models.Index(fields=["poll", "position"]),
]
def __str__(self):
return f"{self.poll.question[:30]} - {self.text[:30]}"
class PollVote(models.Model):
"""Голос пользователя в голосовании"""
poll = models.ForeignKey(
Poll,
on_delete=models.CASCADE,
related_name="votes",
verbose_name="Голосование",
)
option = models.ForeignKey(
PollOption,
on_delete=models.CASCADE,
related_name="votes",
verbose_name="Выбранный вариант",
)
voter = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="poll_votes",
verbose_name="Проголосовавший",
)
voted_at = models.DateTimeField(auto_now_add=True, verbose_name="Время голосования")
class Meta:
verbose_name = "Голос"
verbose_name_plural = "Голоса"
ordering = ["-voted_at"]
constraints = [
models.UniqueConstraint(
fields=["poll", "voter", "option"],
name="unique_vote_per_option",
)
]
indexes = [
models.Index(fields=["poll", "voter"]),
models.Index(fields=["option"]),
]
def __str__(self):
return f"{self.voter} → {self.option.text}"