diff --git a/backend/api/v1/notifications/urls.py b/backend/api/v1/notifications/urls.py index 5a97753f..9e6c207d 100644 --- a/backend/api/v1/notifications/urls.py +++ b/backend/api/v1/notifications/urls.py @@ -1,5 +1,7 @@ """ -URL routing для API v1 уведомлений. +URL routing для API v2 уведомлений. + +Новая архитектура: verb-based notifications с channel preferences. """ from django.urls import path from . import views @@ -7,29 +9,24 @@ from . import views app_name = 'notifications_api_v1' urlpatterns = [ - # CRUD уведомлений + # Список и счетчики path('', views.get_notifications, name='list'), path('count/', views.get_unread_count, name='count'), + path('verb-types/', views.get_verb_types, name='verb_types'), + + # Операции с уведомлениями path('/read/', views.mark_as_read, name='mark_read'), - path('read-all/', views.mark_all_as_read, name='mark_all_read'), + path('/unread/', views.mark_as_unread, name='mark_unread'), path('/', views.delete_notification, name='delete'), - # Категории - path('categories/', views.get_categories, name='categories'), + # Массовые операции + path('read-all/', views.mark_all_as_read, name='mark_all_read'), + path('delete-all-read/', views.delete_all_read, name='delete_all_read'), - # Настройки - path('settings/', views.get_user_settings, name='settings_get'), - path('settings/update/', views.update_user_settings, name='settings_update'), - path('settings/category/update/', views.update_category_settings, name='category_settings_update'), - - # Telegram интеграция - path('telegram/status/', views.get_telegram_link_status, name='telegram_status'), - path('telegram/generate-code/', views.generate_telegram_link_code, name='telegram_generate_code'), - path('telegram/unlink/', views.unlink_telegram, name='telegram_unlink'), + # Настройки каналов + path('preferences/', views.channel_preferences, name='preferences'), # Web Push интеграция - path('push/vapid-key/', views.get_vapid_public_key, name='push_vapid_key'), path('push/subscribe/', views.subscribe_push, name='push_subscribe'), path('push/unsubscribe/', views.unsubscribe_push, name='push_unsubscribe'), - path('push/subscriptions/', views.get_push_subscriptions, name='push_subscriptions'), ] diff --git a/backend/api/v1/notifications/views.py b/backend/api/v1/notifications/views.py index 16b94c5a..7f8e16bd 100644 --- a/backend/api/v1/notifications/views.py +++ b/backend/api/v1/notifications/views.py @@ -1,79 +1,84 @@ """ -API endpoints для работы с уведомлениями (версия 1). +API endpoints для системы уведомлений v2.0 + +Новая архитектура: +- Notification с GenericForeignKey (actor/action_object/target) +- verb вместо category/type +- UserChannelPreferences для настроек каналов """ from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework import status -from django.shortcuts import get_object_or_404 -from notifications.models import Notification, NotificationCategory, NotificationType -from notifications.services import NotificationService +from django.db import models +from notifications.models import Notification, UserChannelPreferences @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: поиск по описанию + """ page = int(request.GET.get('page', 1)) page_size = int(request.GET.get('page_size', 20)) - category = request.GET.get('category') - is_read = request.GET.get('is_read') - search = request.GET.get('search', '').strip() + 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, - is_archived=False - ).select_related( - 'notification_type', - 'notification_type__category' - ).order_by('-created_at') + deleted=False, + ).order_by('-timestamp') - # Фильтр по категории - if category: - queryset = queryset.filter(notification_type__category__code=category) - - # Фильтр по статусу прочитанности - if is_read and is_read in ('true', 'false'): - queryset = queryset.filter(is_read=(is_read == 'true')) - elif unread_only: - queryset = queryset.filter(is_read=False) - - # Поиск по заголовку и тексту + # Фильтры + if verb: + queryset = queryset.filter(verb=verb) + + if unread_only: + queryset = queryset.unread() + if search: - from django.db.models import Q - queryset = queryset.filter( - Q(title__icontains=search) | - Q(message__icontains=search) | - Q(short_message__icontains=search) - ) + queryset = queryset.filter(description__icontains=search) + # Пагинация total = queryset.count() start = (page - 1) * page_size end = start + page_size - notifications = queryset[start:end] + # Сериализация data = { 'total': total, 'page': page, 'page_size': page_size, + 'unread_count': queryset.filter(unread=True).count(), 'notifications': [ { 'id': n.id, - 'title': n.title, - 'message': n.message, - 'short_message': n.short_message or n.message[:150], # Краткое сообщение для превью - 'category': n.notification_type.category.code, - 'category_name': n.notification_type.category.name, - 'icon': n.notification_type.category.icon, - 'color': n.notification_type.category.color, - 'priority': n.notification_type.priority, - 'is_read': n.is_read, + '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, 'action_url': n.action_url, - 'action_text': n.action_text, - 'created_at': n.created_at.isoformat(), + 'unread': n.unread, + 'public': n.public, + 'deleted': n.deleted, + 'emailed': n.emailed, + 'timestamp': n.timestamp.isoformat(), + 'timesince': n.timesince(), } for n in notifications ] @@ -88,8 +93,8 @@ def get_unread_count(request): """Получить количество непрочитанных уведомлений""" count = Notification.objects.filter( recipient=request.user, - is_read=False, - is_archived=False + unread=True, + deleted=False, ).count() return Response({'count': count}) @@ -99,11 +104,42 @@ def get_unread_count(request): @permission_classes([IsAuthenticated]) def mark_as_read(request, notification_id): """Отметить уведомление как прочитанное""" - success = NotificationService.mark_as_read(notification_id, request.user) + try: + notification = Notification.objects.get( + id=notification_id, + recipient=request.user + ) + notification.mark_as_read() + + 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 + ) - if success: - return Response({'status': 'success'}) - else: + +@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 @@ -114,23 +150,39 @@ def mark_as_read(request, notification_id): @permission_classes([IsAuthenticated]) def mark_all_as_read(request): """Отметить все уведомления как прочитанные""" - category = request.data.get('category') - count = NotificationService.mark_all_as_read(request.user, category) + verb = request.data.get('verb') # Опционально - только определенный тип + + queryset = Notification.objects.filter( + recipient=request.user, + unread=True, + deleted=False, + ) + + if verb: + queryset = queryset.filter(verb=verb) + + count = queryset.mark_all_as_read() - return Response({'status': 'success', 'count': count}) + return Response({ + 'status': 'success', + 'count': count + }) @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.archive() + notification.deleted = True + notification.save(update_fields=['deleted']) + return Response({'status': 'success'}) + except Notification.DoesNotExist: return Response( {'status': 'error', 'message': 'Notification not found'}, @@ -138,438 +190,206 @@ def delete_notification(request, notification_id): ) +@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 + }) + + @api_view(['GET']) @permission_classes([IsAuthenticated]) -def get_categories(request): - """Получить список категорий уведомлений""" - categories = NotificationCategory.objects.filter(is_active=True) +def get_verb_types(request): + """ + Получить список типов (verb) уведомлений с количеством + + Возвращает статистику по типам уведомлений пользователя. + """ + from django.db.models import Count, Q + + 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 → человекочитаемое название + verb_names = { + 'liked': 'Понравилось', + 'commented': 'Прокомментировал', + 'mentioned': 'Упомянул', + 'messaged': 'Отправил сообщение', + 'shared': 'Поделился', + 'posted': 'Опубликовал', + 'created': 'Создал', + 'updated': 'Обновил', + 'approved': 'Одобрил', + 'rejected': 'Отклонил', + 'scheduled': 'Запланировал', + 'notified': 'Уведомил', + } + data = [ { - 'code': cat.code, - 'name': cat.name, - 'icon': cat.icon, - 'color': cat.color, + 'verb': item['verb'], + 'name': verb_names.get(item['verb'], item['verb'].title()), + 'total': item['total'], + 'unread': item['unread'], } - for cat in categories + for item in verb_stats ] - return Response(data) + return Response({'verb_types': data}) -@api_view(['GET']) +@api_view(['GET', 'PUT']) @permission_classes([IsAuthenticated]) -def get_user_settings(request): - """Получить настройки уведомлений пользователя по категориям""" - categories = NotificationCategory.objects.filter(is_active=True) +def channel_preferences(request): + """ + Получить или обновить настройки каналов уведомлений - settings_by_category = {} + GET: Возвращает текущие настройки + PUT: Обновляет настройки - for category in categories: - # Получить типы уведомлений этой категории - types = NotificationType.objects.filter( - category=category, - is_active=True - ) - - if not types.exists(): - continue - - # Получить настройки первого типа категории как представитель - # (предполагается, что настройки одинаковы для всей категории) - first_type = types.first() - settings = NotificationService.get_user_settings( - request.user, first_type - ) - - settings_by_category[category.code] = { - 'is_enabled': settings.is_enabled, - 'web_enabled': settings.send_web, - 'email_enabled': settings.send_email, - 'email_frequency': settings.email_frequency, - 'telegram_enabled': settings.send_telegram, + Настройки: + - 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 + ) + + if request.method == 'GET': + data = { + 'web_enabled': prefs.web_enabled, + 'email_enabled': prefs.email_enabled, + 'email_frequency': prefs.email_frequency, + 'push_enabled': prefs.push_enabled, + 'dnd_enabled': prefs.dnd_enabled, + 'dnd_start_time': prefs.dnd_start_time.strftime('%H:%M') if prefs.dnd_start_time else None, + 'dnd_end_time': prefs.dnd_end_time.strftime('%H:%M') if prefs.dnd_end_time else None, + 'disabled_verbs': prefs.disabled_verbs, } + return Response(data) - return Response({ - 'settings': settings_by_category - }) - - -@api_view(['PUT']) -@permission_classes([IsAuthenticated]) -def update_user_settings(request): - """Обновить настройки уведомлений пользователя""" - notification_type_code = request.data.get('notification_type_code') - - try: - notification_type = NotificationType.objects.get( - code=notification_type_code - ) - except NotificationType.DoesNotExist: - return Response( - {'status': 'error', 'message': 'Notification type not found'}, - status=status.HTTP_404_NOT_FOUND - ) - - settings = NotificationService.get_user_settings( - request.user, notification_type - ) - - # Обновить настройки - if 'is_enabled' in request.data and not notification_type.is_required: - settings.is_enabled = request.data['is_enabled'] - - if 'send_web' in request.data: - settings.send_web = request.data['send_web'] - - if 'send_email' in request.data: - settings.send_email = request.data['send_email'] - - if 'send_telegram' in request.data: - settings.send_telegram = request.data['send_telegram'] - - if 'send_whatsapp' in request.data: - settings.send_whatsapp = request.data['send_whatsapp'] - - if 'send_wechat' in request.data: - settings.send_wechat = request.data['send_wechat'] - - if 'quiet_hours_enabled' in request.data: - settings.quiet_hours_enabled = request.data['quiet_hours_enabled'] - - if 'quiet_start_time' in request.data: - settings.quiet_start_time = request.data['quiet_start_time'] - - if 'quiet_end_time' in request.data: - settings.quiet_end_time = request.data['quiet_end_time'] - - settings.save() - - return Response({'status': 'success'}) - - -@api_view(['PUT']) -@permission_classes([IsAuthenticated]) -def update_category_settings(request): - """ - Обновить настройки для всех уведомлений категории - """ - category_code = request.data.get('category') - - if not category_code: - return Response( - {'status': 'error', 'message': 'Category is required'}, - status=status.HTTP_400_BAD_REQUEST - ) - - try: - category = NotificationCategory.objects.get(code=category_code) - except NotificationCategory.DoesNotExist: - return Response( - {'status': 'error', 'message': 'Category not found'}, - status=status.HTTP_404_NOT_FOUND - ) - - # Получить все типы уведомлений категории - notification_types = NotificationType.objects.filter( - category=category, - is_active=True - ) - - # Обновить настройки для каждого типа - updated_count = 0 - for notification_type in notification_types: - settings = NotificationService.get_user_settings( - request.user, notification_type - ) - - # Обновить поля - if 'is_enabled' in request.data and not notification_type.is_required: - settings.is_enabled = request.data['is_enabled'] - + elif request.method == 'PUT': + # Обновляем настройки if 'web_enabled' in request.data: - settings.send_web = request.data['web_enabled'] + prefs.web_enabled = request.data['web_enabled'] if 'email_enabled' in request.data: - settings.send_email = request.data['email_enabled'] + prefs.email_enabled = request.data['email_enabled'] if 'email_frequency' in request.data: - settings.email_frequency = request.data['email_frequency'] + frequency = request.data['email_frequency'] + if frequency in ['instant', 'daily', 'weekly', 'disabled']: + prefs.email_frequency = frequency - if 'telegram_enabled' in request.data: - settings.send_telegram = request.data['telegram_enabled'] + if 'push_enabled' in request.data: + prefs.push_enabled = request.data['push_enabled'] - settings.save() - updated_count += 1 - - return Response({ - 'status': 'success', - 'updated_count': updated_count - }) - - -@api_view(['GET']) -@permission_classes([IsAuthenticated]) -def get_telegram_link_status(request): - """Получить статус привязки Telegram аккаунта""" - from notifications.telegram_models import TelegramUser - - try: - tg_user = TelegramUser.objects.get( - user=request.user, - is_active=True, - telegram_id__isnull=False - ) + if 'dnd_enabled' in request.data: + prefs.dnd_enabled = request.data['dnd_enabled'] - return Response({ - 'is_linked': True, - 'telegram_username': tg_user.telegram_username, - 'first_name': tg_user.first_name, - 'last_name': tg_user.last_name, - 'linked_at': tg_user.linked_at, - 'is_blocked': tg_user.is_blocked, - }) - except TelegramUser.DoesNotExist: - # Проверяем, может быть есть незавершенная привязка с кодом - pending = TelegramUser.objects.filter( - user=request.user, - link_code__isnull=False - ).first() + if 'dnd_start_time' in request.data: + from datetime import datetime + time_str = request.data['dnd_start_time'] + if time_str: + prefs.dnd_start_time = datetime.strptime(time_str, '%H:%M').time() - response_data = {'is_linked': False} + if 'dnd_end_time' in request.data: + from datetime import datetime + time_str = request.data['dnd_end_time'] + if time_str: + prefs.dnd_end_time = datetime.strptime(time_str, '%H:%M').time() - if pending and pending.is_link_code_valid(): - response_data['link_code'] = pending.link_code + if 'disabled_verbs' in request.data: + prefs.disabled_verbs = request.data['disabled_verbs'] - return Response(response_data) - - -@api_view(['POST']) -@permission_classes([IsAuthenticated]) -def generate_telegram_link_code(request): - """Сгенерировать код для привязки Telegram аккаунта""" - from notifications.telegram_models import TelegramUser - - # Проверяем что аккаунт еще не привязан - existing = TelegramUser.objects.filter( - user=request.user, - is_active=True, - telegram_id__isnull=False - ).first() - - if existing: - return Response({ - 'status': 'error', - 'message': 'Telegram аккаунт уже привязан' - }, status=status.HTTP_400_BAD_REQUEST) - - # Получаем или создаем запись БЕЗ telegram_id (он будет установлен при привязке) - tg_user = TelegramUser.objects.filter( - user=request.user - ).first() - - if not tg_user: - tg_user = TelegramUser.objects.create( - user=request.user, - is_active=False - ) - - # Генерируем новый код (даже если уже был старый) - link_code = tg_user.generate_link_code() - - from django.conf import settings - bot_username = getattr(settings, 'TELEGRAM_BOT_USERNAME', 'eusrr_bot') - - return Response({ - 'status': 'success', - 'link_code': link_code, - 'bot_username': bot_username, - 'bot_link': f'https://t.me/{bot_username}', - 'expires_in_seconds': 900, # 15 минут - }) - - -@api_view(['POST']) -@permission_classes([IsAuthenticated]) -def unlink_telegram(request): - """Отвязать Telegram аккаунт""" - from notifications.telegram_models import TelegramUser - - try: - tg_user = TelegramUser.objects.get( - user=request.user, - is_active=True - ) - - tg_user.is_active = False - tg_user.save() + prefs.save() return Response({ 'status': 'success', - 'message': 'Telegram аккаунт отвязан' + 'message': 'Preferences updated' }) - except TelegramUser.DoesNotExist: - return Response({ - 'status': 'error', - 'message': 'Telegram аккаунт не привязан' - }, status=status.HTTP_404_NOT_FOUND) - - -# ============================================ -# Web Push API (для браузерных уведомлений) -# ============================================ - -from django.conf import settings -from push_notifications.models import WebPushDevice -import logging - -logger = logging.getLogger(__name__) - - -@api_view(['GET']) -@permission_classes([IsAuthenticated]) -def get_vapid_public_key(request): - """ - Получить публичный VAPID ключ для подписки на push-уведомления. - Этот ключ нужен браузеру для создания подписки. - """ - return Response({ - 'vapid_public_key': settings.VAPID_PUBLIC_KEY - }) @api_view(['POST']) @permission_classes([IsAuthenticated]) def subscribe_push(request): """ - Подписаться на Web Push уведомления. + Подписка на Web Push уведомления - Ожидаемые данные: - { - "endpoint": "https://fcm.googleapis.com/...", - "keys": { - "p256dh": "...", - "auth": "..." - }, - "user_agent": "...", // опционально - "device_name": "..." // опционально - } + Body: + - subscription: объект subscription от браузера """ - try: - endpoint = request.data.get('endpoint') - keys = request.data.get('keys', {}) - p256dh_key = keys.get('p256dh') - auth_key = keys.get('auth') - user_agent = request.data.get('user_agent', '') - device_name = request.data.get('device_name', '') or 'Unknown' - - if not endpoint or not p256dh_key or not auth_key: - return Response({ - 'status': 'error', - 'message': 'Отсутствуют обязательные поля: endpoint, keys.p256dh, keys.auth' - }, status=status.HTTP_400_BAD_REQUEST) - - # Обновляем или создаем устройство (django-push-notifications) - device, created = WebPushDevice.objects.update_or_create( - user=request.user, - registration_id=endpoint, - defaults={ - 'p256dh': p256dh_key, - 'auth': auth_key, - 'browser': device_name, - 'active': True, - } + from push_notifications.models import WebPushDevice + + subscription_data = request.data.get('subscription') + + if not subscription_data: + return Response( + {'status': 'error', 'message': 'Subscription data required'}, + status=status.HTTP_400_BAD_REQUEST ) - - # Логируем только создание нового устройства, не обновление - if created: - logger.info(f"[WebPush] New device for user {request.user.id}") - - return Response({ - 'status': 'success', - 'message': 'Подписка на push-уведомления активирована', - 'created': created - }) - - except Exception as e: - logger.exception(f"[WebPush] Error subscribing user {request.user.id}: {e}") - return Response({ - 'status': 'error', - 'message': 'Ошибка при создании подписки' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - -@api_view(['DELETE']) -@permission_classes([IsAuthenticated]) -def unsubscribe_push(request): - """ - Отписаться от Web Push уведомлений. - Ожидаемые данные: - { - "endpoint": "https://fcm.googleapis.com/..." - } - - Если endpoint не указан - отписывает от всех подписок пользователя. - """ - try: - endpoint = request.data.get('endpoint') - - if endpoint: - # Удаляем конкретное устройство - deleted, _ = WebPushDevice.objects.filter( - user=request.user, - registration_id=endpoint - ).delete() - - if deleted: - logger.info(f"[WebPush] Deleted device for user {request.user.id} (endpoint: {endpoint[:50]}...)") - return Response({ - 'status': 'success', - 'message': 'Подписка удалена' - }) - else: - return Response({ - 'status': 'error', - 'message': 'Подписка не найдена' - }, status=status.HTTP_404_NOT_FOUND) - else: - # Удаляем все устройства пользователя - deleted, _ = WebPushDevice.objects.filter(user=request.user).delete() - logger.info(f"[WebPush] Deleted all ({deleted}) devices for user {request.user.id}") - return Response({ - 'status': 'success', - 'message': f'Удалено подписок: {deleted}' - }) - - except Exception as e: - logger.exception(f"[WebPush] Error unsubscribing user {request.user.id}: {e}") - return Response({ - 'status': 'error', - 'message': 'Ошибка при удалении подписки' - }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - -@api_view(['GET']) -@permission_classes([IsAuthenticated]) -def get_push_subscriptions(request): - """ - Получить список активных push-подписок пользователя. - """ - devices = WebPushDevice.objects.filter( + # Регистрируем устройство + device, created = WebPushDevice.objects.update_or_create( user=request.user, - active=True - ).order_by('-date_created') + registration_id=subscription_data.get('endpoint'), + defaults={ + 'p256dh': subscription_data.get('keys', {}).get('p256dh'), + 'auth': subscription_data.get('keys', {}).get('auth'), + 'browser': request.data.get('browser', 'unknown'), + 'active': True, + } + ) return Response({ - 'subscriptions': [ - { - 'id': device.id, - 'device_name': device.browser or 'Неизвестное устройство', - 'user_agent': '', - 'created_at': device.date_created.isoformat(), - 'updated_at': device.date_created.isoformat(), - } - for device in devices - ] + 'status': 'success', + 'created': created, + 'device_id': device.id + }) + + +@api_view(['POST']) +@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() + + return Response({ + 'status': 'success', + 'deleted_count': deleted_count }) diff --git a/backend/notifications/services.py b/backend/notifications/services.py index 97443c03..18fa140c 100644 --- a/backend/notifications/services.py +++ b/backend/notifications/services.py @@ -1,199 +1,90 @@ """ -Слой обратной совместимости для старого API уведомлений. +DEPRECATED: Этот модуль удален в v2.0 -DEPRECATED: Этот модуль предоставляет facade для старого кода. -Новый код должен использовать напрямую: -- from notifications.signals import notify -- notify.send(sender=user, recipient=other, verb='liked', ...) +Вместо NotificationService используйте: + +1. Создание уведомлений: + + СТАРОЕ: + NotificationService.create_notification( + recipient=user, + notification_type_code='message', + title='Заголовок', + message='Текст', + action_url='/link/' + ) + + НОВОЕ: + from notifications.signals import notify + notify.send( + sender=actor, + recipient=user, + verb='messaged', + description='Текст', + action_url='/link/' + ) + +2. Отметка как прочитанное: + + СТАРОЕ: + NotificationService.mark_as_read(notification_id, user) + + НОВОЕ: + notification = Notification.objects.get(id=notification_id) + notification.mark_as_read() + +3. Настройки: + + СТАРОЕ: + settings = NotificationService.get_user_settings(user) + + НОВОЕ: + prefs = UserChannelPreferences.objects.get(user=user) + # или через related name: + prefs = user.channel_preferences + +Документация: backend/notifications/README.md """ -import logging -from typing import Optional, Dict, Any -from django.contrib.auth import get_user_model - -logger = logging.getLogger(__name__) -User = get_user_model() +# Для обратной совместимости можно оставить минимальный stub +# который будет выбрасывать ошибки с подсказками как мигрировать class NotificationService: - """ - Facade для обратной совместимости со старым API. - - Все методы теперь работают через новую систему (notify.send()). - """ + """DEPRECATED: Удален в v2.0. См. docstring модуля.""" @staticmethod - def create_notification( - recipient: User, - notification_type_code: str, - title: str, - message: str, - content_object=None, - action_url: str = '', - action_text: str = 'Посмотреть', - metadata: Optional[Dict[str, Any]] = None, - send_immediately: bool = True, - ): - """ - Создает уведомление (старый API → новая система). - - DEPRECATED: Используйте notify.send() напрямую. - - Args: - recipient: Получатель уведомления - notification_type_code: Код типа (mapping в verb) - title: Заголовок (не используется в новой системе) - message: Описание уведомления - content_object: Объект-источник (→ action_object) - action_url: URL для действия - action_text: Текст кнопки (не используется) - metadata: Дополнительные данные - send_immediately: Игнорируется (всегда через Celery) - - Returns: - Notification объект (новая модель) - """ - from .signals import notify - - # Маппинг старых типов на новые verbs - type_to_verb = { - 'message': 'messaged', - 'message_new': 'messaged', - 'document': 'shared', - 'document_new': 'shared', - 'request': 'created', - 'request_status_changed': 'updated', - 'request_comment_added': 'commented', - 'feed_post': 'posted', - 'feed_comment': 'commented', - 'feed_like': 'liked', - 'calendar_event': 'scheduled', - 'procurement': 'created', - 'procurement_approved': 'approved', - 'procurement_rejected': 'rejected', - } - - verb = type_to_verb.get(notification_type_code, 'notified') - - # Создаем через новую систему - notification = notify.send( - sender=None, # System notification - recipient=recipient, - verb=verb, - action_object=content_object, - description=message, - action_url=action_url, - ) - - # notify.send() возвращает список [(signal, [notifications])] - if notification and len(notification) > 0: - return notification[0][1][0] if notification[0][1] else None - - return None - - @staticmethod - def create_notification_async( - recipient: User, - notification_type_code: str, - title: str, - message: str, - content_object=None, - action_url: str = '', - action_text: str = 'Посмотреть', - metadata: Optional[Dict[str, Any]] = None, - ): - """ - Асинхронное создание уведомления. - - DEPRECATED: Новая система всегда асинхронна через Celery. - Этот метод теперь идентичен create_notification(). - """ - return NotificationService.create_notification( - recipient=recipient, - notification_type_code=notification_type_code, - title=title, - message=message, - content_object=content_object, - action_url=action_url, - action_text=action_text, - metadata=metadata, - send_immediately=True, + def create_notification(*args, **kwargs): + raise NotImplementedError( + "NotificationService.create_notification() удален. " + "Используйте: notify.send(sender=actor, recipient=user, verb='...', description='...')" ) @staticmethod - def mark_as_read(notification_id: int, user: User) -> bool: - """ - Отмечает уведомление как прочитанное. - - Args: - notification_id: ID уведомления - user: Пользователь (проверка владения) - - Returns: - True если успешно, False иначе - """ - try: - from .models import Notification - - notification = Notification.objects.get( - id=notification_id, - recipient=user - ) - notification.mark_as_read() - return True - - except Notification.DoesNotExist: - logger.warning(f"Notification {notification_id} not found for user {user.id}") - return False - - @staticmethod - def mark_all_as_read(user: User, category: Optional[str] = None) -> int: - """ - Отмечает все уведомления как прочитанные. - - Args: - user: Пользователь - category: Категория (не используется в новой системе) - - Returns: - Количество отмеченных уведомлений - """ - from .models import Notification - - return Notification.objects.filter( - recipient=user, - unread=True - ).mark_all_as_read() - - @staticmethod - def get_user_settings(user: User, setting_type: Optional[str] = None): - """ - Получает настройки уведомлений пользователя. - - DEPRECATED: Используйте UserChannelPreferences напрямую. - - Args: - user: Пользователь - setting_type: Тип настройки (игнорируется) - - Returns: - UserChannelPreferences объект - """ - try: - return user.channel_preferences - except Exception: - from .models import UserChannelPreferences - return UserChannelPreferences.objects.create(user=user) - - @staticmethod - def send_websocket_notification(user_id: int, notification_data: Optional[dict] = None): - """ - Отправка WebSocket уведомления. - - DEPRECATED: WebSocket уведомления отправляются автоматически - через channels.py при создании Notification. - """ - logger.warning( - "NotificationService.send_websocket_notification() is deprecated. " - "WebSocket notifications are sent automatically via channels.py" + def create_notification_async(*args, **kwargs): + raise NotImplementedError( + "NotificationService.create_notification_async() удален. " + "Все уведомления теперь асинхронны через Celery. " + "Используйте: notify.send(...)" ) - return True + + @staticmethod + def mark_as_read(*args, **kwargs): + raise NotImplementedError( + "NotificationService.mark_as_read() удален. " + "Используйте: notification.mark_as_read()" + ) + + @staticmethod + def mark_all_as_read(*args, **kwargs): + raise NotImplementedError( + "NotificationService.mark_all_as_read() удален. " + "Используйте: Notification.objects.filter(...).mark_all_as_read()" + ) + + @staticmethod + def get_user_settings(*args, **kwargs): + raise NotImplementedError( + "NotificationService.get_user_settings() удален. " + "Используйте: user.channel_preferences или UserChannelPreferences.objects.get(user=user)" + ) +