From 5220a96684ac8afe9f0a0da8fc0bb3cbebcc5705 Mon Sep 17 00:00:00 2001 From: lizerk Date: Mon, 9 Mar 2026 17:06:20 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=9C=D0=BE=D0=B4=D1=83=D0=BB?= =?UTF-8?q?=D1=8C=20notifications=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=D1=8E=20=D1=81=D0=B0=D0=BC=D0=BE=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D1=8F=D1=82=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C?= =?UTF-8?q?=D0=B7=D1=83=D0=B5=D0=BC=D1=8B=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit КРИТИЧНЫЕ УЛУЧШЕНИЯ: ✅ Убран хардкод 'EUSRR' → универсальное 'My Site' ✅ Созданы email шаблоны внутри модуля (4 файла) ✅ Централизованная конфигурация в config.py ✅ API перемещен в notifications/api/ Новые файлы: - config.py - централизованные настройки с fallback значениями - templates/notifications/email/*.html/*.txt - HTML и текстовые шаблоны - api/__init__.py, api/views.py, api/urls.py - REST API внутри модуля Изменения в существующих файлах: - senders/email.py: использует config.site_name() и config.from_email() - tasks/email.py: rate_limit, max_retries, retry_delay из config - tasks/push.py: rate_limit, max_retries, retry_delay из config - tasks/websocket.py: max_retries, retry_delay из config - api/v1/notifications/urls.py: реэкспорт из notifications.api (обратная совместимость) Структура шаблонов: 📁 notifications/templates/notifications/email/ ├── notification.html - красивый HTML для обычных уведомлений ├── notification.txt - текстовая версия ├── digest.html - HTML дайджест (daily/weekly) └── digest.txt - текстовая версия дайджеста Конфигурация (config.py): - DEFAULT_CONFIG с fallback значениями для всех параметров - get_config() - объединяет с settings.NOTIFICATIONS_CONFIG - Удобные функции: email_rate_limit(), push_max_retries(), site_name() - Полностью настраиваемо через settings проекта API внутри модуля: - notifications/api/views.py - все endpoint'ы - notifications/api/urls.py - URL patterns - Относительные импорты (from ..models) - Обратная совместимость через api/v1/notifications/urls.py УНИВЕРСАЛЬНОСТЬ: ✅ Нет хардкода проекта ✅ Все шаблоны внутри модуля ✅ API внутри модуля ✅ Конфигурация через settings ✅ Можно скопировать в любой проект Django 5.2+ --- backend/api/v1/notifications/urls.py | 30 +- backend/notifications/api/__init__.py | 19 + backend/notifications/api/urls.py | 32 ++ backend/notifications/api/views.py | 395 ++++++++++++++++++ backend/notifications/config.py | 140 +++++++ backend/notifications/senders/email.py | 9 +- backend/notifications/tasks/email.py | 16 +- backend/notifications/tasks/push.py | 16 +- backend/notifications/tasks/websocket.py | 9 +- .../templates/notifications/email/digest.html | 174 ++++++++ .../templates/notifications/email/digest.txt | 33 ++ .../notifications/email/notification.html | 111 +++++ .../notifications/email/notification.txt | 18 + 13 files changed, 949 insertions(+), 53 deletions(-) create mode 100644 backend/notifications/api/__init__.py create mode 100644 backend/notifications/api/urls.py create mode 100644 backend/notifications/api/views.py create mode 100644 backend/notifications/config.py create mode 100644 backend/notifications/templates/notifications/email/digest.html create mode 100644 backend/notifications/templates/notifications/email/digest.txt create mode 100644 backend/notifications/templates/notifications/email/notification.html create mode 100644 backend/notifications/templates/notifications/email/notification.txt diff --git a/backend/api/v1/notifications/urls.py b/backend/api/v1/notifications/urls.py index 9e6c207d..2148b851 100644 --- a/backend/api/v1/notifications/urls.py +++ b/backend/api/v1/notifications/urls.py @@ -1,32 +1,14 @@ """ URL routing для API v2 уведомлений. +DEPRECATED: Этот файл оставлен для обратной совместимости. +Используйте notifications.api.urls напрямую. + Новая архитектура: verb-based notifications с channel preferences. """ -from django.urls import path -from . import views +# Импортируем из самого модуля notifications для обратной совместимости +from notifications.api.urls import urlpatterns, app_name # noqa -app_name = 'notifications_api_v1' - -urlpatterns = [ - # Список и счетчики - path('', views.get_notifications, name='list'), - path('count/', views.get_unread_count, name='count'), - path('verb-types/', views.get_verb_types, name='verb_types'), - - # Операции с уведомлениями - path('/read/', views.mark_as_read, name='mark_read'), - path('/unread/', views.mark_as_unread, name='mark_unread'), - path('/', views.delete_notification, name='delete'), - - # Массовые операции - path('read-all/', views.mark_all_as_read, name='mark_all_read'), - path('delete-all-read/', views.delete_all_read, name='delete_all_read'), - - # Настройки каналов - path('preferences/', views.channel_preferences, name='preferences'), - - # Web Push интеграция - path('push/subscribe/', views.subscribe_push, name='push_subscribe'), +# app_name = 'notifications_api_v1' - уже импортирован path('push/unsubscribe/', views.unsubscribe_push, name='push_unsubscribe'), ] diff --git a/backend/notifications/api/__init__.py b/backend/notifications/api/__init__.py new file mode 100644 index 00000000..d1c4b5b6 --- /dev/null +++ b/backend/notifications/api/__init__.py @@ -0,0 +1,19 @@ +""" +REST API для системы уведомлений + +Endpoints: +- GET /api/v1/notifications/ - список уведомлений +- GET /api/v1/notifications// - детали уведомления +- GET /api/v1/notifications/unread/count/ - счетчик непрочитанных +- GET /api/v1/notifications/verb-types/ - статистика по типам +- GET /api/v1/notifications/preferences/ - настройки каналов +- PUT /api/v1/notifications/preferences/ - обновить настройки +- POST /api/v1/notifications//read/ - отметить прочитанным +- POST /api/v1/notifications//unread/ - отметить непрочитанным +- DELETE /api/v1/notifications// - удалить уведомление +- DELETE /api/v1/notifications/delete-all-read/ - удалить прочитанные +- POST /api/v1/notifications/push/subscribe/ - подписаться на push +- DELETE /api/v1/notifications/push/unsubscribe/ - отписаться от push +""" + +__all__ = ['views', 'urls'] diff --git a/backend/notifications/api/urls.py b/backend/notifications/api/urls.py new file mode 100644 index 00000000..9e6c207d --- /dev/null +++ b/backend/notifications/api/urls.py @@ -0,0 +1,32 @@ +""" +URL routing для API v2 уведомлений. + +Новая архитектура: verb-based notifications с channel preferences. +""" +from django.urls import path +from . import views + +app_name = 'notifications_api_v1' + +urlpatterns = [ + # Список и счетчики + path('', views.get_notifications, name='list'), + path('count/', views.get_unread_count, name='count'), + path('verb-types/', views.get_verb_types, name='verb_types'), + + # Операции с уведомлениями + path('/read/', views.mark_as_read, name='mark_read'), + path('/unread/', views.mark_as_unread, name='mark_unread'), + path('/', views.delete_notification, name='delete'), + + # Массовые операции + path('read-all/', views.mark_all_as_read, name='mark_all_read'), + path('delete-all-read/', views.delete_all_read, name='delete_all_read'), + + # Настройки каналов + path('preferences/', views.channel_preferences, name='preferences'), + + # Web Push интеграция + path('push/subscribe/', views.subscribe_push, name='push_subscribe'), + path('push/unsubscribe/', views.unsubscribe_push, name='push_unsubscribe'), +] diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py new file mode 100644 index 00000000..07734a0d --- /dev/null +++ b/backend/notifications/api/views.py @@ -0,0 +1,395 @@ +""" +API endpoints для системы уведомлений v2.0 + +Новая архитектура: +- Notification с GenericForeignKey (actor/action_object/target) +- verb вместо category/type +- UserChannelPreferences для настроек каналов +""" +from rest_framework.decorators import api_view, permission_classes +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework import status +from django.db import models + +from ..models import Notification, UserChannelPreferences + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_notifications(request): + """ + Получить список уведомлений с пагинацией и фильтрами + + Query params: + - page: номер страницы (default: 1) + - page_size: размер страницы (default: 20) + - verb: фильтр по типу действия (liked, commented, mentioned и т.д.) + - unread_only: только непрочитанные (true/false) + - search: поиск по описанию + """ + page = int(request.GET.get('page', 1)) + page_size = int(request.GET.get('page_size', 20)) + verb = request.GET.get('verb') + unread_only = request.GET.get('unread_only', 'false').lower() == 'true' + search = request.GET.get('search', '').strip() + + # Базовый queryset + queryset = Notification.objects.filter( + recipient=request.user, + deleted=False, + ).order_by('-timestamp') + + # Фильтры + if verb: + queryset = queryset.filter(verb=verb) + + if unread_only: + queryset = queryset.unread() + + if search: + queryset = queryset.filter(description__icontains=search) + + # Пагинация + total = queryset.count() + start = (page - 1) * page_size + end = start + page_size + notifications = queryset[start:end] + + # Сериализация + data = { + 'total': total, + 'page': page, + 'page_size': page_size, + 'unread_count': queryset.filter(unread=True).count(), + 'notifications': [ + { + 'id': n.id, + 'verb': n.verb, + 'description': n.description, + 'actor': { + 'id': n.actor.id if n.actor else None, + 'name': str(n.actor) if n.actor else 'Система', + 'type': n.actor_content_type.model if n.actor else None, + } if n.actor else None, + 'action_url': n.action_url, + 'unread': n.unread, + 'public': n.public, + 'deleted': n.deleted, + 'emailed': n.emailed, + 'timestamp': n.timestamp.isoformat(), + 'timesince': n.timesince(), + } + for n in notifications + ] + } + + return Response(data) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_unread_count(request): + """Получить количество непрочитанных уведомлений""" + count = Notification.objects.filter( + recipient=request.user, + unread=True, + deleted=False, + ).count() + + return Response({'count': count}) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def mark_as_read(request, notification_id): + """Отметить уведомление как прочитанное""" + try: + notification = Notification.objects.get( + id=notification_id, + recipient=request.user + ) + notification.mark_as_read() + + return Response({ + 'status': 'success', + 'notification_id': notification_id + }) + + except Notification.DoesNotExist: + return Response( + {'status': 'error', 'message': 'Notification not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def mark_as_unread(request, notification_id): + """Отметить уведомление как непрочитанное""" + try: + notification = Notification.objects.get( + id=notification_id, + recipient=request.user + ) + notification.mark_as_unread() + + return Response({ + 'status': 'success', + 'notification_id': notification_id + }) + + except Notification.DoesNotExist: + return Response( + {'status': 'error', 'message': 'Notification not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def mark_all_as_read(request): + """Отметить все уведомления как прочитанные""" + verb = request.data.get('verb') # Опционально - только определенный тип + + queryset = Notification.objects.filter( + recipient=request.user, + unread=True, + deleted=False, + ) + + if verb: + queryset = queryset.filter(verb=verb) + + count = queryset.mark_all_as_read() + + return Response({ + 'status': 'success', + 'count': count + }) + + +@api_view(['DELETE']) +@permission_classes([IsAuthenticated]) +def delete_notification(request, notification_id): + """Удалить уведомление (soft delete)""" + try: + notification = Notification.objects.get( + id=notification_id, + recipient=request.user + ) + notification.deleted = True + notification.save(update_fields=['deleted']) + + return Response({'status': 'success'}) + + except Notification.DoesNotExist: + return Response( + {'status': 'error', 'message': 'Notification not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + +@api_view(['DELETE']) +@permission_classes([IsAuthenticated]) +def delete_all_read(request): + """Удалить все прочитанные уведомления""" + count = Notification.objects.filter( + recipient=request.user, + unread=False, + deleted=False, + ).update(deleted=True) + + return Response({ + 'status': 'success', + 'count': count + }) + + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_verb_types(request): + """ + Получить список типов (verb) уведомлений с количеством + + Возвращает статистику по типам уведомлений пользователя. + """ + from django.db.models import Count, Q + + verb_stats = Notification.objects.filter( + recipient=request.user, + deleted=False, + ).values('verb').annotate( + total=Count('id'), + unread=Count('id', filter=Q(unread=True)) + ).order_by('-total') + + # Маппинг verb → человекочитаемое название + verb_names = { + 'liked': 'Понравилось', + 'commented': 'Прокомментировал', + 'mentioned': 'Упомянул', + 'messaged': 'Отправил сообщение', + 'shared': 'Поделился', + 'posted': 'Опубликовал', + 'created': 'Создал', + 'updated': 'Обновил', + 'approved': 'Одобрил', + 'rejected': 'Отклонил', + 'scheduled': 'Запланировал', + 'notified': 'Уведомил', + } + + data = [ + { + 'verb': item['verb'], + 'name': verb_names.get(item['verb'], item['verb'].title()), + 'total': item['total'], + 'unread': item['unread'], + } + for item in verb_stats + ] + + return Response({'verb_types': data}) + + +@api_view(['GET', 'PUT']) +@permission_classes([IsAuthenticated]) +def channel_preferences(request): + """ + Получить или обновить настройки каналов уведомлений + + GET: Возвращает текущие настройки + PUT: Обновляет настройки + + Настройки: + - web_enabled: WebSocket уведомления (bool) + - email_enabled: Email уведомления (bool) + - email_frequency: instant/daily/weekly/disabled + - push_enabled: Web Push уведомления (bool) + - dnd_enabled: Режим "Не беспокоить" (bool) + - dnd_start_time: Начало DND (HH:MM) + - dnd_end_time: Конец DND (HH:MM) + - disabled_verbs: Список отключенных типов (array) + """ + # Получаем или создаем настройки + prefs, created = UserChannelPreferences.objects.get_or_create( + user=request.user + ) + + if request.method == 'GET': + data = { + 'web_enabled': prefs.web_enabled, + 'email_enabled': prefs.email_enabled, + 'email_frequency': prefs.email_frequency, + 'push_enabled': prefs.push_enabled, + 'dnd_enabled': prefs.dnd_enabled, + 'dnd_start_time': prefs.dnd_start_time.strftime('%H:%M') if prefs.dnd_start_time else None, + 'dnd_end_time': prefs.dnd_end_time.strftime('%H:%M') if prefs.dnd_end_time else None, + 'disabled_verbs': prefs.disabled_verbs, + } + return Response(data) + + elif request.method == 'PUT': + # Обновляем настройки + if 'web_enabled' in request.data: + prefs.web_enabled = request.data['web_enabled'] + + if 'email_enabled' in request.data: + prefs.email_enabled = request.data['email_enabled'] + + if 'email_frequency' in request.data: + frequency = request.data['email_frequency'] + if frequency in ['instant', 'daily', 'weekly', 'disabled']: + prefs.email_frequency = frequency + + if 'push_enabled' in request.data: + prefs.push_enabled = request.data['push_enabled'] + + if 'dnd_enabled' in request.data: + prefs.dnd_enabled = request.data['dnd_enabled'] + + if 'dnd_start_time' in request.data: + from datetime import datetime + time_str = request.data['dnd_start_time'] + if time_str: + prefs.dnd_start_time = datetime.strptime(time_str, '%H:%M').time() + + if 'dnd_end_time' in request.data: + from datetime import datetime + time_str = request.data['dnd_end_time'] + if time_str: + prefs.dnd_end_time = datetime.strptime(time_str, '%H:%M').time() + + if 'disabled_verbs' in request.data: + prefs.disabled_verbs = request.data['disabled_verbs'] + + prefs.save() + + return Response({ + 'status': 'success', + 'message': 'Preferences updated' + }) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def subscribe_push(request): + """ + Подписка на Web Push уведомления + + Body: + - subscription: объект subscription от браузера + """ + from push_notifications.models import WebPushDevice + + subscription_data = request.data.get('subscription') + + if not subscription_data: + return Response( + {'status': 'error', 'message': 'Subscription data required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Регистрируем устройство + device, created = WebPushDevice.objects.update_or_create( + user=request.user, + registration_id=subscription_data.get('endpoint'), + defaults={ + 'p256dh': subscription_data.get('keys', {}).get('p256dh'), + 'auth': subscription_data.get('keys', {}).get('auth'), + 'browser': request.data.get('browser', 'unknown'), + 'active': True, + } + ) + + return Response({ + 'status': 'success', + 'created': created, + 'device_id': device.id + }) + + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def unsubscribe_push(request): + """Отписка от Web Push уведомлений""" + from push_notifications.models import WebPushDevice + + endpoint = request.data.get('endpoint') + + if endpoint: + deleted_count, _ = WebPushDevice.objects.filter( + user=request.user, + registration_id=endpoint + ).delete() + else: + # Удалить все устройства пользователя + deleted_count, _ = WebPushDevice.objects.filter( + user=request.user + ).delete() + + return Response({ + 'status': 'success', + 'deleted_count': deleted_count + }) diff --git a/backend/notifications/config.py b/backend/notifications/config.py new file mode 100644 index 00000000..d7e5814d --- /dev/null +++ b/backend/notifications/config.py @@ -0,0 +1,140 @@ +""" +Централизованная конфигурация модуля notifications + +Все настройки вынесены в один файл для удобства переиспользования. +Значения можно переопределить в settings.py проекта через NOTIFICATIONS_CONFIG. +""" +from django.conf import settings + + +# Базовая конфигурация по умолчанию +DEFAULT_CONFIG = { + # === Каналы доставки === + 'CHANNELS_ENABLED': { + 'email': True, + 'push': True, + 'websocket': True, + }, + + # === Email настройки === + 'EMAIL_RATE_LIMIT': '10/m', # 10 писем в минуту + 'EMAIL_MAX_RETRIES': 3, + 'EMAIL_RETRY_DELAY': 300, # 5 минут в секундах + 'EMAIL_DEFAULT_FREQUENCY': 'instant', # instant, daily, weekly, disabled + + # === Push настройки === + 'PUSH_RATE_LIMIT': '50/m', # 50 push в минуту + 'PUSH_MAX_RETRIES': 2, + 'PUSH_RETRY_DELAY': 60, # 1 минута в секундах + + # === WebSocket настройки === + 'WEBSOCKET_MAX_RETRIES': 1, + 'WEBSOCKET_RETRY_DELAY': 5, # 5 секунд + + # === Digest настройки === + 'DIGEST_DAILY_TIME': '09:00', # Время отправки дейли дайджеста + 'DIGEST_WEEKLY_DAY': 'monday', # День недели для weekly дайджеста + 'DIGEST_WEEKLY_TIME': '09:00', + + # === Общие настройки === + 'SITE_NAME': 'My Site', # Название сайта для email + 'DEFAULT_FROM_EMAIL': None, # None = использовать settings.DEFAULT_FROM_EMAIL + 'NOTIFICATION_MAX_AGE_DAYS': 90, # Храним уведомления 90 дней + 'UNREAD_NOTIFICATIONS_LIMIT': 100, # Максимум непрочитанных для показа + + # === DND режим === + 'DND_ENABLED': True, # Включить поддержку "Не беспокоить" + 'DND_SILENT_CHANNELS': ['websocket'], # В DND только silent websocket +} + + +def get_config(): + """ + Получить конфигурацию модуля. + Объединяет DEFAULT_CONFIG с пользовательскими настройками из settings.NOTIFICATIONS_CONFIG + + Returns: + dict: Итоговая конфигурация + + Usage: + from notifications.config import get_config + config = get_config() + rate_limit = config['EMAIL_RATE_LIMIT'] + """ + config = DEFAULT_CONFIG.copy() + + # Обновляем из settings проекта + user_config = getattr(settings, 'NOTIFICATIONS_CONFIG', {}) + config.update(user_config) + + return config + + +def get(key, default=None): + """ + Получить конкретную настройку по ключу + + Args: + key: Ключ настройки + default: Значение по умолчанию если не найдено + + Returns: + Значение настройки + + Usage: + from notifications.config import get + rate_limit = get('EMAIL_RATE_LIMIT', '10/m') + """ + config = get_config() + return config.get(key, default) + + +# Удобные алиасы для часто используемых настроек +def email_rate_limit(): + """Получить rate limit для email""" + return get('EMAIL_RATE_LIMIT') + + +def email_max_retries(): + """Получить максимум retry для email""" + return get('EMAIL_MAX_RETRIES') + + +def email_retry_delay(): + """Получить задержку retry для email""" + return get('EMAIL_RETRY_DELAY') + + +def push_rate_limit(): + """Получить rate limit для push""" + return get('PUSH_RATE_LIMIT') + + +def push_max_retries(): + """Получить максимум retry для push""" + return get('PUSH_MAX_RETRIES') + + +def push_retry_delay(): + """Получить задержку retry для push""" + return get('PUSH_RETRY_DELAY') + + +def websocket_max_retries(): + """Получить максимум retry для websocket""" + return get('WEBSOCKET_MAX_RETRIES') + + +def websocket_retry_delay(): + """Получить задержку retry для websocket""" + return get('WEBSOCKET_RETRY_DELAY') + + +def site_name(): + """Получить название сайта""" + return get('SITE_NAME') or getattr(settings, 'SITE_NAME', 'My Site') + + +def from_email(): + """Получить from email""" + return get('DEFAULT_FROM_EMAIL') or settings.DEFAULT_FROM_EMAIL diff --git a/backend/notifications/senders/email.py b/backend/notifications/senders/email.py index ddab42c2..a3dfcd99 100644 --- a/backend/notifications/senders/email.py +++ b/backend/notifications/senders/email.py @@ -14,6 +14,7 @@ from django.template.loader import render_to_string from django.core.mail import send_mail from .base import BaseNotificationSender +from notifications import config class EmailNotificationSender(BaseNotificationSender): @@ -86,8 +87,7 @@ class EmailNotificationSender(BaseNotificationSender): 'description': notification.description, 'action_url': self._get_full_url(notification.action_url), 'action_text': 'Посмотреть', - # TODO: Убрать хардкод 'EUSRR', использовать 'My Site' как fallback - 'site_name': getattr(settings, 'SITE_NAME', 'EUSRR'), + 'site_name': config.site_name(), 'site_url': self._get_site_url(), 'verb_icon': self.VERB_ICONS.get(notification.verb, '🔔'), } @@ -100,7 +100,7 @@ class EmailNotificationSender(BaseNotificationSender): send_mail( subject=subject, message=text_message, - from_email=settings.DEFAULT_FROM_EMAIL, + from_email=config.from_email(), recipient_list=[recipient_email], html_message=html_message, fail_silently=False, @@ -158,8 +158,7 @@ class EmailNotificationSender(BaseNotificationSender): 'digest_name': digest_name, 'total_count': len(notifications), 'verb_icons': self.VERB_ICONS, - # TODO: Убрать хардкод 'EUSRR', использовать 'My Site' как fallback - 'site_name': getattr(settings, 'SITE_NAME', 'EUSRR'), + 'site_name': config.site_name(), 'site_url': self._get_site_url(), } diff --git a/backend/notifications/tasks/email.py b/backend/notifications/tasks/email.py index 3aca18ca..6127bb14 100644 --- a/backend/notifications/tasks/email.py +++ b/backend/notifications/tasks/email.py @@ -1,12 +1,8 @@ """ Celery задачи для отправки email уведомлений - -TODO: Вынести конфигурацию в settings: - - NOTIFICATIONS_EMAIL_RATE_LIMIT (сейчас '10/m') - - NOTIFICATIONS_EMAIL_MAX_RETRIES (сейчас 3) - - NOTIFICATIONS_EMAIL_RETRY_DELAY (сейчас 300) """ from .base import BaseNotificationTask +from notifications import config class EmailNotificationTask(BaseNotificationTask): @@ -14,15 +10,15 @@ class EmailNotificationTask(BaseNotificationTask): Celery задача для асинхронной отправки email уведомлений. Особенности: - - Rate limiting: 10 email/минуту (защита от SMTP бана) - - Retry: 3 попытки с интервалом 5 минут + - Rate limiting: из config (default: 10 email/минуту) + - Retry: из config (default: 3 попытки с интервалом 5 минут) - Поддержка кастомных тем и получателей """ task_name = "notifications.send_email" - max_retries = 3 - retry_delay = 300 # 5 минут между попытками - rate_limit = '10/m' # Не более 10 email в минуту + max_retries = config.email_max_retries() + retry_delay = config.email_retry_delay() + rate_limit = config.email_rate_limit() def send_notification(self, notification, **kwargs) -> bool: """ diff --git a/backend/notifications/tasks/push.py b/backend/notifications/tasks/push.py index 28188bef..c52375c7 100644 --- a/backend/notifications/tasks/push.py +++ b/backend/notifications/tasks/push.py @@ -1,12 +1,8 @@ """ Celery задачи для отправки Web Push уведомлений - -TODO: Вынести конфигурацию в settings: - - NOTIFICATIONS_PUSH_RATE_LIMIT (сейчас '50/m') - - NOTIFICATIONS_PUSH_MAX_RETRIES (сейчас 2) - - NOTIFICATIONS_PUSH_RETRY_DELAY (сейчас 60) """ from .base import BaseNotificationTask +from notifications import config class PushNotificationTask(BaseNotificationTask): @@ -14,15 +10,15 @@ class PushNotificationTask(BaseNotificationTask): Celery задача для асинхронной отправки Web Push уведомлений. Особенности: - - Rate limiting: 50 push/минуту - - Retry: 2 попытки с интервалом 1 минута + - Rate limiting: из config (default: 50 push/минуту) + - Retry: из config (default: 2 попытки с интервалом 1 минута) - Автоматическое удаление неактивных устройств """ task_name = "notifications.send_push" - max_retries = 2 - retry_delay = 60 # 1 минута между попытками - rate_limit = '50/m' # Не более 50 push в минуту + max_retries = config.push_max_retries() + retry_delay = config.push_retry_delay() + rate_limit = config.push_rate_limit() def send_notification(self, notification, **kwargs) -> bool: """ diff --git a/backend/notifications/tasks/websocket.py b/backend/notifications/tasks/websocket.py index 94684b9a..61c6b949 100644 --- a/backend/notifications/tasks/websocket.py +++ b/backend/notifications/tasks/websocket.py @@ -2,6 +2,7 @@ Celery задачи для отправки WebSocket уведомлений (realtime) """ from .base import BaseNotificationTask +from notifications import config class WebSocketNotificationTask(BaseNotificationTask): @@ -9,14 +10,14 @@ class WebSocketNotificationTask(BaseNotificationTask): Celery задача для асинхронной отправки WebSocket уведомлений. Особенности: - - Минимальный retry (1 попытка) - realtime не критично - - Быстрая повторная попытка (5 секунд) + - Минимальный retry: из config (default: 1 попытка) + - Быстрая повторная попытка: из config (default: 5 секунд) - Поддержка silent режима (DND) """ task_name = "notifications.send_websocket" - max_retries = 1 # WebSocket - не критично, 1 попытка достаточно - retry_delay = 5 # 5 секунд (быстро) + max_retries = config.websocket_max_retries() + retry_delay = config.websocket_retry_delay() rate_limit = None # Без ограничений для realtime def execute(self, celery_task, notification_id: int, silent: bool = False, **kwargs): diff --git a/backend/notifications/templates/notifications/email/digest.html b/backend/notifications/templates/notifications/email/digest.html new file mode 100644 index 00000000..8544e0e6 --- /dev/null +++ b/backend/notifications/templates/notifications/email/digest.html @@ -0,0 +1,174 @@ + + + + + + 📬 {{ digest_name }} + + + +
+
+ +
+ +
+ 📬 {{ digest_name }} +
+ +
+
{{ total_count }}
+
новых уведомлений
+
+ + {% for verb, notifications in grouped_notifications.items %} +
+
+ {{ verb_icons|default_if_none:''|get_item:verb|default:'🔔' }} + {{ verb|title }} ({{ notifications|length }}) +
+ + {% for notification in notifications %} +
+
+ {% if notification.actor %} + {{ notification.actor }} + {% else %} + Система + {% endif %} +
+ + {% if notification.description %} +
+ {{ notification.description }} +
+ {% endif %} + +
+ {{ notification.timestamp|timesince }} назад +
+
+ {% endfor %} +
+ {% endfor %} + + + + +
+ + diff --git a/backend/notifications/templates/notifications/email/digest.txt b/backend/notifications/templates/notifications/email/digest.txt new file mode 100644 index 00000000..bd9e9a1d --- /dev/null +++ b/backend/notifications/templates/notifications/email/digest.txt @@ -0,0 +1,33 @@ +📬 {{ digest_name }} +================================================================================ + +{{ site_name }} + +У вас {{ total_count }} новых уведомлений + +{% for verb, notifications in grouped_notifications.items %} + +-------------------------------------------------------------------------------- +{{ verb|upper }} ({{ notifications|length }}) +-------------------------------------------------------------------------------- + +{% for notification in notifications %} + * {% if notification.actor %}{{ notification.actor }}{% else %}Система{% endif %} + {% if notification.description %}{{ notification.description }}{% endif %} + ({{ notification.timestamp|timesince }} назад) + +{% endfor %} +{% endfor %} + +================================================================================ + +Посмотреть все уведомления: {{ site_url }}/notifications/ + +Настройки: {{ site_url }}/settings/notifications/ + + +Вы получили это письмо, потому что включили {{ frequency|lower }} дайджест +уведомлений. Вы можете изменить частоту или отключить дайджест в настройках. + +{{ site_name }} +{{ site_url }} diff --git a/backend/notifications/templates/notifications/email/notification.html b/backend/notifications/templates/notifications/email/notification.html new file mode 100644 index 00000000..f46d8694 --- /dev/null +++ b/backend/notifications/templates/notifications/email/notification.html @@ -0,0 +1,111 @@ + + + + + + {{ verb_icon }} Новое уведомление + + + +
+
+ +
+ +
+ {{ verb_icon }} +
+ +
+ {% if actor %} +

{{ actor }} {{ verb }}

+ {% else %} +

Система {{ verb }}

+ {% endif %} + + {% if description %} +

{{ description }}

+ {% endif %} + + {% if action_url %} + + {% endif %} +
+ + +
+ + diff --git a/backend/notifications/templates/notifications/email/notification.txt b/backend/notifications/templates/notifications/email/notification.txt new file mode 100644 index 00000000..ff949f9d --- /dev/null +++ b/backend/notifications/templates/notifications/email/notification.txt @@ -0,0 +1,18 @@ +{{ verb_icon }} {{ site_name }} +================================================================================ + +{% if actor %}{{ actor }}{% else %}Система{% endif %} {{ verb }} + +{% if description %} +{{ description }} +{% endif %} + +{% if action_url %} +{{ action_text }}: {{ action_url }} +{% endif %} + +-------------------------------------------------------------------------------- +Это автоматическое уведомление от {{ site_name }} +{{ site_url }} + +Вы можете изменить настройки уведомлений в своем профиле.