655 lines
20 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 drf_spectacular.utils import OpenApiParameter, extend_schema
from django.db.models import Count, Q
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from ..models import Notification, UserChannelPreferences
from ..realtime import (
send_notification_read_event,
send_notifications_read_all_event,
)
from .serializers import (
ChannelPreferencesSerializer,
CountResponseSerializer,
MarkAllAsReadRequestSerializer,
MarkCategoryAsReadRequestSerializer,
NotificationsListResponseSerializer,
StatusResponseSerializer,
SubscribePushRequestSerializer,
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()
@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
verb = request.GET.get('verb')
unread_only = request.GET.get('unread_only', 'false').lower() == 'true'
search = request.GET.get('search', '').strip()
# Базовый queryset
queryset = Notification.objects.filter(
recipient=request.user,
deleted=False,
).order_by('-timestamp')
# Фильтры
if verb:
queryset = queryset.filter(verb=verb)
if unread_only:
queryset = queryset.unread()
if search:
queryset = queryset.filter(description__icontains=search)
# Пагинация
total = queryset.count()
start = (page - 1) * page_size
end = start + page_size
notifications = queryset[start:end]
# Сериализация (совместимость с frontend)
data = {
'total': total,
'page': page,
'page_size': page_size,
'unread_count': queryset.filter(unread=True).count(),
'notifications': [
{
'id': n.id,
# Frontend ожидает title, message, is_read, created_at
'title': (
n.data['title']
if n.data and 'title' in n.data
else n.verb.replace('_', ' ').title()
),
'message': n.description,
'short_message': (
n.description[:100] + '...'
if len(n.description) > 100
else n.description
),
'is_read': not n.unread,
'created_at': n.timestamp.isoformat(),
'category': n.verb,
'action_url': n.action_url,
'data': n.data,
# Дополнительные поля для обратной совместимости
'verb': n.verb,
'description': n.description,
'actor': {
'id': n.actor.id if n.actor else None,
'name': str(n.actor) if n.actor else 'Система',
'type': n.actor_content_type.model if n.actor else None,
} if n.actor else None,
'unread': n.unread,
'public': n.public,
'deleted': n.deleted,
'emailed': n.emailed,
'timestamp': n.timestamp.isoformat(),
'timesince': n.timesince,
}
for n in notifications
]
}
return Response(data)
@extend_schema(
tags=["Notifications"],
summary="Получить количество непрочитанных уведомлений",
responses=CountResponseSerializer,
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_unread_count(request):
"""Получить количество непрочитанных уведомлений"""
count = Notification.objects.filter(
recipient=request.user,
unread=True,
deleted=False,
).count()
return Response({'count': count})
@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:
send_notification_read_event(request.user.id, 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
)
notification.mark_as_unread()
return Response({
'status': 'success',
'notification_id': notification_id
})
except Notification.DoesNotExist:
return Response(
{'status': 'error', 'message': 'Notification not found'},
status=status.HTTP_404_NOT_FOUND
)
@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 notification_ids:
send_notifications_read_all_event(
request.user.id,
notification_ids,
category=verb,
)
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 notification_ids:
send_notifications_read_all_event(
request.user.id,
notification_ids,
category=request.data.get('category'),
)
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
)
notification.deleted = True
notification.save(update_fields=['deleted'])
return Response({'status': 'success'})
except Notification.DoesNotExist:
return Response(
{'status': 'error', 'message': 'Notification not found'},
status=status.HTTP_404_NOT_FOUND
)
@extend_schema(
tags=["Notifications"],
summary="Удалить все прочитанные уведомления",
responses=StatusResponseSerializer,
)
@api_view(['DELETE'])
@permission_classes([IsAuthenticated])
def delete_all_read(request):
"""Удалить все прочитанные уведомления"""
count = Notification.objects.filter(
recipient=request.user,
unread=False,
deleted=False,
).update(deleted=True)
return Response({
'status': 'success',
'count': count
})
@extend_schema(
tags=["Notifications"],
summary="Получить статистику по типам уведомлений",
responses=VerbTypesResponseSerializer,
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_verb_types(request):
"""
Получить список типов (verb) уведомлений с количеством.
Возвращает статистику по типам уведомлений пользователя.
"""
verb_stats = Notification.objects.filter(
recipient=request.user,
deleted=False,
).values('verb').annotate(
total=Count('id'),
unread=Count('id', filter=Q(unread=True))
).order_by('-total')
# Возвращаем verb как есть - фронтенд сам сделает перевод
data = [
{
'verb': item['verb'],
'name': item['verb'], # Фронтенд переведет
'total': item['total'],
'unread': item['unread'],
}
for item in verb_stats
]
return Response({'verb_types': data})
@extend_schema(
methods=['GET'],
tags=["Notifications"],
summary="Получить настройки каналов уведомлений",
responses=ChannelPreferencesSerializer,
)
@extend_schema(
methods=['PUT'],
tags=["Notifications"],
summary="Обновить настройки каналов уведомлений",
request=UpdateChannelPreferencesSerializer,
responses=StatusResponseSerializer,
)
@api_view(['GET', 'PUT'])
@permission_classes([IsAuthenticated])
def channel_preferences(request):
"""
Получить или обновить настройки каналов уведомлений.
GET: Возвращает текущие настройки
PUT: Обновляет настройки
Настройки:
- web_enabled: WebSocket уведомления (bool)
- email_enabled: Email уведомления (bool)
- email_frequency: instant/daily/weekly/disabled
- push_enabled: Web Push уведомления (bool)
- dnd_enabled: Режим "Не беспокоить" (bool)
- dnd_start_time: Начало DND (HH:MM)
- dnd_end_time: Конец DND (HH:MM)
- disabled_verbs: Список отключенных типов (array)
"""
# Получаем или создаем настройки
prefs, _created = UserChannelPreferences.objects.get_or_create(
user=request.user
)
def _normalize_email_frequency(value):
if value == 'disabled':
return 'never'
return value
if request.method == 'GET':
data = {
'web_enabled': prefs.web_enabled,
'email_enabled': prefs.email_enabled,
'email_frequency': _normalize_email_frequency(
prefs.email_frequency
),
'push_enabled': prefs.push_enabled,
'dnd_enabled': prefs.dnd_enabled,
'dnd_start_time': _format_time_or_none(prefs.dnd_start_time),
'dnd_end_time': _format_time_or_none(prefs.dnd_end_time),
'disabled_verbs': prefs.disabled_verbs,
}
return Response(data)
if request.method == 'PUT':
# Обновляем настройки
if 'web_enabled' in request.data:
prefs.web_enabled = request.data.get('web_enabled')
if 'email_enabled' in request.data:
prefs.email_enabled = request.data.get('email_enabled')
if 'email_frequency' in request.data:
frequency = _normalize_email_frequency(
request.data.get('email_frequency')
)
if frequency in ['instant', 'daily', 'weekly', 'never']:
prefs.email_frequency = frequency
if 'push_enabled' in request.data:
prefs.push_enabled = request.data.get('push_enabled')
if 'dnd_enabled' in request.data:
prefs.dnd_enabled = request.data.get('dnd_enabled')
if 'dnd_start_time' in request.data:
time_str = request.data.get('dnd_start_time')
prefs.dnd_start_time = _parse_time_or_none(time_str)
if 'dnd_end_time' in request.data:
time_str = request.data.get('dnd_end_time')
prefs.dnd_end_time = _parse_time_or_none(time_str)
if 'disabled_verbs' in request.data:
prefs.disabled_verbs = request.data.get('disabled_verbs')
prefs.save()
return Response({
'status': 'success',
'message': 'Preferences updated'
})
return Response(
{'status': 'error', 'message': 'Method not allowed'},
status=status.HTTP_405_METHOD_NOT_ALLOWED,
)
@extend_schema(
tags=["Notifications"],
summary="Получить публичный VAPID ключ",
responses={
200: VapidPublicKeyResponseSerializer,
503: VapidPublicKeyResponseSerializer,
},
)
@api_view(['GET'])
@permission_classes([IsAuthenticated])
def get_vapid_public_key(request):
"""
Получить публичный VAPID ключ для Web Push подписки.
Returns:
- vapid_public_key: публичный ключ в формате base64url
"""
from django.conf import settings
vapid_key = getattr(settings, 'VAPID_PUBLIC_KEY', None)
if not vapid_key:
return Response(
{
'status': 'error',
'message': 'VAPID keys not configured',
'vapid_public_key': None
},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
return Response({
'status': 'success',
'vapid_public_key': vapid_key
})
@extend_schema(
tags=["Notifications"],
summary="Подписаться на Web Push",
request=SubscribePushRequestSerializer,
responses={200: StatusResponseSerializer, 400: StatusResponseSerializer},
)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
def subscribe_push(request):
"""
Подписка на Web Push уведомления.
Body:
- endpoint: URL для отправки push
- keys: {p256dh, auth} ключи шифрования
- device_name: название устройства/браузера (опционально)
"""
from push_notifications.models import WebPushDevice
endpoint = request.data.get('endpoint')
keys = request.data.get('keys', {})
device_name = request.data.get('device_name', 'unknown')
if not endpoint or not keys:
return Response(
{'status': 'error', 'message': 'endpoint and keys required'},
status=status.HTTP_400_BAD_REQUEST
)
# Регистрируем устройство
device, created = WebPushDevice.objects.update_or_create(
user=request.user,
registration_id=endpoint,
defaults={
'p256dh': keys.get('p256dh', ''),
'auth': keys.get('auth', ''),
'browser': device_name,
'active': True,
}
)
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
})
@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:
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
})