1249 lines
41 KiB
Python
1249 lines
41 KiB
Python
"""
|
||
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.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,
|
||
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})
|
||
|
||
|
||
@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", ""),
|
||
# 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"
|