954 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
API endpoints для системы уведомлений v2.0
Новая архитектура:
- Notification с GenericForeignKey (actor/action_object/target)
- verb вместо category/type
- UserChannelPreferences для настроек каналов
"""
from datetime import datetime
import uuid
from drf_spectacular.utils import OpenApiParameter, extend_schema
from django.db.models import Count, Q
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ..models import (
Notification,
NotificationDeliveryRouting,
NotificationInstallation,
UserChannelPreferences,
)
from ..routing import NOTIFICATION_CATEGORIES, normalize_routes
from ..cache import (
get_unread_count as get_cached_unread_count,
get_unread_summary,
invalidate_unread_summary,
)
from ..realtime import (
send_notification_read_event,
send_notifications_deleted_event,
send_notifications_read_all_event,
)
from ..push_lifecycle import dismiss_notifications_push
from .serializers import (
ChannelPreferencesSerializer,
CountResponseSerializer,
DeliveryRoutingRequestSerializer,
DeliveryRoutingResponseSerializer,
DisconnectInstallationRequestSerializer,
InstallationHeartbeatRequestSerializer,
MarkAllAsReadRequestSerializer,
MarkCategoryAsReadRequestSerializer,
NotificationsListResponseSerializer,
StatusResponseSerializer,
SubscribePushRequestSerializer,
UnsubscribePushRequestSerializer,
UpdateChannelPreferencesSerializer,
UnreadSummaryResponseSerializer,
VapidPublicKeyResponseSerializer,
VerbTypesResponseSerializer,
)
def _format_time_or_none(value):
if value is None:
return None
return value.strftime('%H:%M')
def _parse_time_or_none(value):
if not value:
return None
return datetime.strptime(value, '%H:%M').time()
@extend_schema(
tags=["Notifications"],
summary="Получить список уведомлений",
parameters=[
OpenApiParameter("page", int, OpenApiParameter.QUERY),
OpenApiParameter("page_size", int, OpenApiParameter.QUERY),
OpenApiParameter("verb", str, OpenApiParameter.QUERY),
OpenApiParameter("unread_only", bool, OpenApiParameter.QUERY),
OpenApiParameter("search", str, OpenApiParameter.QUERY),
],
responses=NotificationsListResponseSerializer,
)
@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: поиск по описанию
"""
try:
page = int(request.GET.get('page', 1))
except (ValueError, TypeError):
page = 1
try:
page_size = int(request.GET.get('page_size', 20))
except (ValueError, TypeError):
page_size = 20
page = max(page, 1)
page_size = min(max(page_size, 1), 100)
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,
).select_related(
'actor_content_type',
).prefetch_related(
'actor',
).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]
# Сериализация (совместимость с frontend)
data = {
'total': total,
'page': page,
'page_size': page_size,
'unread_count': total if unread_only else queryset.filter(unread=True).count(),
'notifications': [
{
'id': n.id,
# Frontend ожидает title, message, is_read, created_at
'title': (
n.data['title']
if n.data and 'title' in n.data
else n.verb.replace('_', ' ').title()
),
'message': n.description,
'short_message': (
n.description[:100] + '...'
if len(n.description) > 100
else n.description
),
'is_read': not n.unread,
'created_at': n.timestamp.isoformat(),
'category': n.verb,
'action_url': n.action_url,
'data': n.data,
# Дополнительные поля для обратной совместимости
'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,
'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)
@extend_schema(
tags=["Notifications"],
summary="Получить количество непрочитанных уведомлений",
responses=CountResponseSerializer,
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_unread_count(request):
"""Получить количество непрочитанных уведомлений"""
count = get_cached_unread_count(request.user.id)
return Response({'count': count})
@extend_schema(
tags=["Notifications"],
summary="Получить сводку непрочитанных уведомлений по типам",
responses=UnreadSummaryResponseSerializer,
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_unread_summary_view(request):
"""Получить cached-сводку непрочитанных уведомлений по verb."""
return Response(get_unread_summary(request.user.id))
@extend_schema(
tags=["Notifications"],
summary="Отметить уведомление как прочитанное",
request=None,
responses={200: StatusResponseSerializer, 404: StatusResponseSerializer},
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def mark_as_read(request, notification_id):
"""Отметить уведомление как прочитанное"""
try:
notification = Notification.objects.get(
id=notification_id,
recipient=request.user
)
was_unread = notification.unread
notification.mark_as_read()
if was_unread:
invalidate_unread_summary(request.user.id)
send_notification_read_event(request.user.id, notification_id)
dismiss_notifications_push(
user_id=request.user.id,
notification_ids=[notification_id],
)
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
)
@extend_schema(
tags=["Notifications"],
summary="Отметить уведомление как непрочитанное",
request=None,
responses={200: StatusResponseSerializer, 404: StatusResponseSerializer},
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def mark_as_unread(request, notification_id):
"""Отметить уведомление как непрочитанное"""
try:
notification = Notification.objects.get(
id=notification_id,
recipient=request.user
)
was_read = not notification.unread
notification.mark_as_unread()
if was_read:
invalidate_unread_summary(request.user.id)
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
)
@extend_schema(
tags=["Notifications"],
summary="Отметить все уведомления как прочитанные",
request=MarkAllAsReadRequestSerializer,
responses=StatusResponseSerializer,
)
@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)
notification_ids = list(queryset.values_list('id', flat=True))
count = queryset.mark_all_as_read()
if count:
invalidate_unread_summary(request.user.id)
if notification_ids:
send_notifications_read_all_event(
request.user.id,
notification_ids,
category=verb,
)
dismiss_notifications_push(
user_id=request.user.id,
notification_ids=notification_ids,
)
return Response({
'status': 'success',
'count': count
})
@extend_schema(
tags=["Notifications"],
summary="Отметить группу уведомлений как прочитанную",
request=MarkCategoryAsReadRequestSerializer,
responses={200: StatusResponseSerializer, 400: StatusResponseSerializer},
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def mark_category_as_read(request):
"""
Отметить группу уведомлений по списку verb'ов как прочитанные
Body:
- verbs: список verb'ов для отметки (array)
- category: название категории (опционально, для логирования)
Example:
POST /api/v1/notifications/category/read/
{
"verbs": ["request_new", "request_updated"],
"category": "Заявки"
}
"""
verbs = request.data.get('verbs', [])
if not verbs or not isinstance(verbs, list):
return Response(
{'status': 'error', 'message': 'verbs array is required'},
status=status.HTTP_400_BAD_REQUEST
)
# Отмечаем все уведомления с этими verb'ами
queryset = Notification.objects.filter(
recipient=request.user,
unread=True,
deleted=False,
verb__in=verbs,
)
notification_ids = list(queryset.values_list('id', flat=True))
count = queryset.mark_all_as_read()
if count:
invalidate_unread_summary(request.user.id)
if notification_ids:
send_notifications_read_all_event(
request.user.id,
notification_ids,
category=request.data.get('category'),
)
dismiss_notifications_push(
user_id=request.user.id,
notification_ids=notification_ids,
)
return Response({
'status': 'success',
'verbs': verbs,
'count': count
})
@extend_schema(
tags=["Notifications"],
summary="Удалить уведомление",
responses={200: StatusResponseSerializer, 404: StatusResponseSerializer},
)
@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
)
was_unread = notification.unread
notification.deleted = True
notification.save(update_fields=['deleted'])
if was_unread:
invalidate_unread_summary(request.user.id)
dismiss_notifications_push(
user_id=request.user.id,
notification_ids=[notification_id],
)
send_notifications_deleted_event(request.user.id, [notification_id])
return Response({'status': 'success'})
except Notification.DoesNotExist:
return Response(
{'status': 'error', 'message': 'Notification not found'},
status=status.HTTP_404_NOT_FOUND
)
@extend_schema(
tags=["Notifications"],
summary="Удалить все прочитанные уведомления",
responses=StatusResponseSerializer,
)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
def delete_all_read(request):
"""Удалить все прочитанные уведомления"""
queryset = Notification.objects.filter(
recipient=request.user,
unread=False,
deleted=False,
)
notification_ids = list(queryset.values_list('id', flat=True))
count = queryset.update(deleted=True)
dismiss_notifications_push(
user_id=request.user.id,
notification_ids=notification_ids,
)
if notification_ids:
send_notifications_deleted_event(request.user.id, notification_ids)
return Response({
'status': 'success',
'count': count
})
@extend_schema(
tags=["Notifications"],
summary="Получить статистику по типам уведомлений",
responses=VerbTypesResponseSerializer,
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_verb_types(request):
"""
Получить список типов (verb) уведомлений с количеством.
Возвращает статистику по типам уведомлений пользователя.
"""
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 как есть - фронтенд сам сделает перевод
data = [
{
'verb': item['verb'],
'name': item['verb'], # Фронтенд переведет
'total': item['total'],
'unread': item['unread'],
}
for item in verb_stats
]
return Response({'verb_types': data})
@extend_schema(
methods=['GET'],
tags=["Notifications"],
summary="Получить настройки каналов уведомлений",
responses=ChannelPreferencesSerializer,
)
@extend_schema(
methods=['PUT'],
tags=["Notifications"],
summary="Обновить настройки каналов уведомлений",
request=UpdateChannelPreferencesSerializer,
responses=StatusResponseSerializer,
)
@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
)
def _normalize_email_frequency(value):
if value == 'disabled':
return 'never'
return value
if request.method == 'GET':
data = {
'web_enabled': prefs.web_enabled,
'email_enabled': prefs.email_enabled,
'email_frequency': _normalize_email_frequency(
prefs.email_frequency
),
'push_enabled': prefs.push_enabled,
'dnd_enabled': prefs.dnd_enabled,
'dnd_start_time': _format_time_or_none(prefs.dnd_start_time),
'dnd_end_time': _format_time_or_none(prefs.dnd_end_time),
'disabled_verbs': prefs.disabled_verbs,
}
return Response(data)
if request.method == 'PUT':
# Обновляем настройки
if 'web_enabled' in request.data:
prefs.web_enabled = request.data.get('web_enabled')
if 'email_enabled' in request.data:
prefs.email_enabled = request.data.get('email_enabled')
if 'email_frequency' in request.data:
frequency = _normalize_email_frequency(
request.data.get('email_frequency')
)
if frequency in ['instant', 'daily', 'weekly', 'never']:
prefs.email_frequency = frequency
if 'push_enabled' in request.data:
prefs.push_enabled = request.data.get('push_enabled')
if 'dnd_enabled' in request.data:
prefs.dnd_enabled = request.data.get('dnd_enabled')
if 'dnd_start_time' in request.data:
time_str = request.data.get('dnd_start_time')
prefs.dnd_start_time = _parse_time_or_none(time_str)
if 'dnd_end_time' in request.data:
time_str = request.data.get('dnd_end_time')
prefs.dnd_end_time = _parse_time_or_none(time_str)
if 'disabled_verbs' in request.data:
prefs.disabled_verbs = request.data.get('disabled_verbs')
prefs.save()
return Response({
'status': 'success',
'message': 'Preferences updated'
})
return Response(
{'status': 'error', 'message': 'Method not allowed'},
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
@extend_schema(
tags=["Notifications"],
summary="Получить публичный VAPID ключ",
responses={
200: VapidPublicKeyResponseSerializer,
503: VapidPublicKeyResponseSerializer,
},
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_vapid_public_key(request):
"""
Получить публичный VAPID ключ для Web Push подписки.
Returns:
- vapid_public_key: публичный ключ в формате base64url
"""
from django.conf import settings
vapid_key = getattr(settings, 'VAPID_PUBLIC_KEY', None)
if not vapid_key:
return Response(
{
'status': 'error',
'message': 'VAPID keys not configured',
'vapid_public_key': None
},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
return Response({
'status': 'success',
'vapid_public_key': vapid_key
})
@extend_schema(
tags=["Notifications"],
summary="Подписаться на Web Push",
request=SubscribePushRequestSerializer,
responses={200: StatusResponseSerializer, 400: StatusResponseSerializer},
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def subscribe_push(request):
"""
Подписка на Web Push уведомления.
Body:
- endpoint: URL для отправки push
- keys: {p256dh, auth} ключи шифрования
- device_name: название устройства/браузера (опционально)
"""
from push_notifications.models import WebPushDevice
serializer = SubscribePushRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data
endpoint = payload['endpoint']
keys = payload['keys']
device_name = payload.get('device_name', 'unknown')
app_kind = payload.get('app_kind', NotificationInstallation.APP_MAIN)
generated_id = uuid.uuid5(uuid.NAMESPACE_URL, endpoint)
installation_id = payload.get('installation_id', generated_id)
device_group_id = payload.get('device_group_id', generated_id)
# Регистрируем устройство
device, created = WebPushDevice.objects.update_or_create(
registration_id=endpoint,
defaults={
'user': request.user,
'p256dh': keys.get('p256dh', ''),
'auth': keys.get('auth', ''),
'browser': device_name,
'active': True,
}
)
NotificationInstallation.objects.filter(endpoint=endpoint).exclude(
user=request.user,
).update(endpoint=None, push_device_id=None, active=False)
installation = NotificationInstallation.objects.filter(
user=request.user,
endpoint=endpoint,
).first() or NotificationInstallation.objects.filter(
user=request.user,
device_group_id=device_group_id,
app_kind=app_kind,
).first()
if installation is None:
installation = NotificationInstallation(
user=request.user,
device_group_id=device_group_id,
app_kind=app_kind,
)
elif installation.endpoint and installation.endpoint != endpoint:
WebPushDevice.objects.filter(
user=request.user,
registration_id=installation.endpoint,
).exclude(pk=device.pk).delete()
installation.installation_id = installation_id
installation.endpoint = endpoint
installation.push_device_id = device.id
installation.device_name = device_name
installation.installed = payload.get('installed', installation.installed)
installation.active = True
installation.last_seen_at = timezone.now()
installation.save()
prefs, _ = UserChannelPreferences.objects.get_or_create(
user=request.user,
)
if not prefs.push_enabled:
prefs.push_enabled = True
prefs.save(update_fields=["push_enabled"])
return Response({
'status': 'success',
'message': (
'Push subscription created'
if created
else 'Push subscription updated'
),
'created': created,
'device_id': device.id,
'installation_id': str(installation.installation_id),
'app_kind': installation.app_kind,
})
@extend_schema(
tags=["Notifications"],
summary="Подтвердить активность установленного приложения",
request=InstallationHeartbeatRequestSerializer,
responses={200: StatusResponseSerializer},
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def installation_heartbeat(request):
serializer = InstallationHeartbeatRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data
installation, created = NotificationInstallation.objects.update_or_create(
user=request.user,
device_group_id=payload['device_group_id'],
app_kind=payload['app_kind'],
defaults={
'installation_id': payload['installation_id'],
'device_name': payload.get('device_name', ''),
'installed': payload.get('installed', True),
'active': True,
'last_seen_at': timezone.now(),
},
)
return Response({
'status': 'success',
'created': created,
'installation': _serialize_installation(installation),
})
def _serialize_installation(installation):
return {
'installation_id': str(installation.installation_id),
'device_group_id': str(installation.device_group_id),
'app_kind': installation.app_kind,
'device_name': installation.device_name,
'installed': installation.installed,
'active': installation.active,
'push_ready': installation.push_ready,
'last_seen_at': installation.last_seen_at.isoformat(),
}
@extend_schema(
methods=['GET'],
tags=["Notifications"],
summary="Получить маршруты доставки по приложениям",
responses={200: DeliveryRoutingResponseSerializer},
)
@extend_schema(
methods=['PUT'],
tags=["Notifications"],
summary="Обновить маршруты доставки по приложениям",
request=DeliveryRoutingRequestSerializer,
responses={200: DeliveryRoutingResponseSerializer},
)
@api_view(['GET', 'PUT'])
@permission_classes([IsAuthenticated])
def delivery_routing(request):
if request.method == 'GET':
raw_device_group_id = request.query_params.get('device_group_id')
try:
device_group_id = uuid.UUID(str(raw_device_group_id))
except (TypeError, ValueError, AttributeError):
return Response(
{'detail': 'device_group_id is required'},
status=status.HTTP_400_BAD_REQUEST,
)
else:
serializer = DeliveryRoutingRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
device_group_id = serializer.validated_data['device_group_id']
routing, _created = NotificationDeliveryRouting.objects.get_or_create(
user=request.user,
device_group_id=device_group_id,
)
if request.method == 'PUT':
routes = normalize_routes(serializer.validated_data['routes'])
messenger_requested = any(
NotificationInstallation.APP_MESSENGER in destinations
for destinations in routes.values()
)
if messenger_requested and not NotificationInstallation.objects.filter(
user=request.user,
device_group_id=device_group_id,
app_kind=NotificationInstallation.APP_MESSENGER,
active=True,
installed=True,
endpoint__isnull=False,
).exists():
return Response(
{
'detail': (
'Сначала завершите установку мессенджера и '
'разрешите ему уведомления.'
)
},
status=status.HTTP_409_CONFLICT,
)
routing.routes = routes
routing.save(update_fields=['routes', 'updated_at'])
installations = NotificationInstallation.objects.filter(
user=request.user,
device_group_id=device_group_id,
active=True,
)
return Response({
'device_group_id': str(device_group_id),
'categories': [
{'key': key, 'label': label}
for key, label in NOTIFICATION_CATEGORIES
],
'routes': normalize_routes(routing.routes),
'installations': [
_serialize_installation(installation)
for installation in installations
],
})
@extend_schema(
tags=["Notifications"],
summary="Отключить приложение доставки на устройстве",
request=DisconnectInstallationRequestSerializer,
responses={200: StatusResponseSerializer},
)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
def disconnect_installation(request):
from push_notifications.models import WebPushDevice
serializer = DisconnectInstallationRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
payload = serializer.validated_data
installations = NotificationInstallation.objects.filter(
user=request.user,
device_group_id=payload['device_group_id'],
app_kind=payload['app_kind'],
)
endpoints = list(
installations.exclude(endpoint__isnull=True).values_list(
'endpoint',
flat=True,
)
)
WebPushDevice.objects.filter(
user=request.user,
registration_id__in=endpoints,
).delete()
installations.update(
endpoint=None,
push_device_id=None,
installed=False,
active=False,
last_seen_at=timezone.now(),
)
routing, _created = NotificationDeliveryRouting.objects.get_or_create(
user=request.user,
device_group_id=payload['device_group_id'],
)
routes = normalize_routes(routing.routes)
if payload['app_kind'] == NotificationInstallation.APP_MESSENGER:
routes = {
category: (
[app for app in destinations if app != 'messenger']
or ['main']
)
for category, destinations in routes.items()
}
routing.routes = routes
routing.save(update_fields=['routes', 'updated_at'])
return Response({
'status': 'success',
'routes': routes,
'disconnected': installations.count(),
})
@extend_schema(
methods=['POST', 'DELETE'],
tags=["Notifications"],
summary="Отписаться от Web Push",
request=UnsubscribePushRequestSerializer,
responses=StatusResponseSerializer,
)
@api_view(['POST', 'DELETE'])
@permission_classes([IsAuthenticated])
def unsubscribe_push(request):
"""Отписка от Web Push уведомлений"""
from push_notifications.models import WebPushDevice
endpoint = request.data.get('endpoint')
if endpoint:
NotificationInstallation.objects.filter(
user=request.user,
endpoint=endpoint,
).update(
endpoint=None,
push_device_id=None,
last_seen_at=timezone.now(),
)
deleted_count, _ = WebPushDevice.objects.filter(
user=request.user,
registration_id=endpoint
).delete()
else:
# Удалить все устройства пользователя
deleted_count, _ = WebPushDevice.objects.filter(
user=request.user
).delete()
has_active_devices = WebPushDevice.objects.filter(
user=request.user,
active=True,
).exists()
if not has_active_devices:
prefs, _ = UserChannelPreferences.objects.get_or_create(
user=request.user,
)
if prefs.push_enabled:
prefs.push_enabled = False
prefs.save(update_fields=["push_enabled"])
return Response({
'status': 'success',
'message': f'Deleted {deleted_count} push subscription(s)',
'deleted_count': deleted_count
})