""" Упрощенная универсальная система уведомлений Основана на архитектуре django-notifications-hq, адаптирована для Django 5.2+ Ключевые изменения: - Одна модель Notification вместо 6 - GenericForeignKey для универсальности - Простой API через сигналы - Multi-channel поддержка (Web, Email, Push) TODO: Добавить тесты для моделей: - NotificationQuerySet методы (unread, read, mark_all_as_*) - Notification.mark_as_read/unread() - Notification.timesince() - UserChannelPreferences.is_verb_enabled() - UserChannelPreferences.is_in_dnd_period() """ from django.db import models from django.conf import settings from django.utils import timezone from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db.models.query import QuerySet class NotificationQuerySet(QuerySet): """QuerySet с удобными методами фильтрации""" def unread(self): """Непрочитанные уведомления""" return self.filter(unread=True) def read(self): """Прочитанные уведомления""" return self.filter(unread=False) def mark_all_as_read(self, recipient=None): """Отметить все как прочитанные""" qs = self.unread() if recipient: qs = qs.filter(recipient=recipient) return qs.update( unread=False, timestamp_read=timezone.now() ) def mark_all_as_unread(self, recipient=None): """Отметить все как непрочитанные""" qs = self.read() if recipient: qs = qs.filter(recipient=recipient) return qs.update( unread=True, timestamp_read=None ) def deleted(self): """Удаленные уведомления""" return self.filter(deleted=True) def active(self): """Активные (не удаленные) уведомления""" return self.filter(deleted=False) def public(self): """Публичные уведомления""" return self.filter(public=True) def for_user(self, user): """Уведомления для конкретного пользователя""" return self.filter(recipient=user).active() class Notification(models.Model): """ Универсальная модель уведомления Структура по образу django-notifications-hq: - actor performed verb on action_object at target Примеры: - John (actor) commented (verb) on photo (target) - Mary (actor) liked (verb) your comment (action_object) on post (target) - System (actor) approved (verb) your request (action_object) """ # === Базовые поля === recipient = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='notifications', verbose_name='Получатель', help_text='Кому адресовано уведомление' ) # Кто совершил действие (GenericForeignKey) actor_content_type = models.ForeignKey( ContentType, related_name='notify_actor', on_delete=models.CASCADE, null=True, blank=True, verbose_name='Тип актора' ) actor_object_id = models.CharField( max_length=255, null=True, blank=True, verbose_name='ID актора' ) actor = GenericForeignKey('actor_content_type', 'actor_object_id') # Что произошло verb = models.CharField( max_length=255, default='notification', verbose_name='Действие', help_text='Например: liked, commented, approved, mentioned', db_index=True ) # Объект действия (GenericForeignKey) - опционально action_object_content_type = models.ForeignKey( ContentType, related_name='notify_action_object', on_delete=models.CASCADE, null=True, blank=True, verbose_name='Тип объекта действия' ) action_object_object_id = models.CharField( max_length=255, null=True, blank=True, verbose_name='ID объекта действия' ) action_object = GenericForeignKey( 'action_object_content_type', 'action_object_object_id' ) # Целевой объект (GenericForeignKey) - опционально target_content_type = models.ForeignKey( ContentType, related_name='notify_target', on_delete=models.CASCADE, null=True, blank=True, verbose_name='Тип целевого объекта' ) target_object_id = models.CharField( max_length=255, null=True, blank=True, verbose_name='ID целевого объекта' ) target = GenericForeignKey('target_content_type', 'target_object_id') # === Контент === description = models.TextField( blank=True, null=True, verbose_name='Описание', help_text='Человекочитаемое описание уведомления' ) # URL для перехода action_url = models.CharField( max_length=500, blank=True, null=True, verbose_name='URL действия', help_text='Куда перейти при клике на уведомление' ) # Дополнительные данные (JSON) data = models.JSONField( default=dict, blank=True, verbose_name='Дополнительные данные', help_text='Любые метаданные для рендеринга или логики' ) # === Статусы === unread = models.BooleanField( default=True, db_index=True, verbose_name='Непрочитано' ) timestamp_read = models.DateTimeField( null=True, blank=True, verbose_name='Время прочтения' ) public = models.BooleanField( default=True, db_index=True, verbose_name='Публичное', help_text='Можно показывать другим пользователям' ) deleted = models.BooleanField( default=False, db_index=True, verbose_name='Удалено', help_text='Мягкое удаление' ) emailed = models.BooleanField( default=False, db_index=True, verbose_name='Отправлено на email' ) # === Временные метки === timestamp = models.DateTimeField( default=timezone.now, db_index=True, verbose_name='Создано' ) # === Manager === objects = NotificationQuerySet.as_manager() class Meta: ordering = ['-timestamp'] indexes = [ models.Index(fields=['recipient', '-timestamp']), models.Index(fields=['recipient', 'unread', '-timestamp']), models.Index(fields=['verb', '-timestamp']), models.Index(fields=['-timestamp']), ] verbose_name = 'Уведомление' verbose_name_plural = 'Уведомления' db_table = 'notifications_notification_v2' # Новая таблица для миграции def __str__(self): actor_str = str(self.actor) if self.actor else 'Система' target_str = f' → {self.target}' if self.target else '' return f'{actor_str} {self.verb}{target_str}' # === Методы === def mark_as_read(self): """Отметить уведомление как прочитанное""" if self.unread: self.unread = False self.timestamp_read = timezone.now() self.save(update_fields=['unread', 'timestamp_read']) def mark_as_unread(self): """Отметить уведомление как непрочитанное""" if not self.unread: self.unread = True self.timestamp_read = None self.save(update_fields=['unread', 'timestamp_read']) @property def timesince(self, now=None): """ Возвращает человекочитаемое время с момента создания Например: "5 минут назад", "2 часа назад" """ from django.utils.timesince import timesince as timesince_ return timesince_(self.timestamp, now) @property def slug(self): """ Создает slug для уведомления (для URL) Формат: id-verb-target """ return f"{self.id or ''}-{self.verb}-{self.target_object_id or ''}" class UserChannelPreferences(models.Model): """ Настройки каналов доставки для пользователя Упрощенная модель вместо UserNotificationSettings Один пользователь - одна запись с настройками для всех каналов """ user = models.OneToOneField( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='channel_preferences', verbose_name='Пользователь' ) # === Каналы доставки === web_enabled = models.BooleanField( default=True, verbose_name='Веб уведомления', help_text='Показывать в интерфейсе сайта' ) email_enabled = models.BooleanField( default=False, verbose_name='Email уведомления' ) push_enabled = models.BooleanField( default=False, verbose_name='Push уведомления', help_text='Web Push для браузера' ) # === Email настройки === EMAIL_FREQUENCY_CHOICES = [ ('instant', 'Мгновенно'), ('daily', 'Ежедневный дайджест'), ('weekly', 'Еженедельный дайджест'), ('never', 'Никогда'), ] email_frequency = models.CharField( max_length=20, choices=EMAIL_FREQUENCY_CHOICES, default='instant', verbose_name='Частота email' ) # === Тихий режим (Do Not Disturb) === dnd_enabled = models.BooleanField( default=False, verbose_name='Режим "Не беспокоить"', help_text='Отключить все уведомления в определенное время' ) dnd_start_time = models.TimeField( null=True, blank=True, verbose_name='Начало тихого режима', help_text='Например, 22:00' ) dnd_end_time = models.TimeField( null=True, blank=True, verbose_name='Конец тихого режима', help_text='Например, 08:00' ) # === Фильтры по типам (verb) === disabled_verbs = models.JSONField( default=list, blank=True, verbose_name='Отключенные типы', help_text='Список verb для которых не показывать уведомления. Например: ["liked", "followed"]' ) class Meta: verbose_name = 'Настройки каналов пользователя' verbose_name_plural = 'Настройки каналов пользователей' db_table = 'notifications_userchannelpreferences' def __str__(self): return f'{self.user.get_short_name()} - настройки каналов' def is_verb_enabled(self, verb): """Проверить, включен ли этот тип уведомлений""" return verb not in self.disabled_verbs def disable_verb(self, verb): """Отключить определенный тип уведомлений""" if verb not in self.disabled_verbs: self.disabled_verbs.append(verb) self.save(update_fields=['disabled_verbs']) def enable_verb(self, verb): """Включить определенный тип уведомлений""" if verb in self.disabled_verbs: self.disabled_verbs.remove(verb) self.save(update_fields=['disabled_verbs']) def is_in_dnd_period(self): """Проверить, находимся ли в режиме "Не беспокоить""" if not self.dnd_enabled or not self.dnd_start_time or not self.dnd_end_time: return False now = timezone.now().time() # Простой случай: начало < конец (например, 09:00 - 18:00) if self.dnd_start_time < self.dnd_end_time: return self.dnd_start_time <= now <= self.dnd_end_time # Через полночь: начало > конец (например, 22:00 - 08:00) return now >= self.dnd_start_time or now <= self.dnd_end_time # === Web Push устройства управляются через django-push-notifications === # Используйте: from push_notifications.models import WebPushDevice