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:
lizerk 2026-03-09 17:06:20 +03:00
parent 11fbcd1e52
commit 5220a96684
13 changed files with 949 additions and 53 deletions

View File

@ -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'),
]

View 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']

View 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'),
]

View 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
})

View 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

View File

@ -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(),
}

View File

@ -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:
"""

View File

@ -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:
"""

View File

@ -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):

View 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>

View File

@ -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 }}

View File

@ -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>

View File

@ -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 }}
Вы можете изменить настройки уведомлений в своем профиле.