refactor: Переписан API v1 и services для новой архитектуры
BREAKING CHANGES: - API полностью переписан под новую модель (verb вместо category/type) - NotificationService заменен на stub с NotImplementedError - Удалена обратная совместимость API endpoints (обновлены): - GET /api/v1/notifications/ - новый формат ответа - GET /api/v1/notifications/verb-types/ - вместо categories - GET/PUT /api/v1/notifications/preferences/ - вместо settings - POST /api/v1/notifications/<id>/unread/ - новый endpoint - DELETE /api/v1/notifications/delete-all-read/ - новый endpoint Удалены endpoints: - /api/v1/notifications/categories/ (нет категорий в v2) - /api/v1/notifications/settings/* (заменено на preferences) - /api/v1/notifications/telegram/* (Telegram отключен) - /api/v1/notifications/push/vapid-key/ (не нужен) - /api/v1/notifications/push/subscriptions/ (не нужен) services.py: - Заменен на stub с NotImplementedError - Все методы выбрасывают исключения с подсказками миграции - Документация как мигрировать со старого API Новая структура ответа API: - verb вместо category/type - actor (GenericForeignKey) вместо sender - description вместо message/title - timesince() вместо created_at - unread вместо is_read Старые файлы: - Удалены все *.old файлы
This commit is contained in:
parent
d5b4ae9f43
commit
aa614bb4c1
@ -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('<int:notification_id>/read/', views.mark_as_read, name='mark_read'),
|
||||
path('read-all/', views.mark_all_as_read, name='mark_all_read'),
|
||||
path('<int:notification_id>/unread/', views.mark_as_unread, name='mark_unread'),
|
||||
path('<int:notification_id>/', 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'),
|
||||
]
|
||||
|
||||
@ -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
|
||||
})
|
||||
|
||||
@ -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)"
|
||||
)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user