1222 lines
39 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
from io import BytesIO
import uuid
from drf_spectacular.utils import OpenApiParameter, extend_schema
from django.core import signing
from django.core.files.storage import default_storage
from django.db import transaction
from django.db.models import Count, Q
from django.http import HttpResponse
from django.utils import timezone
from PIL import Image, ImageDraw, ImageOps, UnidentifiedImageError
from rest_framework import status
from rest_framework.decorators import (
api_view,
authentication_classes,
permission_classes,
)
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from communications.models import Chat, Message
from communications.services import (
ChatNotificationReadService,
ChatQuickReplyError,
ChatQuickReplyService,
ChatReadMarkerService,
)
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 ..push_actions import (
load_push_avatar_token,
load_push_read_token,
load_push_reply_token,
)
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()
def _actor_avatar_url(actor):
if actor is None:
return None
avatar = getattr(actor, 'avatar', None)
if not avatar:
return None
try:
return avatar.url
except (AttributeError, ValueError):
return None
@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,
'avatar': _actor_avatar_url(n.actor),
} 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
})
@api_view(['POST'])
@authentication_classes([])
@permission_classes([AllowAny])
def push_mark_chat_read(request):
"""Mark the pushed chat message as read using a short-lived signed token."""
token = request.data.get('token')
if not isinstance(token, str) or not token:
return Response(
{'status': 'error', 'message': 'token is required'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
payload = load_push_read_token(token)
except signing.SignatureExpired:
return Response(
{'status': 'error', 'message': 'token expired'},
status=status.HTTP_410_GONE,
)
except signing.BadSignature:
return Response(
{'status': 'error', 'message': 'invalid token'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
notification = Notification.objects.select_related('recipient').get(
id=int(payload['notification_id']),
recipient_id=int(payload['user_id']),
deleted=False,
)
chat = Chat.objects.get(id=int(payload['chat_id']))
message = Message.objects.get(
id=int(payload['message_id']),
chat=chat,
is_deleted=False,
)
except (
KeyError,
TypeError,
ValueError,
Notification.DoesNotExist,
Chat.DoesNotExist,
Message.DoesNotExist,
):
return Response(
{'status': 'error', 'message': 'push target not found'},
status=status.HTTP_404_NOT_FOUND,
)
notification_data = (
notification.data if isinstance(notification.data, dict) else {}
)
if (
str(notification_data.get('chat_id')) != str(chat.id)
or str(notification_data.get('message_id')) != str(message.id)
):
return Response(
{'status': 'error', 'message': 'push target mismatch'},
status=status.HTTP_400_BAD_REQUEST,
)
ChatReadMarkerService().mark_read(
chat=chat,
user=notification.recipient,
message=message,
)
read_result = ChatNotificationReadService().mark_chat_notifications_as_read(
user=notification.recipient,
chat_id=chat.id,
)
return Response({
'status': 'success',
'chat_id': chat.id,
'message_id': message.id,
'notifications_read': read_result.count,
})
@api_view(['POST'])
@authentication_classes([])
@permission_classes([AllowAny])
def push_reply_to_chat(request):
"""Send a one-time reply entered in Chromium's native notification UI."""
token = request.data.get('token')
content = request.data.get('reply')
if not isinstance(token, str) or not token:
return Response(
{'status': 'error', 'message': 'token is required'},
status=status.HTTP_400_BAD_REQUEST,
)
if not isinstance(content, str) or not content.strip():
return Response(
{'status': 'error', 'message': 'reply is required'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
payload = load_push_reply_token(token)
except signing.SignatureExpired:
return Response(
{'status': 'error', 'message': 'token expired'},
status=status.HTTP_410_GONE,
)
except signing.BadSignature:
return Response(
{'status': 'error', 'message': 'invalid token'},
status=status.HTTP_400_BAD_REQUEST,
)
try:
with transaction.atomic():
notification = (
Notification.objects.select_for_update()
.select_related('recipient')
.get(
id=int(payload['notification_id']),
recipient_id=int(payload['user_id']),
deleted=False,
)
)
chat = Chat.objects.get(id=int(payload['chat_id']))
source_message = Message.objects.get(
id=int(payload['message_id']),
chat=chat,
is_deleted=False,
)
notification_data = (
notification.data if isinstance(notification.data, dict) else {}
)
if (
str(notification_data.get('chat_id')) != str(chat.id)
or str(notification_data.get('message_id'))
!= str(source_message.id)
):
return Response(
{'status': 'error', 'message': 'push target mismatch'},
status=status.HTTP_400_BAD_REQUEST,
)
if notification_data.get('push_reply_message_id'):
return Response(
{'status': 'error', 'message': 'reply already sent'},
status=status.HTTP_409_CONFLICT,
)
reply_message = ChatQuickReplyService().send(
chat=chat,
user=notification.recipient,
reply_to=source_message,
content=content,
)
notification.data = {
**notification_data,
'push_reply_message_id': reply_message.id,
}
notification.save(update_fields=['data'])
except (
KeyError,
TypeError,
ValueError,
Notification.DoesNotExist,
Chat.DoesNotExist,
Message.DoesNotExist,
):
return Response(
{'status': 'error', 'message': 'push target not found'},
status=status.HTTP_404_NOT_FOUND,
)
except ChatQuickReplyError:
return Response(
{'status': 'error', 'message': 'reply is not allowed'},
status=status.HTTP_403_FORBIDDEN,
)
ChatReadMarkerService().mark_read(
chat=chat,
user=notification.recipient,
message=source_message,
)
ChatNotificationReadService().mark_chat_notifications_as_read(
user=notification.recipient,
chat_id=chat.id,
)
return Response(
{
'status': 'success',
'chat_id': chat.id,
'message_id': reply_message.id,
'reply_to_id': source_message.id,
},
status=status.HTTP_201_CREATED,
)
@api_view(['GET'])
@authentication_classes([])
@permission_classes([AllowAny])
def push_avatar(request):
"""Return a signed avatar as a transparent circular PNG for native push UI."""
token = request.query_params.get('token', '')
try:
payload = load_push_avatar_token(token)
path = payload['path']
if not isinstance(path, str) or not path:
raise signing.BadSignature('missing path')
with default_storage.open(path, 'rb') as avatar_file:
with Image.open(avatar_file) as source:
source = ImageOps.exif_transpose(source).convert('RGBA')
image = ImageOps.fit(
source,
(192, 192),
method=Image.Resampling.LANCZOS,
)
except signing.SignatureExpired:
return Response(status=status.HTTP_410_GONE)
except (signing.BadSignature, KeyError, OSError, UnidentifiedImageError):
return Response(status=status.HTTP_404_NOT_FOUND)
mask = Image.new('L', image.size, 0)
ImageDraw.Draw(mask).ellipse(
(0, 0, image.width - 1, image.height - 1),
fill=255,
)
image.putalpha(mask)
output = BytesIO()
image.save(output, format='PNG', optimize=True)
response = HttpResponse(output.getvalue(), content_type='image/png')
response['Cache-Control'] = 'public, max-age=2592000, immutable'
return response
@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
})