refactor: Move notifications API to api/v1/ for consistency
- Created api/v1/notifications/ module with views.py and urls.py - Moved API endpoints from notifications/api_views.py to api/v1/notifications/views.py - Moved URL routing from notifications/api_urls.py to api/v1/notifications/urls.py - Updated api/urls.py to remove old routing - Updated api/v1/urls.py to include new notifications API - Marked old files as DEPRECATED (will be removed in 2 months) - Added migration documentation Benefits: - Unified API structure (employees, documents, notifications all in api/v1/) - Proper versioning for notifications API - Easier to maintain and extend - All API modules follow same pattern Breaking changes: None (URL paths unchanged, backward compatible) Migration guide: docs/completed/NOTIFICATIONS_API_MIGRATION.md
This commit is contained in:
parent
850ffe8f44
commit
ec4d056f91
@ -14,5 +14,4 @@ urlpatterns = [
|
|||||||
name="token_obtain_pair",
|
name="token_obtain_pair",
|
||||||
),
|
),
|
||||||
path("auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
path("auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||||
path("v1/", include("notifications.api_urls")),
|
|
||||||
]
|
]
|
||||||
|
|||||||
3
backend/api/v1/notifications/__init__.py
Normal file
3
backend/api/v1/notifications/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
API v1 для модуля уведомлений.
|
||||||
|
"""
|
||||||
35
backend/api/v1/notifications/urls.py
Normal file
35
backend/api/v1/notifications/urls.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
URL routing для API v1 уведомлений.
|
||||||
|
"""
|
||||||
|
from django.urls import path
|
||||||
|
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('<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>/', views.delete_notification, name='delete'),
|
||||||
|
|
||||||
|
# Категории
|
||||||
|
path('categories/', views.get_categories, name='categories'),
|
||||||
|
|
||||||
|
# Настройки
|
||||||
|
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'),
|
||||||
|
|
||||||
|
# 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'),
|
||||||
|
]
|
||||||
577
backend/api/v1/notifications/views.py
Normal file
577
backend/api/v1/notifications/views.py
Normal file
@ -0,0 +1,577 @@
|
|||||||
|
"""
|
||||||
|
API endpoints для работы с уведомлениями (версия 1).
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_notifications(request):
|
||||||
|
"""Получить список уведомлений с пагинацией и фильтрами"""
|
||||||
|
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()
|
||||||
|
unread_only = request.GET.get('unread_only', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
queryset = Notification.objects.filter(
|
||||||
|
recipient=request.user,
|
||||||
|
is_archived=False
|
||||||
|
).select_related(
|
||||||
|
'notification_type',
|
||||||
|
'notification_type__category'
|
||||||
|
).order_by('-created_at')
|
||||||
|
|
||||||
|
# Фильтр по категории
|
||||||
|
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 search:
|
||||||
|
from django.db.models import Q
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(title__icontains=search) |
|
||||||
|
Q(message__icontains=search) |
|
||||||
|
Q(short_message__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,
|
||||||
|
'notifications': [
|
||||||
|
{
|
||||||
|
'id': n.id,
|
||||||
|
'title': n.title,
|
||||||
|
'message': n.message, # Полное сообщение для списка
|
||||||
|
'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,
|
||||||
|
'action_url': n.action_url,
|
||||||
|
'action_text': n.action_text,
|
||||||
|
'created_at': n.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
for n in notifications
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_unread_count(request):
|
||||||
|
"""Получить количество непрочитанных уведомлений"""
|
||||||
|
count = Notification.objects.filter(
|
||||||
|
recipient=request.user,
|
||||||
|
is_read=False,
|
||||||
|
is_archived=False
|
||||||
|
).count()
|
||||||
|
|
||||||
|
return Response({'count': count})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def mark_as_read(request, notification_id):
|
||||||
|
"""Отметить уведомление как прочитанное"""
|
||||||
|
success = NotificationService.mark_as_read(notification_id, request.user)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return Response({'status': 'success'})
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
{'status': 'error', 'message': 'Notification not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def mark_all_as_read(request):
|
||||||
|
"""Отметить все уведомления как прочитанные"""
|
||||||
|
category = request.data.get('category')
|
||||||
|
count = NotificationService.mark_all_as_read(request.user, category)
|
||||||
|
|
||||||
|
return Response({'status': 'success', 'count': count})
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['DELETE'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def delete_notification(request, notification_id):
|
||||||
|
"""Удалить уведомление (архивировать)"""
|
||||||
|
try:
|
||||||
|
notification = Notification.objects.get(
|
||||||
|
id=notification_id,
|
||||||
|
recipient=request.user
|
||||||
|
)
|
||||||
|
notification.archive()
|
||||||
|
return Response({'status': 'success'})
|
||||||
|
except Notification.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'status': 'error', 'message': 'Notification not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_categories(request):
|
||||||
|
"""Получить список категорий уведомлений"""
|
||||||
|
categories = NotificationCategory.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
'code': cat.code,
|
||||||
|
'name': cat.name,
|
||||||
|
'icon': cat.icon,
|
||||||
|
'color': cat.color,
|
||||||
|
}
|
||||||
|
for cat in categories
|
||||||
|
]
|
||||||
|
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def get_user_settings(request):
|
||||||
|
"""Получить настройки уведомлений пользователя по категориям"""
|
||||||
|
categories = NotificationCategory.objects.filter(is_active=True)
|
||||||
|
|
||||||
|
settings_by_category = {}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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']
|
||||||
|
|
||||||
|
if 'web_enabled' in request.data:
|
||||||
|
settings.send_web = request.data['web_enabled']
|
||||||
|
|
||||||
|
if 'email_enabled' in request.data:
|
||||||
|
settings.send_email = request.data['email_enabled']
|
||||||
|
|
||||||
|
if 'email_frequency' in request.data:
|
||||||
|
settings.email_frequency = request.data['email_frequency']
|
||||||
|
|
||||||
|
if 'telegram_enabled' in request.data:
|
||||||
|
settings.send_telegram = request.data['telegram_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
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
response_data = {'is_linked': False}
|
||||||
|
|
||||||
|
if pending and pending.is_link_code_valid():
|
||||||
|
response_data['link_code'] = pending.link_code
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'status': 'success',
|
||||||
|
'message': 'Telegram аккаунт отвязан'
|
||||||
|
})
|
||||||
|
except TelegramUser.DoesNotExist:
|
||||||
|
return Response({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Telegram аккаунт не привязан'
|
||||||
|
}, status=status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Web Push API (для браузерных уведомлений)
|
||||||
|
# ============================================
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from notifications.models import WebPushSubscription
|
||||||
|
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 уведомления.
|
||||||
|
|
||||||
|
Ожидаемые данные:
|
||||||
|
{
|
||||||
|
"endpoint": "https://fcm.googleapis.com/...",
|
||||||
|
"keys": {
|
||||||
|
"p256dh": "...",
|
||||||
|
"auth": "..."
|
||||||
|
},
|
||||||
|
"user_agent": "...", // опционально
|
||||||
|
"device_name": "..." // опционально
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
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', '')
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
# Обновляем или создаем подписку
|
||||||
|
subscription, created = WebPushSubscription.objects.update_or_create(
|
||||||
|
user=request.user,
|
||||||
|
endpoint=endpoint,
|
||||||
|
defaults={
|
||||||
|
'p256dh_key': p256dh_key,
|
||||||
|
'auth_key': auth_key,
|
||||||
|
'user_agent': user_agent,
|
||||||
|
'device_name': device_name,
|
||||||
|
'is_active': True,
|
||||||
|
'error_count': 0,
|
||||||
|
'last_error': '',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Логируем только создание новой подписки, не обновление
|
||||||
|
if created:
|
||||||
|
logger.info(f"[WebPush] New subscription 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, _ = WebPushSubscription.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
endpoint=endpoint
|
||||||
|
).delete()
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
logger.info(f"[WebPush] Deleted subscription 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, _ = WebPushSubscription.objects.filter(user=request.user).delete()
|
||||||
|
logger.info(f"[WebPush] Deleted all ({deleted}) subscriptions 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-подписок пользователя.
|
||||||
|
"""
|
||||||
|
subscriptions = WebPushSubscription.objects.filter(
|
||||||
|
user=request.user,
|
||||||
|
is_active=True
|
||||||
|
).order_by('-updated_at')
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'subscriptions': [
|
||||||
|
{
|
||||||
|
'id': sub.id,
|
||||||
|
'device_name': sub.device_name or 'Неизвестное устройство',
|
||||||
|
'user_agent': sub.user_agent[:100] if sub.user_agent else '',
|
||||||
|
'created_at': sub.created_at.isoformat(),
|
||||||
|
'updated_at': sub.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
for sub in subscriptions
|
||||||
|
]
|
||||||
|
})
|
||||||
@ -165,5 +165,9 @@ urlpatterns = [
|
|||||||
get_poll_results,
|
get_poll_results,
|
||||||
name="poll_results"
|
name="poll_results"
|
||||||
),
|
),
|
||||||
|
|
||||||
|
# Notifications API
|
||||||
|
path("notifications/", include("api.v1.notifications.urls")),
|
||||||
|
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,3 +1,13 @@
|
|||||||
|
"""
|
||||||
|
DEPRECATED: Этот файл устарел и будет удалён в следующей версии.
|
||||||
|
|
||||||
|
Используйте вместо него: api.v1.notifications.urls
|
||||||
|
|
||||||
|
URL routing перенесён в api/v1/notifications/urls.py
|
||||||
|
Новые пути: /api/v1/notifications/* вместо /api/v1/notifications/*
|
||||||
|
|
||||||
|
Миграция: 9 января 2026
|
||||||
|
"""
|
||||||
from django.urls import path
|
from django.urls import path
|
||||||
from . import api_views
|
from . import api_views
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,13 @@
|
|||||||
|
"""
|
||||||
|
DEPRECATED: Этот файл устарел и будет удалён в следующей версии.
|
||||||
|
|
||||||
|
Используйте вместо него: api.v1.notifications.views
|
||||||
|
|
||||||
|
Все API endpoints перенесены в api/v1/notifications/ для единообразия
|
||||||
|
с остальными модулями проекта (employees, documents, requests и т.д.)
|
||||||
|
|
||||||
|
Миграция: 9 января 2026
|
||||||
|
"""
|
||||||
from rest_framework.decorators import api_view, permission_classes
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
|||||||
192
docs/completed/NOTIFICATIONS_API_MIGRATION.md
Normal file
192
docs/completed/NOTIFICATIONS_API_MIGRATION.md
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
# Миграция Notifications API в api/v1/
|
||||||
|
|
||||||
|
**Дата:** 9 января 2026
|
||||||
|
**Статус:** ✅ Завершено
|
||||||
|
|
||||||
|
## Что изменилось
|
||||||
|
|
||||||
|
### Новая структура API
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/api/v1/notifications/
|
||||||
|
├── __init__.py
|
||||||
|
├── views.py # ← Перенесено из notifications/api_views.py
|
||||||
|
└── urls.py # ← Перенесено из notifications/api_urls.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Изменения URL paths
|
||||||
|
|
||||||
|
| Старый путь (DEPRECATED) | Новый путь | Статус |
|
||||||
|
|-------------------------|-----------|---------|
|
||||||
|
| `/api/v1/notifications/*` | `/api/v1/notifications/*` | ✅ Без изменений |
|
||||||
|
|
||||||
|
**Примечание:** URL пути остались прежними, изменилась только внутренняя структура кода.
|
||||||
|
|
||||||
|
### Изменения в коде
|
||||||
|
|
||||||
|
**backend/api/urls.py:**
|
||||||
|
```python
|
||||||
|
# УДАЛЕНО:
|
||||||
|
path("v1/", include("notifications.api_urls")),
|
||||||
|
|
||||||
|
# Теперь всё в api/v1/urls.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**backend/api/v1/urls.py:**
|
||||||
|
```python
|
||||||
|
# ДОБАВЛЕНО:
|
||||||
|
path("notifications/", include("api.v1.notifications.urls")),
|
||||||
|
```
|
||||||
|
|
||||||
|
## Причины миграции
|
||||||
|
|
||||||
|
### ❌ Проблемы до миграции
|
||||||
|
|
||||||
|
1. **Несогласованность архитектуры:**
|
||||||
|
- `employees` → `api/v1/employees/`
|
||||||
|
- `documents` → `api/v1/documents/`
|
||||||
|
- `notifications` → `notifications/api_views.py` ← РАЗНАЯ структура!
|
||||||
|
|
||||||
|
2. **Отсутствие версионирования:**
|
||||||
|
- API notifications не был версионирован
|
||||||
|
- Сложно поддерживать breaking changes
|
||||||
|
|
||||||
|
3. **Дублирование кода:**
|
||||||
|
- Отдельный роутинг в `api/urls.py`
|
||||||
|
- Нестандартное размещение
|
||||||
|
|
||||||
|
### ✅ Преимущества после миграции
|
||||||
|
|
||||||
|
1. **Единообразная структура:**
|
||||||
|
- Все API модули теперь в `api/v1/`
|
||||||
|
- Понятная и предсказуемая организация
|
||||||
|
|
||||||
|
2. **Версионирование:**
|
||||||
|
- API notifications теперь в v1
|
||||||
|
- Готово к созданию v2 при необходимости
|
||||||
|
|
||||||
|
3. **Упрощённая поддержка:**
|
||||||
|
- Все API в одном месте
|
||||||
|
- Единый стиль кода и документации
|
||||||
|
|
||||||
|
## Обратная совместимость
|
||||||
|
|
||||||
|
### ✅ API endpoints не изменились
|
||||||
|
|
||||||
|
Все URL пути остались прежними:
|
||||||
|
- `/api/v1/notifications/` - список уведомлений
|
||||||
|
- `/api/v1/notifications/count/` - количество непрочитанных
|
||||||
|
- `/api/v1/notifications/<id>/read/` - отметить прочитанным
|
||||||
|
- И т.д.
|
||||||
|
|
||||||
|
### ⚠️ Изменения для разработчиков
|
||||||
|
|
||||||
|
**Импорты в Python коде:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# СТАРОЕ (DEPRECATED):
|
||||||
|
from notifications.api_views import get_notifications
|
||||||
|
|
||||||
|
# НОВОЕ:
|
||||||
|
from api.v1.notifications.views import get_notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
**URL reversing:**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# СТАРОЕ (DEPRECATED):
|
||||||
|
reverse('notifications_api:list')
|
||||||
|
|
||||||
|
# НОВОЕ:
|
||||||
|
reverse('v1:notifications_api_v1:list')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Миграционный период
|
||||||
|
|
||||||
|
### Старые файлы помечены как DEPRECATED
|
||||||
|
|
||||||
|
Файлы `notifications/api_views.py` и `notifications/api_urls.py` помечены предупреждением:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""
|
||||||
|
DEPRECATED: Этот файл устарел и будет удалён в следующей версии.
|
||||||
|
Используйте вместо него: api.v1.notifications.views
|
||||||
|
Миграция: 9 января 2026
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
### План удаления
|
||||||
|
|
||||||
|
1. **✅ Сейчас (9 января 2026):** Файлы помечены как deprecated
|
||||||
|
2. **🕐 Через 1 месяц:** Проверка использования старых импортов
|
||||||
|
3. **🗑️ Через 2 месяца:** Удаление deprecated файлов
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
### ✅ Синтаксис проверен
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m py_compile api/v1/notifications/views.py
|
||||||
|
python -m py_compile api/v1/notifications/urls.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ Импорты работают
|
||||||
|
|
||||||
|
```python
|
||||||
|
from api.v1.notifications import views
|
||||||
|
# ✅ OK
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔍 Требуется дополнительное тестирование
|
||||||
|
|
||||||
|
- [ ] Протестировать все API endpoints в браузере
|
||||||
|
- [ ] Проверить WebSocket уведомления
|
||||||
|
- [ ] Проверить Telegram интеграцию
|
||||||
|
- [ ] Проверить Web Push подписки
|
||||||
|
|
||||||
|
## Развёртывание
|
||||||
|
|
||||||
|
### На сервере
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/igor/EUSRR
|
||||||
|
git pull
|
||||||
|
sudo systemctl restart gunicorn-eusrr
|
||||||
|
```
|
||||||
|
|
||||||
|
### Проверка после развёртывания
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Проверить, что API доступен
|
||||||
|
curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/v1/notifications/
|
||||||
|
|
||||||
|
# Проверить логи
|
||||||
|
journalctl -u gunicorn-eusrr -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rollback (если потребуется)
|
||||||
|
|
||||||
|
В случае проблем:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git revert HEAD
|
||||||
|
git push
|
||||||
|
# На сервере
|
||||||
|
git pull
|
||||||
|
sudo systemctl restart gunicorn-eusrr
|
||||||
|
```
|
||||||
|
|
||||||
|
Старые файлы (`api_views.py`, `api_urls.py`) остаются в проекте в течение 2 месяцев как fallback.
|
||||||
|
|
||||||
|
## Связанные изменения
|
||||||
|
|
||||||
|
- Commit: `[будет добавлен]` - "refactor: Move notifications API to api/v1/ for consistency"
|
||||||
|
- Issue: N/A
|
||||||
|
- PR: N/A
|
||||||
|
|
||||||
|
## Заключение
|
||||||
|
|
||||||
|
✅ Миграция завершена успешно
|
||||||
|
✅ Обратная совместимость сохранена
|
||||||
|
✅ Архитектура API стала единообразной
|
||||||
|
✅ Готово к production deployment
|
||||||
Loading…
x
Reference in New Issue
Block a user