refactor: Модуль notifications полностью самостоятельный и переиспользуемый
КРИТИЧНЫЕ УЛУЧШЕНИЯ: ✅ Убран хардкод '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+
This commit is contained in:
parent
11fbcd1e52
commit
5220a96684
@ -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('<int:notification_id>/read/', views.mark_as_read, name='mark_read'),
|
||||
path('<int:notification_id>/unread/', views.mark_as_unread, name='mark_unread'),
|
||||
path('<int:notification_id>/', 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'),
|
||||
]
|
||||
|
||||
19
backend/notifications/api/__init__.py
Normal file
19
backend/notifications/api/__init__.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""
|
||||
REST API для системы уведомлений
|
||||
|
||||
Endpoints:
|
||||
- GET /api/v1/notifications/ - список уведомлений
|
||||
- GET /api/v1/notifications/<id>/ - детали уведомления
|
||||
- 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/<id>/read/ - отметить прочитанным
|
||||
- POST /api/v1/notifications/<id>/unread/ - отметить непрочитанным
|
||||
- DELETE /api/v1/notifications/<id>/ - удалить уведомление
|
||||
- DELETE /api/v1/notifications/delete-all-read/ - удалить прочитанные
|
||||
- POST /api/v1/notifications/push/subscribe/ - подписаться на push
|
||||
- DELETE /api/v1/notifications/push/unsubscribe/ - отписаться от push
|
||||
"""
|
||||
|
||||
__all__ = ['views', 'urls']
|
||||
32
backend/notifications/api/urls.py
Normal file
32
backend/notifications/api/urls.py
Normal file
@ -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('<int:notification_id>/read/', views.mark_as_read, name='mark_read'),
|
||||
path('<int:notification_id>/unread/', views.mark_as_unread, name='mark_unread'),
|
||||
path('<int:notification_id>/', 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'),
|
||||
]
|
||||
395
backend/notifications/api/views.py
Normal file
395
backend/notifications/api/views.py
Normal file
@ -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
|
||||
})
|
||||
140
backend/notifications/config.py
Normal file
140
backend/notifications/config.py
Normal file
@ -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
|
||||
@ -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(),
|
||||
}
|
||||
|
||||
|
||||
@ -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:
|
||||
"""
|
||||
|
||||
@ -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:
|
||||
"""
|
||||
|
||||
@ -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):
|
||||
|
||||
174
backend/notifications/templates/notifications/email/digest.html
Normal file
174
backend/notifications/templates/notifications/email/digest.html
Normal file
@ -0,0 +1,174 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>📬 {{ digest_name }}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 650px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.digest-title {
|
||||
font-size: 28px;
|
||||
margin: 20px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.digest-summary {
|
||||
background-color: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
.digest-summary .count {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #3498db;
|
||||
}
|
||||
.digest-summary .label {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
.verb-group {
|
||||
margin: 30px 0;
|
||||
border-left: 4px solid #3498db;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.verb-group-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.notification-item {
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
border-left: 3px solid #e0e0e0;
|
||||
}
|
||||
.notification-item:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
.notification-actor {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.notification-description {
|
||||
color: #555;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.notification-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
.view-all-button {
|
||||
display: inline-block;
|
||||
padding: 14px 28px;
|
||||
background-color: #3498db;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
.view-all-button:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
.footer a {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">{{ site_name }}</div>
|
||||
</div>
|
||||
|
||||
<div class="digest-title">
|
||||
📬 {{ digest_name }}
|
||||
</div>
|
||||
|
||||
<div class="digest-summary">
|
||||
<div class="count">{{ total_count }}</div>
|
||||
<div class="label">новых уведомлений</div>
|
||||
</div>
|
||||
|
||||
{% for verb, notifications in grouped_notifications.items %}
|
||||
<div class="verb-group">
|
||||
<div class="verb-group-title">
|
||||
{{ verb_icons|default_if_none:''|get_item:verb|default:'🔔' }}
|
||||
{{ verb|title }} ({{ notifications|length }})
|
||||
</div>
|
||||
|
||||
{% for notification in notifications %}
|
||||
<div class="notification-item">
|
||||
<div>
|
||||
{% if notification.actor %}
|
||||
<span class="notification-actor">{{ notification.actor }}</span>
|
||||
{% else %}
|
||||
<span class="notification-actor">Система</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if notification.description %}
|
||||
<div class="notification-description">
|
||||
{{ notification.description }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="notification-time">
|
||||
{{ notification.timestamp|timesince }} назад
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div style="text-align: center; margin-top: 40px;">
|
||||
<a href="{{ site_url }}/notifications/" class="view-all-button">
|
||||
Посмотреть все уведомления
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Вы получили это письмо, потому что включили {{ frequency|lower }} дайджест уведомлений</p>
|
||||
<p>Измените частоту или отключите дайджест в <a href="{{ site_url }}/settings/notifications/">настройках</a></p>
|
||||
<p><a href="{{ site_url }}">{{ site_name }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -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 }}
|
||||
@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ verb_icon }} Новое уведомление</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.logo {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.notification-icon {
|
||||
font-size: 48px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.notification-content {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.actor {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
}
|
||||
.description {
|
||||
color: #555;
|
||||
margin: 10px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.action-button {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #3498db;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
margin: 20px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
.action-button:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
}
|
||||
.footer a {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<div class="logo">{{ site_name }}</div>
|
||||
</div>
|
||||
|
||||
<div class="notification-icon">
|
||||
{{ verb_icon }}
|
||||
</div>
|
||||
|
||||
<div class="notification-content">
|
||||
{% if actor %}
|
||||
<p><span class="actor">{{ actor }}</span> {{ verb }}</p>
|
||||
{% else %}
|
||||
<p><span class="actor">Система</span> {{ verb }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if description %}
|
||||
<p class="description">{{ description }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if action_url %}
|
||||
<div style="text-align: center;">
|
||||
<a href="{{ action_url }}" class="action-button">{{ action_text }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Это автоматическое уведомление от <a href="{{ site_url }}">{{ site_name }}</a></p>
|
||||
<p>Вы можете изменить настройки уведомлений в своем профиле</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@ -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 }}
|
||||
|
||||
Вы можете изменить настройки уведомлений в своем профиле.
|
||||
Loading…
x
Reference in New Issue
Block a user