""" API endpoints для системы уведомлений v2.0 Новая архитектура: - Notification с GenericForeignKey (actor/action_object/target) - verb вместо category/type - UserChannelPreferences для настроек каналов """ from datetime import datetime import uuid from drf_spectacular.utils import OpenApiParameter, extend_schema from django.db.models import Count, Q from django.utils import timezone from rest_framework import status from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from ..models import ( Notification, NotificationDeliveryRouting, NotificationInstallation, UserChannelPreferences, ) from ..routing import NOTIFICATION_CATEGORIES, normalize_routes from ..cache import ( get_unread_count as get_cached_unread_count, get_unread_summary, invalidate_unread_summary, ) from ..realtime import ( send_notification_read_event, send_notifications_deleted_event, send_notifications_read_all_event, ) from ..push_lifecycle import dismiss_notifications_push from .serializers import ( ChannelPreferencesSerializer, CountResponseSerializer, DeliveryRoutingRequestSerializer, DeliveryRoutingResponseSerializer, DisconnectInstallationRequestSerializer, InstallationHeartbeatRequestSerializer, MarkAllAsReadRequestSerializer, MarkCategoryAsReadRequestSerializer, NotificationsListResponseSerializer, StatusResponseSerializer, SubscribePushRequestSerializer, UnsubscribePushRequestSerializer, UpdateChannelPreferencesSerializer, UnreadSummaryResponseSerializer, VapidPublicKeyResponseSerializer, VerbTypesResponseSerializer, ) def _format_time_or_none(value): if value is None: return None return value.strftime('%H:%M') def _parse_time_or_none(value): if not value: return None return datetime.strptime(value, '%H:%M').time() 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"], 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 })