""" API endpoints для системы уведомлений v2.0 Новая архитектура: - Notification с GenericForeignKey (actor/action_object/target) - verb вместо category/type - UserChannelPreferences для настроек каналов """ import uuid from datetime import datetime from io import BytesIO 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 drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema 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 ..cache import ( get_unread_count as get_cached_unread_count, ) from ..cache import ( get_unread_summary, invalidate_unread_summary, ) from ..models import ( Notification, NotificationDeliveryRouting, NotificationInstallation, UserChannelPreferences, ) from ..push_actions import ( load_push_avatar_token, load_push_read_token, load_push_reply_token, ) from ..push_lifecycle import dismiss_notifications_push from ..realtime import ( send_notification_read_event, send_notifications_deleted_event, send_notifications_read_all_event, ) from ..routing import NOTIFICATION_CATEGORIES, normalize_routes from .serializers import ( ChannelPreferencesSerializer, CountResponseSerializer, DeliveryRoutingRequestSerializer, DeliveryRoutingResponseSerializer, DisconnectInstallationRequestSerializer, InstallationHeartbeatRequestSerializer, MarkAllAsReadRequestSerializer, MarkCategoryAsReadRequestSerializer, NotificationsListResponseSerializer, PushReadRequestSerializer, PushReadResponseSerializer, PushReplyRequestSerializer, PushReplyResponseSerializer, StatusResponseSerializer, SubscribePushRequestSerializer, UnreadSummaryResponseSerializer, UnsubscribePushRequestSerializer, UpdateChannelPreferencesSerializer, 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}) @extend_schema( tags=["Notifications"], request=PushReadRequestSerializer, responses={ 200: PushReadResponseSerializer, 400: StatusResponseSerializer, 404: StatusResponseSerializer, 410: StatusResponseSerializer, }, ) @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, up_to_message_id=message.id, ) return Response( { "status": "success", "chat_id": chat.id, "message_id": message.id, "notifications_read": read_result.count, } ) @extend_schema( tags=["Notifications"], request=PushReplyRequestSerializer, responses={ 201: PushReplyResponseSerializer, 400: StatusResponseSerializer, 403: StatusResponseSerializer, 404: StatusResponseSerializer, 409: StatusResponseSerializer, 410: StatusResponseSerializer, }, ) @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, up_to_message_id=source_message.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, ) @extend_schema( tags=["Notifications"], parameters=[OpenApiParameter("token", str, required=True)], responses={(200, "image/png"): OpenApiTypes.BINARY, 404: None, 410: None}, ) @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", ""), # django-push-notifications keeps a legacy browser code in a # varchar(10). The human-readable device name belongs to # NotificationInstallation.device_name and may be much longer. "browser": _web_push_browser(request), "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 serializer = UnsubscribePushRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) endpoint = serializer.validated_data.get("endpoint") device_group_id = serializer.validated_data.get("device_group_id") 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() elif device_group_id: # Logout is scoped to the current physical device. A user may still # have active subscriptions on a phone, another browser, or a PWA. installations = NotificationInstallation.objects.filter( user=request.user, device_group_id=device_group_id, ) endpoints = list( installations.exclude(endpoint__isnull=True).values_list( "endpoint", flat=True, ) ) deleted_count, _ = WebPushDevice.objects.filter( user=request.user, registration_id__in=endpoints, ).delete() installations.update( endpoint=None, push_device_id=None, active=False, last_seen_at=timezone.now(), ) 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, } ) def _web_push_browser(request): """Return a value accepted by WebPushDevice.browser (max length 10).""" user_agent = request.META.get("HTTP_USER_AGENT", "").lower() if "firefox" in user_agent: return "FIREFOX" if "edg/" in user_agent or "edge/" in user_agent: return "EDGE" if "opr/" in user_agent or "opera" in user_agent: return "OPERA" # The library has no Safari choice. For modern HTTPS endpoints this # legacy field is not used to construct the delivery URL. return "CHROME"